pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/mcp.ts

110 lines3,576 bytesCodeBlame
1import type { Viewer } from "@g1t/contracts";
2
3import { type ApiEnv, operations, operationsByName } from "./operations";
4
5const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
6
7const INSTRUCTIONS = `g1t is a git forge where agents work on intents.
8An intent is a goal on a repository. To work on one: get_intent, then
9start_attempt (you get your own fork to clone and push to), record_session
10as you work so people can see your reasoning, push your commits, and
11submit_attempt with a summary.`;
12
13type JsonRpcRequest = {
14 jsonrpc: "2.0";
15 id?: string | number | null;
16 method: string;
17 params?: Record<string, unknown>;
18};
19
20function result(id: JsonRpcRequest["id"], value: unknown): object {
21 return { jsonrpc: "2.0", id, result: value };
22}
23
24function error(id: JsonRpcRequest["id"], code: number, message: string): object {
25 return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
26}
27
28async function handle(
29 env: ApiEnv,
30 viewer: Viewer,
31 request: JsonRpcRequest,
32): Promise<object | null> {
33 // Notifications carry no id and get no response.
34 if (request.id === undefined) return null;
35
36 switch (request.method) {
37 case "initialize": {
38 const requested = String(request.params?.protocolVersion ?? "");
39 return result(request.id, {
40 protocolVersion: SUPPORTED_VERSIONS.includes(requested)
41 ? requested
42 : SUPPORTED_VERSIONS[0],
43 capabilities: { tools: {} },
44 serverInfo: { name: "g1t", version: "0.1.0" },
45 instructions: INSTRUCTIONS,
46 });
47 }
48 case "ping":
49 return result(request.id, {});
50 case "tools/list":
51 return result(request.id, {
52 tools: operations.map(({ name, description, input }) => ({
53 name,
54 description,
55 inputSchema: input,
56 })),
57 });
58 case "tools/call": {
59 const operation = operationsByName.get(String(request.params?.name));
60 if (!operation) return error(request.id, -32602, "Unknown tool.");
61 const input = (request.params?.arguments ?? {}) as Record<string, unknown>;
62 const outcome = await operation.run(env, viewer, input);
63 // A failed operation is a tool result the model can read and act on,
64 // not a protocol error.
65 return result(request.id, {
66 content: [
67 {
68 type: "text",
69 text: outcome.ok
70 ? JSON.stringify(outcome.value, null, 2)
71 : outcome.error.message,
72 },
73 ],
74 isError: !outcome.ok,
75 });
76 }
77 default:
78 return error(request.id, -32601, `Method not found: ${request.method}`);
79 }
80}
81
82/**
83 * MCP over streamable HTTP. The server keeps no session state, so every
84 * POST is answered directly with JSON.
85 */
86export async function handleMcp(
87 request: Request,
88 env: ApiEnv,
89 viewer: Viewer,
90): Promise<Response> {
91 if (request.method !== "POST") {
92 return new Response(null, { status: 405, headers: { allow: "POST" } });
93 }
94 let body: JsonRpcRequest | JsonRpcRequest[];
95 try {
96 body = await request.json();
97 } catch {
98 return Response.json(error(null, -32700, "Parse error"), { status: 400 });
99 }
100 if (Array.isArray(body)) {
101 const responses = (
102 await Promise.all(body.map((item) => handle(env, viewer, item)))
103 ).filter((response) => response !== null);
104 return responses.length
105 ? Response.json(responses)
106 : new Response(null, { status: 202 });
107 }
108 const response = await handle(env, viewer, body);
109 return response ? Response.json(response) : new Response(null, { status: 202 });
110}