From 3318390e3f6f80b8f67a23dbf09794ff1e44bb5b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 19:47:37 -0700 Subject: [PATCH] fix(mcp): require the panel key for panel requests panel_request now requires a key that each MCP server generates and places in its panel page, which keeps the tool from clients that list it to the model without loading the panel. The key is not access control: any MCP client can read the panel page. Routes the panel never loads (provider settings, credential and model endpoints, the MCP endpoint and development server internals) are refused to every caller, regardless of letter case or encoding. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/mcp/server.js | 9 ++- server/mcp/services.js | 1 + src/app/globePanelRuntime.js | 3 +- src/tools/globePanel.js | 12 ++- src/tools/globePanel.test.mjs | 16 +++- src/tools/localServer.test.mjs | 40 ++++++++++ src/tools/queries/panelRequest.js | 43 +++++++++- src/tools/queries/panelRequest.test.mjs | 102 +++++++++++++++++++++--- src/tools/services.js | 6 +- 9 files changed, 209 insertions(+), 23 deletions(-) diff --git a/server/mcp/server.js b/server/mcp/server.js index a2acdba27..20a0745ec 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -1,5 +1,6 @@ /** The local MCP server: Core's tools over services backed by a running app. */ +import { randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { composeCatalog, @@ -28,17 +29,21 @@ export function createLocalMcpServer({ 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'); return createMcpServer({ catalog: catalogForSurface( composeCatalog({ tools: coreTools, - services: createLocalToolServices({ apiBase, fetchImpl }), + services: createLocalToolServices({ apiBase, fetchImpl, panelKey }), }), 'mcp', ), name: 'gods-eye-view', version, instructions: INSTRUCTIONS, - resources: [createGlobePanelResource({ runtime: panelRuntime })], + resources: [createGlobePanelResource({ runtime: panelRuntime, panelKey })], }); } diff --git a/server/mcp/services.js b/server/mcp/services.js index 513bc146f..8664417c7 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -59,5 +59,6 @@ export function createLocalToolServices(options = {}) { return createToolServices({ fetchImpl: createApiFetch(options), appUrl: options.apiBase ?? DEFAULT_API_BASE, + panelKey: options.panelKey, }); } diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 33f807af3..2b259d7b9 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -45,7 +45,8 @@ export function panelRuntime(config) { try { const result = await request('tools/call', { name: config.toolName, - arguments: args, + // The server refuses the panel's requests without its key. + arguments: { ...args, key: config.panelKey }, }); if (result?.isError || !result?.structuredContent) throw new Error(result?.content?.[0]?.text || 'The request failed'); diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index abc8213bc..71345dae3 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -55,12 +55,13 @@ export const PANEL_REQUEST_TOOL = 'panel_request'; * the panel's script, `runtime`, which loads the app through the MCP server * and shows each view a tool returns. */ -function panelHtml(runtime) { +function panelHtml(runtime, panelKey) { const config = { appBaseUrl: PANEL_APP_BASE_URL, loadTimeoutMs: LOAD_TIMEOUT_MS, panelBase: PANEL_BASE, panelHeight: PANEL_HEIGHT_PX, + panelKey, protocolVersion: MCP_APPS_PROTOCOL_VERSION, toolName: PANEL_REQUEST_TOOL, workerPreludePath: PANEL_WORKER_PRELUDE_PATH, @@ -103,18 +104,21 @@ function panelHtml(runtime) { * it (src/app/globePanelRuntime.js), as it is browser code. The security * policy lets the panel load map imagery, tiles and fonts from their * providers; everything from the app's own server arrives through the MCP - * server instead. + * server instead. `panelKey` is the key the panel's requests must carry + * (see src/tools/queries/panelRequest.js), which this page holds. */ -export function createGlobePanelResource({ runtime }) { +export function createGlobePanelResource({ runtime, panelKey }) { if (typeof runtime !== 'function') throw new TypeError('The globe panel needs its runtime script'); + if (typeof panelKey !== 'string' || panelKey.length === 0) + throw new TypeError('The globe panel needs its request key'); return Object.freeze({ uri: GLOBE_PANEL_URI, name: 'globe', title: "God's Eye View globe", description: "Live God's Eye View, showing the view a tool returns.", mimeType: MCP_APP_MIME_TYPE, - text: panelHtml(runtime), + text: panelHtml(runtime, panelKey), _meta: { ui: { csp: { diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 51270d884..378a1472c 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -9,7 +9,10 @@ import { panelRuntime } from '../app/globePanelRuntime.js'; import { composeCatalog, coreTools } from './index.js'; test('the globe panel is an MCP Apps resource that loads the app through its server', () => { - const resource = createGlobePanelResource({ runtime: panelRuntime }); + const resource = createGlobePanelResource({ + runtime: panelRuntime, + panelKey: 'test-key', + }); assert.equal(resource.uri, GLOBE_PANEL_URI); assert.equal(resource.mimeType, MCP_APP_MIME_TYPE); assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app'); @@ -24,6 +27,9 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser assert.equal(csp.frameDomains, undefined); assert.equal(csp.baseUriDomains, undefined); assert.match(resource.text, /"toolName":"panel_request"/); + // The page carries the key its requests need, and sends it with each one. + assert.match(resource.text, /"panelKey":"test-key"/); + assert.match(resource.text, /key: config\.panelKey/); assert.match(resource.text, /"panelBase":"\/panel\/"/); assert.match(resource.text, /'ui\/initialize'/); assert.match(resource.text, /'ui\/notifications\/tool-result'/); @@ -39,6 +45,14 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser assert.doesNotThrow(() => new Function(script)); }); +test('the globe panel needs a request key', () => { + for (const panelKey of [undefined, '', 42]) + assert.throws( + () => createGlobePanelResource({ runtime: panelRuntime, panelKey }), + TypeError, + ); +}); + test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index c9b901bf0..7fd47e251 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -131,3 +131,43 @@ test('a failed tool call is logged with its reason', async () => { ' tools/call show_in_gods_eye_view failed: bad area', ]); }); + +test("only the key in the server's own panel page opens panel requests", async () => { + const requested = []; + const server = createLocalMcpServer({ + apiBase: 'http://127.0.0.1:5000', + fetchImpl: async (url) => { + requested.push(new URL(url).pathname); + return new Response('ok'); + }, + }); + const call = (id, args) => + server.handle({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'panel_request', arguments: args }, + }); + const page = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'resources/read', + params: { uri: 'ui://gods-eye-view/globe' }, + }); + const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/); + assert.ok(key.length >= 40); + const refused = await call(2, { path: '/panel/index.html' }); + assert.equal(refused.result.isError, true); + const opened = await call(3, { key, path: '/panel/index.html' }); + assert.equal(opened.result.isError, false); + assert.deepEqual(requested, ['/panel/index.html']); + // Every server makes its own key. + const other = createLocalMcpServer({ apiBase: 'http://127.0.0.1:5000' }); + const otherPage = await other.handle({ + jsonrpc: '2.0', + id: 4, + method: 'resources/read', + params: { uri: 'ui://gods-eye-view/globe' }, + }); + assert.doesNotMatch(otherPage.result.contents[0].text, new RegExp(key)); +}); diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index 22066c233..cd6e00158 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -2,8 +2,16 @@ * The God's Eye View panel's requests. A panel cannot reach the app's server * itself: hosts serve panels from their own sites and may refuse other * addresses, such as a server on the user's machine. The panel asks this - * tool instead, which only an app may call, and it requests the path from - * the app's server. Large responses come back in parts. + * tool instead, and it requests the path from the app's server. Large + * responses come back in parts. + * + * It is meant for the panel. `visibility: ['app']` asks hosts to keep it + * from the model, and every call must also carry the key in the panel's page + * (`services.app.panelKey`), so a client that lists the tool to the model + * without loading the panel cannot use it. The key is not access control: + * any MCP client can read the panel's page, key included. What protects + * sensitive routes is that those the panel never needs are refused to every + * caller. */ import { defineTool, ToolError } from '../catalog.js'; @@ -17,8 +25,15 @@ const HELD_LIMIT_BYTES = 256 * 1024 * 1024; export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024; const REQUEST_TIMEOUT_MS = 60 * 1000; const METHODS = new Set(['GET', 'HEAD', 'POST']); -// Provider Settings write the app's keys; only the app's own page may. -const REFUSED_PATHS = [/^\/api\/setup(?:\/|$)/]; +// Routes the panel never loads, matched without regard to case as the +// server routes them: Provider Settings, which only the app's own page may +// use; credential and model endpoints; the MCP server itself; and the +// development server's internal routes. +const REFUSED_PATHS = [ + /^\/api\/(?:setup|realtime|openai)(?:\/|$)/i, + /^\/mcp(?:\/|$)/i, + /^\/(?:@|__)/, +]; const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type']; const DROPPED_RESPONSE_HEADERS = new Set([ 'connection', @@ -48,6 +63,17 @@ function fromBase64(text) { return Uint8Array.from(atob(text), (char) => char.charCodeAt(0)); } +/** Compare a caller's key with the panel's, in time independent of where they differ. */ +function isPanelKey(given, expected) { + if (typeof expected !== 'string' || expected.length === 0) return false; + if (typeof given !== 'string' || given.length !== expected.length) + return false; + let difference = 0; + for (let index = 0; index < expected.length; index += 1) + difference |= given.charCodeAt(index) ^ expected.charCodeAt(index); + return difference === 0; +} + async function gzip(bytes) { const stream = new Blob([bytes]) .stream() @@ -158,6 +184,10 @@ export const panelRequest = defineTool({ inputSchema: { type: 'object', properties: { + key: { + type: 'string', + description: "The panel's key, from its own page.", + }, path: { type: 'string', description: "A path on the app's server, starting with /.", @@ -179,6 +209,11 @@ export const panelRequest = defineTool({ requires: ['app'], ui: { visibility: ['app'] }, async run(args, { services, signal }) { + if (!isPanelKey(args.key, services.app.panelKey)) + throw new ToolError( + 'invalid_arguments', + "Only the God's Eye View panel may make this request", + ); forgetExpired(); if (args.id !== undefined) { const entry = held.get(args.id); diff --git a/src/tools/queries/panelRequest.test.mjs b/src/tools/queries/panelRequest.test.mjs index ac5177a68..fc9c07896 100644 --- a/src/tools/queries/panelRequest.test.mjs +++ b/src/tools/queries/panelRequest.test.mjs @@ -7,6 +7,7 @@ import { } from './panelRequest.js'; const fromBase64 = (text) => Buffer.from(text, 'base64'); +const KEY = 'panel-key-for-tests'; const gunzip = async (bytes) => new Uint8Array( await new Response( @@ -25,7 +26,11 @@ function catalogWith(handler) { catalog: composeCatalog({ tools: coreTools, services: { - app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl }, + app: { + baseUrl: 'http://localhost:4173/', + fetch: fetchImpl, + panelKey: KEY, + }, }, }), }; @@ -46,6 +51,7 @@ test('a small response returns whole, compressed when that helps', async () => { }), ); const { data } = await catalog.call('panel_request', { + key: KEY, path: '/panel/assets/style.css', headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' }, }); @@ -66,12 +72,16 @@ test('a large response comes in parts that join to the original', async () => { const { catalog } = catalogWith( () => new Response(bytes, { headers: { 'content-type': 'image/png' } }), ); - let { data } = await catalog.call('panel_request', { path: '/a.png' }); + let { data } = await catalog.call('panel_request', { + key: KEY, + path: '/a.png', + }); assert.equal(data.encoding, 'identity'); assert.equal(data.totalBytes, bytes.length); const parts = [fromBase64(data.body)]; while (data.nextOffset !== undefined) { ({ data } = await catalog.call('panel_request', { + key: KEY, id: data.id, offset: data.nextOffset, })); @@ -86,7 +96,8 @@ test('a request body and method pass through; settings and other sites do not', () => new Response('{}', { status: 201 }), ); const { data } = await catalog.call('panel_request', { - path: '/api/openai/hud-summary', + key: KEY, + path: '/api/overpass', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: Buffer.from('{"a":1}').toString('base64'), @@ -103,12 +114,12 @@ test('a request body and method pass through; settings and other sites do not', 'http://evil.example/', ]) await assert.rejects( - catalog.call('panel_request', { path }), + catalog.call('panel_request', { key: KEY, path }), (error) => error.code === 'invalid_arguments', path, ); await assert.rejects( - catalog.call('panel_request', { id: 'unknown' }), + catalog.call('panel_request', { key: KEY, id: 'unknown' }), (error) => error.code === 'invalid_arguments', ); assert.equal(requests.length, 1); @@ -116,7 +127,11 @@ test('a request body and method pass through; settings and other sites do not', test('the path is requested as the parser reads it, without following redirects', async () => { const { catalog, requests } = catalogWith(() => new Response('ok')); - await catalog.call('panel_request', { path: '/panel/a/../b.js?x=1' }); + await catalog.call('panel_request', { + key: KEY, + key: KEY, + path: '/panel/a/../b.js?x=1', + }); assert.equal(requests[0].path, '/panel/b.js?x=1'); assert.equal(requests[0].redirect, 'manual'); assert.ok(requests[0].signal instanceof AbortSignal); @@ -137,7 +152,7 @@ test('a response larger than the panel may load is refused while reading', async ), ); await assert.rejects( - catalog.call('panel_request', { path: '/huge' }), + catalog.call('panel_request', { key: KEY, path: '/huge' }), (error) => error.code === 'unavailable' && /too large/.test(error.message), ); assert.ok(sent <= PANEL_RESPONSE_LIMIT_BYTES + 2 * chunk.length, `${sent}`); @@ -154,16 +169,85 @@ test('large responses read in turn keep their own parts', async () => { headers: { 'content-type': 'image/png' }, }), ); - const first = (await catalog.call('panel_request', { path: '/a.png' })).data; - const second = (await catalog.call('panel_request', { path: '/b.png' })).data; + const first = ( + await catalog.call('panel_request', { key: KEY, path: '/a.png' }) + ).data; + const second = ( + await catalog.call('panel_request', { key: KEY, path: '/b.png' }) + ).data; for (const [start, fill] of [ [first, 1], [second, 2], ]) { const { data } = await catalog.call('panel_request', { + key: KEY, id: start.id, offset: start.nextOffset, }); assert.deepEqual([...fromBase64(data.body)], [1, 1, 1, 1, 1].fill(fill)); } }); + +test('a call without the panel key is refused before any request', async () => { + const { catalog, requests } = catalogWith(() => new Response('ok')); + for (const key of [undefined, '', 'wrong', `${KEY}x`, KEY.toUpperCase()]) + await assert.rejects( + catalog.call('panel_request', { + ...(key === undefined ? {} : { key }), + path: '/panel/index.html', + }), + (error) => error.code === 'invalid_arguments', + String(key), + ); + await assert.rejects( + catalog.call('panel_request', { id: 'held-elsewhere', offset: 0 }), + (error) => error.code === 'invalid_arguments', + ); + assert.equal(requests.length, 0); +}); + +test('without a panel key in its services the tool refuses every call', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + app: { + baseUrl: 'http://localhost:4173/', + fetch: async () => new Response('ok'), + }, + }, + }); + await assert.rejects( + catalog.call('panel_request', { key: '', path: '/panel/index.html' }), + (error) => error.code === 'invalid_arguments', + ); +}); + +test('routes the panel never loads are refused in any case or encoding', async () => { + const { catalog, requests } = catalogWith(() => new Response('ok')); + for (const path of [ + '/API/setup/status', + '/Api/Setup', + '/api/realtime/token', + '/API/REALTIME/token', + '/api/openai/hud-summary', + '/api/%4fpenai/hud-summary', + '/mcp', + '/MCP', + '/@fs/etc/passwd', + '/@vite/client', + '/%40fs/x', + '/__open-in-editor?file=x', + ]) + await assert.rejects( + catalog.call('panel_request', { key: KEY, path }), + (error) => error.code === 'invalid_arguments', + path, + ); + // Similar names the panel does load stay available. + await catalog.call('panel_request', { key: KEY, path: '/api/setups-help' }); + await catalog.call('panel_request', { key: KEY, path: '/mcpx.json' }); + assert.deepEqual( + requests.map((request) => request.path), + ['/api/setups-help', '/mcpx.json'], + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 43517fb9e..961946f98 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -4,6 +4,8 @@ * the sources' relative `/api/...` paths; in a browser the page's own fetch * does, and elsewhere a caller supplies a resolving fetch. `appUrl` is the * address the app is served from; `app.fetch` requests the app's own paths. + * `panelKey`, when the panel is offered, is the key its page carries, which + * the panel's requests must include. */ import { createAlprTileSource } from '../layers/alpr/source.js'; @@ -40,12 +42,12 @@ import { } from './places.js'; /** Construct every service Core's tools read. */ -export function createToolServices({ fetchImpl, appUrl }) { +export function createToolServices({ fetchImpl, appUrl, panelKey }) { if (typeof fetchImpl !== 'function') throw new TypeError('A fetch implementation is required'); const requests = createApplicationRequestServices({ fetchImpl }); return { - app: { baseUrl: appUrl, fetch: fetchImpl }, + app: { baseUrl: appUrl, fetch: fetchImpl, panelKey }, earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }),