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<string, unknown> => 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<object | null> {
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" } });
}