diff --git a/CHANGELOG.md b/CHANGELOG.md index acb9205d7..4b32c2471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -- Add tools for language-model clients and a local MCP server. `npm run mcp` +- Add tools for language-model clients and a local MCP server, served over + stdio by `npm run mcp` and over HTTP at `/mcp` on the development and preview + servers for local requests only. `npm run mcp` serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio, place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter, terrain, installation and map-feature queries, plus a situation brief, over diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 14eb8ac27..bf65cedf1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,6 +6,8 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, reading from a running app's `/api` routes (default `http://localhost:4173`). +The development and preview servers also serve the tools over HTTP at `/mcp`, +accepting only local requests with loopback hosts and origins. Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 259963519..cadd5712a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -11,7 +11,7 @@ They are defined once and exposed through adapters; the Model Context Protocol | `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers | | `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts | | `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries | -| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes and serves stdio | +| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes; serves stdio and `/mcp` | Dependencies point downward only. `gods-eye-view/tools` and `gods-eye-view/tools/mcp` are portable exports: they reach no application, @@ -84,7 +84,18 @@ server with an MCP client, for example Claude Code: claude mcp add gods-eye-view -- npm --prefix /path/to/gods-eye-view run --silent mcp ``` -`npm run mcp -- --api-base http://localhost:4173` selects another server. The +`npm run mcp -- --api-base http://localhost:4173` selects another server. + +The development and preview servers also serve the same tools over HTTP at +`/mcp`, for clients that connect by URL: + +```bash +claude mcp add --transport http gods-eye-view http://localhost:4173/mcp +``` + +The route accepts only requests from this machine that name a loopback host +and, when a browser sends an `Origin`, come from a loopback origin. This is +local transport safety, not authentication. The local server makes no requests other than to the app's `/api` routes and the public feeds the sources already use. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e48d6bd17..7f46193be 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -878,5 +878,6 @@ "src/tools/queries/mobility.test.mjs", "src/tools/queries/environment.test.mjs", "src/tools/queries/maritime.test.mjs", - "src/tools/queries/share.test.mjs" + "src/tools/queries/share.test.mjs", + "src/tools/localRoute.test.mjs" ] diff --git a/server/mcp/plugin.js b/server/mcp/plugin.js new file mode 100644 index 000000000..4c5c4ae24 --- /dev/null +++ b/server/mcp/plugin.js @@ -0,0 +1,119 @@ +/** + * Serves the local MCP server at /mcp on the development and preview servers. + * + * This is local transport safety, not authentication: requests must come from + * this machine, name a loopback host (blocking DNS rebinding) and, when a + * browser sends an Origin, come from a loopback origin. + */ + +import { createMcpHttpHandler } from '../../src/tools/mcp/index.js'; +import { createLocalMcpServer } from './server.js'; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); +const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); +const MAX_BODY_BYTES = 1024 * 1024; + +/** Whether a request may reach the local MCP server. */ +export function isLocalMcpRequest({ remoteAddress, host, origin }) { + if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false; + let hostUrl; + try { + hostUrl = new URL(`http://${host}`); + } catch { + return false; + } + if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false; + if (origin == null) return true; + try { + const originUrl = new URL(origin); + return ( + ['http:', 'https:'].includes(originUrl.protocol) && + LOOPBACK_HOSTS.has(originUrl.hostname) + ); + } catch { + return false; + } +} + +/** Vite plugin that mounts the local MCP server at /mcp. */ +export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { + const handlers = new Map(); + const handlerFor = (apiBase) => { + if (!handlers.has(apiBase)) + handlers.set(apiBase, createMcpHttpHandler(createServer({ apiBase }))); + return handlers.get(apiBase); + }; + const install = (server) => { + server.middlewares.use('/mcp', async (req, res) => { + const host = req.headers.host || ''; + if ( + !isLocalMcpRequest({ + remoteAddress: req.socket?.remoteAddress, + host, + origin: req.headers.origin, + }) + ) { + res.writeHead(403, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'The MCP server only accepts local requests', + }), + ); + return; + } + try { + const body = req.method === 'POST' ? await readBody(req) : undefined; + const request = new Request(`http://${host}/mcp`, { + method: req.method, + headers: Object.entries(req.headers).flatMap(([name, value]) => + value === undefined ? [] : [[name, String(value)]], + ), + body, + }); + const response = await handlerFor(`http://${host}`)(request); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(Buffer.from(await response.arrayBuffer())); + } catch (error) { + if (res.headersSent) return res.destroy(); + res.writeHead(error?.status === 413 ? 413 : 400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: error?.status === 413 ? 'Request too large' : 'Bad request', + }), + ); + } + }); + }; + return { + name: 'local-mcp', + configureServer: install, + configurePreviewServer: install, + }; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let tooLarge = false; + req.on('data', (chunk) => { + size += chunk.length; + // Keep draining without storing, so the 413 response can still be sent. + if (tooLarge || size <= MAX_BODY_BYTES) { + if (!tooLarge) chunks.push(chunk); + return; + } + tooLarge = true; + chunks.length = 0; + reject(Object.assign(new Error('Request too large'), { status: 413 })); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffa5a1e27..fd315bea8 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, loadEnv } from 'vite'; import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; +import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); @@ -13,7 +14,7 @@ export default defineConfig(({ command, mode }) => { if (process.env[key] === undefined) process.env[key] = value; } return createBrowserViteConfig({ - plugins: [...localProviderPlugins(), apiNotFoundPlugin()], + plugins: [...localProviderPlugins(), localMcpPlugin(), apiNotFoundPlugin()], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, host: process.env.HOST, diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 497035a40..6900420a6 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -63,10 +63,12 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(2, -1).map((plugin) => plugin.name), + config.plugins.slice(2, -2).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-2).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-3).name, 'gev-key-setup'); + // The local MCP route follows every provider and precedes the API fallback. + assert.equal(config.plugins.at(-2).name, 'local-mcp'); assert.equal(config.plugins.at(-1).name, 'api-not-found'); }); diff --git a/src/tools/localRoute.test.mjs b/src/tools/localRoute.test.mjs new file mode 100644 index 000000000..bdeb6a0e4 --- /dev/null +++ b/src/tools/localRoute.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { createServer, request as httpRequest } from 'node:http'; +import test from 'node:test'; +import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js'; + +test('only loopback connections with loopback hosts and origins are local', () => { + const local = { remoteAddress: '127.0.0.1', host: 'localhost:4173' }; + assert.equal(isLocalMcpRequest(local), true); + assert.equal( + isLocalMcpRequest({ ...local, remoteAddress: '::1', host: '[::1]:4173' }), + true, + ); + assert.equal( + isLocalMcpRequest({ ...local, origin: 'http://127.0.0.1:5173' }), + true, + ); + for (const request of [ + { ...local, remoteAddress: '192.168.1.20' }, + { ...local, host: 'attacker.example:4173' }, + { ...local, host: 'localhost.attacker.example' }, + { ...local, host: '' }, + { ...local, origin: 'https://attacker.example' }, + { ...local, origin: 'null' }, + { ...local, origin: 'file:///tmp/page.html' }, + ]) + assert.equal(isLocalMcpRequest(request), false, JSON.stringify(request)); +}); + +test('the /mcp route answers local MCP requests and refuses others', async (t) => { + let middleware; + const created = []; + const plugin = localMcpPlugin({ + createServer: ({ apiBase }) => { + created.push(apiBase); + return { + handle: async (message) => + message.id === undefined + ? null + : { jsonrpc: '2.0', id: message.id, result: { apiBase } }, + }; + }, + }); + plugin.configureServer({ + middlewares: { use: (path, handler) => (middleware = handler) }, + }); + const http = createServer((req, res) => middleware(req, res)); + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + t.after(() => http.close()); + const { port } = http.address(); + const send = (method, body, headers = {}) => + new Promise((resolve, reject) => { + const request = httpRequest( + { + host: '127.0.0.1', + port, + path: '/mcp', + method, + headers: { + 'Content-Type': 'application/json', + Host: `localhost:${port}`, + ...headers, + }, + }, + (response) => { + let text = ''; + response.on('data', (chunk) => (text += chunk)); + response.on('end', () => + resolve({ + status: response.statusCode, + json: () => JSON.parse(text), + }), + ); + }, + ); + request.on('error', reject); + request.end( + body === undefined + ? undefined + : typeof body === 'string' + ? body + : JSON.stringify(body), + ); + }); + const post = (body, headers) => send('POST', body, headers); + + const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' }); + assert.equal(ok.status, 200); + assert.deepEqual(await ok.json(), { + jsonrpc: '2.0', + id: 1, + result: { apiBase: `http://localhost:${port}` }, + }); + assert.equal( + (await post({ jsonrpc: '2.0', method: 'notifications/initialized' })) + .status, + 202, + ); + await post({ jsonrpc: '2.0', id: 2, method: 'ping' }); + assert.deepEqual(created, [`http://localhost:${port}`]); + + const foreign = await post( + { jsonrpc: '2.0', id: 3, method: 'ping' }, + { Origin: 'https://attacker.example' }, + ); + assert.equal(foreign.status, 403); + assert.deepEqual(await foreign.json(), { + error: 'The MCP server only accepts local requests', + }); + assert.equal((await post({}, { Host: 'attacker.example' })).status, 403); + assert.equal( + (await post({}, { Host: `localhost.attacker.example:${port}` })).status, + 403, + ); + assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413); + assert.equal( + ( + await fetch(`http://127.0.0.1:${port}/mcp`, { + headers: { Host: `localhost:${port}` }, + }) + ).status, + 405, + ); +});