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();