Vectrum
Sign In
133 lines · 6.1 KB · TypeScript
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 { 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.
- 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];

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" } });
}