mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Fixes bilawalsidhu/gods-eye-view#927. Each stdio MCP server made its own random panel key, and panel_request accepts only the key in the panel page. A client that starts two server processes for one connection and reads the page from one while sending the page's requests to the other (seen in Claude Desktop on Windows) therefore always got "Only the God's Eye View panel may make this request". The stdio entry now passes a key shared by the install, created once in .gev-cache/mcp-panel-key. The file is staged 0600 and published with link(), so processes starting together agree on one key and never read a half-written one. If it cannot be stored, the process keeps a key of its own, as before. createLocalMcpServer still makes a fresh key unless given one, so in-process servers (the /mcp HTTP plugin, tests) are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
114 lines
3.9 KiB
JavaScript
114 lines
3.9 KiB
JavaScript
/**
|
|
* Local MCP server over stdio for agents such as Claude Code and Codex.
|
|
*
|
|
* npm run mcp -- [--api-base http://localhost:4173]
|
|
*
|
|
* Reads newline-delimited JSON-RPC from stdin and writes responses to stdout.
|
|
* Tools read data from a running God's Eye View server (`npm run dev` or
|
|
* `npm run preview`) at the API base. Diagnostics go to stderr.
|
|
*/
|
|
|
|
import { createInterface } from 'node:readline';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { parseErrorResponse } from '../../src/tools/mcp/index.js';
|
|
import { sharedPanelKey } from './panelKey.js';
|
|
import { createLocalMcpServer } from './server.js';
|
|
import { DEFAULT_API_BASE } from './services.js';
|
|
|
|
/**
|
|
* A one-line description of a request for the diagnostic log: its method,
|
|
* and the tool or resource it names. Arguments and data are never logged.
|
|
*/
|
|
export function describeRequest(message) {
|
|
const method = typeof message?.method === 'string' ? message.method : null;
|
|
if (!method) return null;
|
|
const subject =
|
|
method === 'tools/call'
|
|
? message.params?.name
|
|
: method === 'resources/read'
|
|
? message.params?.uri
|
|
: null;
|
|
return typeof subject === 'string' ? `${method} ${subject}` : method;
|
|
}
|
|
|
|
/**
|
|
* Serve newline-delimited JSON-RPC until `input` ends. A client's
|
|
* `notifications/cancelled` aborts the named request, which then gets no
|
|
* response, as the protocol asks.
|
|
*/
|
|
export async function serveStdio(server, { input, output, log = () => {} }) {
|
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
const pending = new Set();
|
|
const running = new Map();
|
|
for await (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
const work = respond(server, line, log, running).then((response) => {
|
|
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
});
|
|
pending.add(work);
|
|
work.finally(() => pending.delete(work));
|
|
}
|
|
await Promise.all(pending);
|
|
}
|
|
|
|
async function respond(server, line, log, running) {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch {
|
|
return parseErrorResponse();
|
|
}
|
|
if (message?.method === 'notifications/cancelled') {
|
|
running.get(message.params?.requestId)?.abort();
|
|
return null;
|
|
}
|
|
const described = describeRequest(message);
|
|
if (described) log(described);
|
|
const id = message?.id;
|
|
const cancellable = id !== undefined && id !== null && !running.has(id);
|
|
const controller = new AbortController();
|
|
if (cancellable) running.set(id, controller);
|
|
let response;
|
|
try {
|
|
response = await server.handle(message, { signal: controller.signal });
|
|
} finally {
|
|
if (cancellable) running.delete(id);
|
|
}
|
|
if (controller.signal.aborted) return null;
|
|
// A failed tool call answers the client, not the log; say why here too.
|
|
if (response?.result?.isError)
|
|
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
|
|
return response;
|
|
}
|
|
|
|
/** Read `--api-base <url>` from command-line arguments. */
|
|
export function parseArgs(argv) {
|
|
const options = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
if (argv[index] === '--api-base' && argv[index + 1])
|
|
options.apiBase = argv[(index += 1)];
|
|
else throw new Error(`Unknown argument: ${argv[index]}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
console.error(
|
|
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
|
|
);
|
|
// A client may start several of these for one connection; they share
|
|
// the install's panel key so the panel works whichever one it reaches.
|
|
const panelKey = sharedPanelKey({ log: (line) => console.error(line) });
|
|
await serveStdio(createLocalMcpServer({ ...options, panelKey }), {
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
log: (line) => console.error(`<- ${line}`),
|
|
});
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|