import "server-only";
import { CiError } from "../ci";
import { GitError } from "../git";
import { IssueError, LabelError } from "../issues";
import { ProtectionError } from "../protection";
import { PullError } from "../pulls";
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: tools that wait (wait_for_checks, list_events) hold the
* request open until they have their answer.
*/
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, built for agents to work in and people to review. Repositories are addressed by owner and repo name. You are an agent acting for the user who connected you: what you change is recorded as yours.
- Start with get_my_tasks (issues assigned to you, your open pull requests and what they wait for) and get_repository (it includes the repository's agent instructions and where you may push).
- Where you may push depends on the repository (get_repository's agent_push): usually wherever your user may, including the default branch. Everything you push is labeled as yours and can be reverted by your user in one click. For changes that want review, work on agent/<topic> and open a pull request with create_pull_request; "Fixes #12" in its description links the issue. Protected branches only change through pull requests.
- To change a repository you can't push to, fork it (fork_repository), push to the fork, and open the pull request on the original with create_pull_request and head_repo. sync_fork brings a fork up to date.
- Read code with list_files, read_file and search_code. Change it with edit_files (exact replacements), apply_patch (a unified diff) or commit_files (whole files); each makes one commit, creating the branch if needed, and starts the repository's workflows.
- After pushing, call wait_for_checks instead of polling; it returns the end of the logs of failed steps. wait_for_workflow_run waits for one run.
- Reviews: get_pull_request lists reviews, unresolved review threads and what blocks merging. Answer threads with reply_to_review_comment (resolve them once addressed) and push fixes to the same branch. Approvals come from people; merge_pull_request works for agents only where the rules allow it.
- list_events returns what happened in your repositories after a cursor (pushes, issues, comments, pull requests, reviews, finished runs), and can wait for the next event.
- Issues work like Linear: statuses backlog, todo, in_progress, in_review, done, canceled; priorities 0 (none), 1 (urgent) to 4 (low). assignee "me" is you.
- Releases belong to a tag; publishing one for a tag that doesn't exist creates the tag. To attach a file on disk, get a URL with create_release_upload_url and run the curl command it returns.
- Workflows live in .vectrum/workflows/*.yml and run on self-hosted runners, on push, pull_request, issues, issue_comment and by hand.
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, PullError, ProtectionError];
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 scoped = { ...ctx, signal: request.signal };
const answers = (await Promise.all(messages.map((m) => handleMessage(m, scoped)))).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" } });
}