import "server-only"; import { CiError } from "../ci"; import { GitError } from "../git"; import { IssueError, LabelError } from "../issues"; import { ReleaseError } from "../releases"; import { RepoError } from "../repos"; import { WikiError } from "../wiki"; import { ToolError } from "./params"; import { TOOLS, type ToolContext } from "./tools"; /* * The Model Context Protocol over Streamable HTTP, statelessly: every POST carries one JSON-RPC * message (or, before protocol 2025-06-18, a batch), and the answer comes back as plain JSON. No * sessions and no server-sent events, since every tool answers right away. */ const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; const INSTRUCTIONS = `Vectrum is a self-hosted git forge, like GitHub. Repositories are addressed by owner and repo name. - Read code with list_files and read_file; change it with commit_files, which makes one commit with any number of files on a branch (creating the branch if needed). Pushing a commit starts the repository's workflows. - Issues work like Linear: statuses backlog, todo, in_progress, in_review, done, canceled; priorities 0 (none), 1 (urgent) to 4 (low). - Releases belong to a tag; publishing one for a tag that doesn't exist creates the tag. Deleting a release keeps its tag. To attach a file on disk (a build output), get a URL with create_release_upload_url and run the curl command it returns; upload_release_asset is for small content you already have. - Workflows live in .vectrum/workflows/*.yml and run on self-hosted runners. Check results with list_workflow_runs, get_workflow_run and get_job_logs. What you can reach depends on what the user approved: read-only or read & write, for all their repositories or selected ones.`; class RpcError extends Error { constructor( readonly code: number, message: string, ) { super(message); } } type Id = string | number | null; /** What the lib functions throw about bad input or state, worded for the user. */ const USER_ERRORS = [ToolError, GitError, RepoError, CiError, IssueError, LabelError, ReleaseError, WikiError]; const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); function initialize(params: unknown) { const requested = isObject(params) ? params.protocolVersion : undefined; return { protocolVersion: typeof requested === "string" && PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, serverInfo: { name: "vectrum", title: "Vectrum", version: "1.0.0" }, instructions: INSTRUCTIONS, }; } /** Read-only connections only see the tools they can use. */ const availableTools = (ctx: ToolContext) => TOOLS.filter((t) => t.access === "read" || ctx.scope.permission === "write"); function listTools(ctx: ToolContext) { return availableTools(ctx).map((t) => ({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: { title: t.title, readOnlyHint: t.access === "read", destructiveHint: t.access !== "read" && !!t.destructive, idempotentHint: t.access === "read" || !!t.idempotent, openWorldHint: false, }, })); } async function callTool(params: unknown, ctx: ToolContext) { if (!isObject(params) || typeof params.name !== "string") throw new RpcError(-32602, "tools/call needs a tool name."); const tool = TOOLS.find((t) => t.name === params.name); if (!tool) throw new RpcError(-32602, `Unknown tool: ${params.name}`); try { if (tool.access !== "read" && ctx.scope.permission !== "write") { throw new ToolError("This connection is read-only. Reconnect with read & write access to make changes."); } const result = await tool.call(params.arguments, ctx); return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; } catch (e) { // Errors the agent can act on come back as the tool's result, not as protocol errors. if (e instanceof Error && USER_ERRORS.some((type) => e instanceof type)) { return { content: [{ type: "text", text: e.message }], isError: true }; } throw e; } } async function handleMessage(message: unknown, ctx: ToolContext): Promise { if (!isObject(message) || message.jsonrpc !== "2.0") return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid request" } }; // Responses (this server never asks the client anything) and notifications get no answer. if (typeof message.method !== "string" || message.id === undefined) return null; const id = message.id as Id; try { switch (message.method) { case "initialize": return { jsonrpc: "2.0", id, result: initialize(message.params) }; case "ping": return { jsonrpc: "2.0", id, result: {} }; case "tools/list": return { jsonrpc: "2.0", id, result: { tools: listTools(ctx) } }; case "tools/call": return { jsonrpc: "2.0", id, result: await callTool(message.params, ctx) }; default: throw new RpcError(-32601, `Method not found: ${message.method}`); } } catch (e) { if (e instanceof RpcError) return { jsonrpc: "2.0", id, error: { code: e.code, message: e.message } }; console.error(`[mcp] ${message.method} failed:`, e); return { jsonrpc: "2.0", id, error: { code: -32603, message: "Internal error" } }; } } /** Answers a POST to /mcp from an authenticated agent. */ export async function handleMcpPost(request: Request, ctx: ToolContext) { const version = request.headers.get("mcp-protocol-version"); if (version && !PROTOCOL_VERSIONS.includes(version)) { return Response.json( { jsonrpc: "2.0", id: null, error: { code: -32600, message: `Unsupported protocol version ${version}. Supported: ${PROTOCOL_VERSIONS.join(", ")}.` } }, { status: 400 }, ); } let body: unknown; try { body = await request.json(); } catch { return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, { status: 400 }); } const messages = Array.isArray(body) ? body : [body]; const answers = (await Promise.all(messages.map((m) => handleMessage(m, ctx)))).filter((a) => a !== null); if (answers.length === 0) return new Response(null, { status: 202 }); return Response.json(Array.isArray(body) ? answers : answers[0], { headers: { "Cache-Control": "no-store" } }); }