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 }),