jevstrudel.git / tools / mcp / strudel-mcp.mjs
1#!/usr/bin/env node
2// The MCP server Claude Code starts from .mcp.json (`nix run .#mcp`): MCP
3// over stdio, newline-delimited JSON-RPC, forwarded message by message to a
4// jevstrudel MCP endpoint (proxy.mjs). By default that is the dev Worker's
5// (POST /mcp, worker/src/mcp.ts) that `buck2 run //:dev` runs under
6// `wrangler dev`; the `use_environment` tool switches to the public site's
7// hosted MCP, signed in with OAuth (oauth.mjs), and back. It holds no
8// connection, so the Worker can restart or reload under a running Claude
9// Code session.
10//
11// While the dev Worker is down, tools/list answers from worker/src/tools.json
12// and shared-tools.json (the tools both MCPs offer), and tool calls say how
13// to start it. The proxy polls the dev Worker's tool
14// list and sends notifications/tools/list_changed when it differs, so a tool
15// edited in the Worker reaches Claude Code without a restart.
16//
17// JEVSTRUDEL_MCP_URL overrides the dev endpoint, JEVSTRUDEL_PROD_MCP_URL the
18// prod one (for testing against a local production Worker, never otherwise).
19import { readFileSync } from 'node:fs';
20import { createInterface } from 'node:readline';
21import { openBrowser } from './browser.mjs';
22import { oauthSession } from './oauth.mjs';
23import { createProxy } from './proxy.mjs';
24
25const worker = new URL('../../worker/', import.meta.url);
26const read = (path) => JSON.parse(readFileSync(new URL(path, worker)));
27const port = read('wrangler.json').dev.port;
28const devEndpoint = process.env.JEVSTRUDEL_MCP_URL ?? `http://localhost:${port}/mcp`;
29const prodEndpoint = process.env.JEVSTRUDEL_PROD_MCP_URL ?? 'https://jevstrudel.deizel.workers.dev/jev/mcp';
30
31const send = (message) => process.stdout.write(JSON.stringify(message) + '\n');
32// stdout is the protocol; anything else goes to stderr, which Claude Code logs
33const log = (line) => process.stderr.write(`[jevstrudel-mcp] ${line}\n`);
34
35const session = oauthSession({ serverUrl: prodEndpoint, openBrowser });
36const proxy = createProxy({
37  dev: {
38    endpoint: devEndpoint,
39    // as the dev Worker lists them (worker/src/mcp.ts's DEV_TOOLS)
40    fallbackTools: () => [...read('src/tools.json'), ...read('src/shared-tools.json').map(({ scope: _scope, ...tool }) => tool)],
41  },
42  prod: { endpoint: prodEndpoint, session },
43  send,
44  log,
45});
46
47setInterval(() => proxy.poll(), 3000).unref();
48
49// The grant ends with the session: revoke it, so it leaves the user's
50// connected apps, then exit.
51let exiting = false;
52async function exit() {
53  if (exiting) return;
54  exiting = true;
55  await session.revoke();
56  process.exit(0);
57}
58process.on('SIGTERM', exit);
59process.on('SIGINT', exit);
60
61for await (const line of createInterface({ input: process.stdin })) {
62  if (!line.trim()) continue;
63  let message;
64  try {
65    message = JSON.parse(line);
66  } catch {
67    send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
68    continue;
69  }
70  if (message.id === undefined) continue; // notifications need no answer
71  proxy.handle(message).then(send);
72}
73await exit();