mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(mcp): share one panel key across an install's stdio servers
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]>
This commit is contained in:
1 parent
e685449a52
commit
e363b6a14f
6 files changed
+229
-5
No files matched your search
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Keep the globe panel working when an MCP client starts more than one
|
||||
server process. Each stdio server made its own panel key, so a client that
|
||||
read the panel page from one process and sent its requests to another
|
||||
(seen in Claude Desktop) got "Only the God's Eye View panel may make this
|
||||
request". Stdio servers of one install now share a key, created once in
|
||||
`.gev-cache/`; a server that cannot store it keeps a key of its own, as
|
||||
before (#927).
|
||||
|
||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
||||
|
||||
@@ -873,6 +873,7 @@
|
||||
"src/tools/queries/queries.test.mjs",
|
||||
"src/tools/mcp/mcp.test.mjs",
|
||||
"src/tools/localServer.test.mjs",
|
||||
"src/tools/mcpPanelKey.test.mjs",
|
||||
"src/tools/queries/aviation.test.mjs",
|
||||
"src/tools/queries/space.test.mjs",
|
||||
"src/tools/queries/media.test.mjs",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Where the install's panel key lives: beside the other local state, which
|
||||
* git ignores, at the checkout root rather than the client's working
|
||||
* directory (an MCP client may start the server anywhere). */
|
||||
export const DEFAULT_PANEL_KEY_FILE = fileURLToPath(
|
||||
new URL('../../.gev-cache/mcp-panel-key', import.meta.url),
|
||||
);
|
||||
|
||||
const KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const newKey = () => randomBytes(32).toString('base64url');
|
||||
const notShared = (error) =>
|
||||
`panel key not shared (${error.code || error.message}); using a key of this process`;
|
||||
|
||||
function readKey(file) {
|
||||
const text = fs.readFileSync(file, 'utf8').trim();
|
||||
return KEY_PATTERN.test(text) ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel key every stdio server of this install shares.
|
||||
*
|
||||
* A client can start more than one server process for one connection: Claude
|
||||
* Desktop has been seen serving the globe panel's page from one and sending
|
||||
* the page's `panel_request` calls to the other. A key made per process then
|
||||
* never matches, and the panel reports that only it may make the request
|
||||
* (#927). The first process to run creates the key, without replacing one
|
||||
* another process created at the same moment; the rest read it. When the file
|
||||
* cannot be read or written, the process falls back to a key of its own,
|
||||
* which is what every process used before.
|
||||
*
|
||||
* As before, the key keeps `panel_request` from clients that only list it; it
|
||||
* is not access control.
|
||||
*
|
||||
* @param {{ file?: string, log?: (line: string) => void }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sharedPanelKey({
|
||||
file = DEFAULT_PANEL_KEY_FILE,
|
||||
log = () => {},
|
||||
} = {}) {
|
||||
try {
|
||||
const existing = readKey(file);
|
||||
if (existing) return existing;
|
||||
// Present but not a key: replace it below.
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
log(notShared(error));
|
||||
return newKey();
|
||||
}
|
||||
}
|
||||
const key = newKey();
|
||||
let tmp = null;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
tmp = path.join(
|
||||
path.dirname(file),
|
||||
`.${path.basename(file)}.${randomUUID().slice(0, 8)}.tmp`,
|
||||
);
|
||||
fs.writeFileSync(tmp, `${key}\n`, { flag: 'wx', mode: 0o600 });
|
||||
try {
|
||||
// link() publishes the whole file at once and fails if another process
|
||||
// got there first, so a reader never sees a half-written key and two
|
||||
// processes starting together cannot end up with different keys.
|
||||
fs.linkSync(tmp, file);
|
||||
return key;
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
const winner = readKey(file);
|
||||
if (winner) return winner;
|
||||
// A malformed file left by something else: replace it.
|
||||
fs.renameSync(tmp, file);
|
||||
tmp = null;
|
||||
return key;
|
||||
}
|
||||
} catch (error) {
|
||||
log(notShared(error));
|
||||
return key;
|
||||
} finally {
|
||||
if (tmp) fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,16 @@ const INSTRUCTIONS =
|
||||
export function createLocalMcpServer({
|
||||
apiBase = DEFAULT_API_BASE,
|
||||
fetchImpl,
|
||||
panelKey = randomBytes(32).toString('base64url'),
|
||||
} = {}) {
|
||||
const { version } = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
// Each server makes a new key for its panel. The panel's page carries it
|
||||
// and panel_request requires it; any client may read the page, so it keeps
|
||||
// the tool from clients that only list it, and is not access control.
|
||||
const panelKey = randomBytes(32).toString('base64url');
|
||||
// Unless given one, each server makes a new key for its panel. The panel's
|
||||
// page carries it and panel_request requires it; any client may read the
|
||||
// page, so it keeps the tool from clients that only list it, and is not
|
||||
// access control. The stdio entry passes the install's shared key
|
||||
// (panelKey.js), so separate processes of one client agree.
|
||||
return createMcpServer({
|
||||
catalog: catalogForSurface(
|
||||
composeCatalog({
|
||||
|
||||
+5
-1
@@ -11,6 +11,7 @@
|
||||
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';
|
||||
|
||||
@@ -97,7 +98,10 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
console.error(
|
||||
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
|
||||
);
|
||||
await serveStdio(createLocalMcpServer(options), {
|
||||
// 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}`),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { sharedPanelKey } from '../../server/mcp/panelKey.js';
|
||||
import { createLocalMcpServer } from '../../server/mcp/server.js';
|
||||
|
||||
const PANEL_KEY_MODULE = new URL(
|
||||
'../../server/mcp/panelKey.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
function tempKeyFile(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-panel-key-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, 'state', 'mcp-panel-key');
|
||||
}
|
||||
|
||||
const readPage = async (server) => {
|
||||
const page = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'resources/read',
|
||||
params: { uri: 'ui://gods-eye-view/globe' },
|
||||
});
|
||||
return page.result.contents[0].text.match(/"panelKey":"([^"]+)"/)[1];
|
||||
};
|
||||
const panelRequest = (server, key) =>
|
||||
server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'panel_request',
|
||||
arguments: { key, path: '/panel/index.html' },
|
||||
},
|
||||
});
|
||||
const serverWith = (panelKey) =>
|
||||
createLocalMcpServer({
|
||||
apiBase: 'http://127.0.0.1:5000',
|
||||
fetchImpl: async () => new Response('ok'),
|
||||
...(panelKey ? { panelKey } : {}),
|
||||
});
|
||||
|
||||
test('a panel page from one server process is refused by another without a shared key (#927)', async () => {
|
||||
const pageServer = serverWith();
|
||||
const requestServer = serverWith();
|
||||
const key = await readPage(pageServer);
|
||||
const refused = await panelRequest(requestServer, key);
|
||||
assert.equal(refused.result.isError, true);
|
||||
assert.match(
|
||||
refused.result.content[0].text,
|
||||
/Only the God's Eye View panel may make this request/,
|
||||
);
|
||||
});
|
||||
|
||||
test('servers sharing the install key accept each other’s panel page', async (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
const pageServer = serverWith(sharedPanelKey({ file }));
|
||||
const requestServer = serverWith(sharedPanelKey({ file }));
|
||||
const key = await readPage(pageServer);
|
||||
const opened = await panelRequest(requestServer, key);
|
||||
assert.equal(opened.result.isError, false);
|
||||
});
|
||||
|
||||
test('the shared key is created once, kept, and private to the user', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
const first = sharedPanelKey({ file });
|
||||
assert.match(first, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(sharedPanelKey({ file }), first);
|
||||
assert.equal(fs.readFileSync(file, 'utf8').trim(), first);
|
||||
if (process.platform !== 'win32')
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
// No staging files are left beside it.
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(file)), ['mcp-panel-key']);
|
||||
});
|
||||
|
||||
test('a malformed key file is replaced', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'not a key\n');
|
||||
const key = sharedPanelKey({ file });
|
||||
assert.match(key, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(sharedPanelKey({ file }), key);
|
||||
});
|
||||
|
||||
test('when the key cannot be stored, the process uses a key of its own', (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
// A file where the directory should be makes every write fail.
|
||||
fs.writeFileSync(path.dirname(path.dirname(file)) + '/state', 'blocker');
|
||||
const logged = [];
|
||||
const a = sharedPanelKey({ file, log: (line) => logged.push(line) });
|
||||
const b = sharedPanelKey({ file, log: (line) => logged.push(line) });
|
||||
assert.match(a, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(logged.length, 2);
|
||||
assert.match(logged[0], /panel key not shared/);
|
||||
});
|
||||
|
||||
test('processes starting at the same moment agree on one key', async (t) => {
|
||||
const file = tempKeyFile(t);
|
||||
const script =
|
||||
`const { sharedPanelKey } = await import(${JSON.stringify(PANEL_KEY_MODULE)});` +
|
||||
`process.stdout.write(sharedPanelKey({ file: ${JSON.stringify(file)} }));`;
|
||||
const run = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', script],
|
||||
{ stdio: ['ignore', 'pipe', 'inherit'] },
|
||||
);
|
||||
let out = '';
|
||||
child.stdout.on('data', (chunk) => (out += chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve(out) : reject(new Error(`exit ${code}`)),
|
||||
);
|
||||
});
|
||||
const keys = await Promise.all(Array.from({ length: 8 }, run));
|
||||
assert.equal(new Set(keys).size, 1);
|
||||
assert.equal(fs.readFileSync(file, 'utf8').trim(), keys[0]);
|
||||
});
|
||||
Reference in new issue
Block a user