jevstrudel.git / worker / src / mcp-rpc.ts

MCP over HTTP, the JSON-RPC half: one message per POST, answered as JSON (Streamable HTTP without the optional SSE stream). Shared by the dev hub's /mcp (mcp.ts) and the hosted, OAuth-protected /jev/mcp (hosted-mcp.ts); each supplies its tools and what calling one does.

Both eras of the protocol are served, statelessly (there are no sessions to keep in either): a legacy client opens with initialize (2025-11-25 and before, what today's SDKs send) and gets the version it asked for if we speak it; a modern one (2026-07-28) carries its version in every request's _meta and may ask server/discover first. An unsupported version gets UnsupportedProtocolVersionError, which names the supported. Every result says resultType: "complete" (modern clients need it, legacy ones ignore it) and names the server in _meta.

15export const MODERN_VERSION = '2026-07-28';
16export const SUPPORTED_VERSIONS = [MODERN_VERSION, '2025-11-25', '2025-06-18', '2025-03-26'];
17const VERSION_META = 'io.modelcontextprotocol/protocolVersion';
18const SERVER_INFO_META = 'io.modelcontextprotocol/serverInfo';
19export const UNSUPPORTED_VERSION = -32022;
21export type Tool = { name: string; description: string; inputSchema: Record<string, unknown> };
22export type ToolResult = { text: string; isError?: boolean };
23export type Server = {
24  name: string;
25  version: string;
26  instructions?: string;
27  tools: Tool[];
28  // A tool's answer. Throwing an Error answers it as a tool error (isError),
29  // which the model reads; a `Response` thrown is sent as is (a scope challenge).
30  call(name: string, args: Record<string, unknown>): Promise<ToolResult | string>;
31};
32
33type Message = { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
34
35const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
36const rpcError = (id: unknown, code: number, message: string, data?: unknown, status = 200) =>
37  Response.json(
38    { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } },
39    { status, headers: { 'Cache-Control': 'no-store' } },
40  );

The version a request is in: its _meta's, else the header's; undefined when neither says (a legacy request after initialize).

44function requestedVersion(request: Request, params: Record<string, unknown>): string | undefined {
45  const meta = isObject(params._meta) ? params._meta : {};
46  if (typeof meta[VERSION_META] === 'string') return meta[VERSION_META] as string;
47  return request.headers.get('MCP-Protocol-Version') ?? undefined;
48}

The body, at most maxBytes, parsed; or the JSON-RPC error to send.

51async function readMessage(request: Request, maxBytes: number): Promise<Message | Response> {
52  const declared = Number(request.headers.get('Content-Length'));
53  if (Number.isFinite(declared) && declared > maxBytes) return rpcError(null, -32600, 'request too large', undefined, 413);
54  const reader = request.body?.getReader();
55  const chunks: Uint8Array[] = [];
56  let length = 0;
57  if (reader) {
58    for (;;) {
59      const { done, value } = await reader.read();
60      if (done) break;
61      length += value.byteLength;
62      if (length > maxBytes) {
63        await reader.cancel();
64        return rpcError(null, -32600, 'request too large', undefined, 413);
65      }
66      chunks.push(value);
67    }
68  }
69  const bytes = new Uint8Array(length);
70  let at = 0;
71  for (const c of chunks) {
72    bytes.set(c, at);
73    at += c.byteLength;
74  }
75  try {
76    const message = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
77    if (!isObject(message)) return rpcError(null, -32600, 'one JSON-RPC message per request');
78    return message;
79  } catch {
80    return rpcError(null, -32700, 'parse error');
81  }
82}
84export async function serveRpc(request: Request, server: Server, { maxBytes }: { maxBytes: number }): Promise<Response> {
85  if (request.method !== 'POST') {
86    return new Response('POST only', { status: 405, headers: { Allow: 'POST', 'Cache-Control': 'no-store' } });
87  }
88  const read = await readMessage(request, maxBytes);
89  if (read instanceof Response) return read;
90  const message = read;
91  // Notifications (no id) and responses are acknowledged without a body.
92  if (message.id === undefined || typeof message.method !== 'string') return new Response(null, { status: 202 });
93  const id = message.id;
94  if (typeof id !== 'string' && typeof id !== 'number') return rpcError(null, -32600, 'id is a string or a number');
95  const params = isObject(message.params) ? message.params : {};
96  const meta = { [SERVER_INFO_META]: { name: server.name, version: server.version } };
97  const ok = (result: Record<string, unknown>) =>
98    Response.json(
99      { jsonrpc: '2.0', id, result: { resultType: 'complete', ...result, _meta: meta } },
100      { headers: { 'Cache-Control': 'no-store' } },
101    );
102
103  if (message.method === 'initialize') {
104    // legacy: the client's version if we speak it, else our newest legacy one
105    const asked = typeof params.protocolVersion === 'string' ? params.protocolVersion : '';
106    const legacy = SUPPORTED_VERSIONS.filter((v) => v !== MODERN_VERSION);
107    return ok({
108      protocolVersion: legacy.includes(asked) ? asked : legacy[0],
109      capabilities: { tools: {} },
110      serverInfo: { name: server.name, version: server.version },
111      ...(server.instructions ? { instructions: server.instructions } : {}),
112    });
113  }
114  const version = requestedVersion(request, params);
115  if (version !== undefined && !SUPPORTED_VERSIONS.includes(version)) {
116    return rpcError(id, UNSUPPORTED_VERSION, 'Unsupported protocol version', {
117      supported: SUPPORTED_VERSIONS,
118      requested: version,
119    }, 400);
120  }
121
122  switch (message.method) {
123    case 'server/discover':
124      return ok({
125        supportedVersions: SUPPORTED_VERSIONS,
126        capabilities: { tools: {} },
127        ...(server.instructions ? { instructions: server.instructions } : {}),
128        ttlMs: 3_600_000,
129        cacheScope: 'public',
130      });
131    case 'ping':
132      return ok({});
133    case 'tools/list':
134      return ok({ tools: server.tools, ttlMs: 300_000, cacheScope: 'public' });
135    case 'tools/call': {
136      const name = params.name;
137      const args = isObject(params.arguments) ? params.arguments : {};
138      if (typeof name !== 'string' || !server.tools.some((t) => t.name === name)) {
139        return rpcError(id, -32602, `unknown tool ${String(name)}`);
140      }
141      try {
142        const result = await server.call(name, args);
143        const { text, isError = false } = typeof result === 'string' ? { text: result } : result;
144        return ok({ content: [{ type: 'text', text }], isError });
145      } catch (e) {
146        if (e instanceof Response) return e;
147        return ok({ content: [{ type: 'text', text: (e as Error).message }], isError: true });
148      }
149    }
150    default:
151      return rpcError(id, -32601, `method not found: ${message.method}`);
152  }
153}