mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat: serve the MCP server at /mcp on the local servers
Mount the local MCP server at /mcp on the development and preview servers for clients that connect by URL. The route accepts only requests from this machine that name a loopback host and, when an Origin is sent, come from a loopback origin, and drains oversized bodies so the 413 response is delivered. The provider-order regression test now pins the route between the providers and the API fallback.
This commit is contained in:
1 parent
b6af8b34e0
commit
469b1e8d89
8 files changed
+268
-7
No files matched your search
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# Changelog
|
# 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,
|
serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio,
|
||||||
place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter,
|
place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter,
|
||||||
terrain, installation and map-feature queries, plus a situation brief, over
|
terrain, installation and map-feature queries, plus a situation brief, over
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
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,
|
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`).
|
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
|
Queries cover earthquakes, active fires, recent launches, aircraft (in an
|
||||||
area, by identifier, tracks, and type and route lookups), ships (in an area,
|
area, by identifier, tracks, and type and route lookups), ships (in an area,
|
||||||
by identifier, and tracks), satellites (next
|
by identifier, and tracks), satellites (next
|
||||||
|
|||||||
+13
-2
@@ -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/` | 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/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 |
|
| `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
|
Dependencies point downward only. `gods-eye-view/tools` and
|
||||||
`gods-eye-view/tools/mcp` are portable exports: they reach no application,
|
`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
|
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
|
local server makes no requests other than to the app's `/api` routes and the
|
||||||
public feeds the sources already use.
|
public feeds the sources already use.
|
||||||
|
|
||||||
|
|||||||
@@ -878,5 +878,6 @@
|
|||||||
"src/tools/queries/mobility.test.mjs",
|
"src/tools/queries/mobility.test.mjs",
|
||||||
"src/tools/queries/environment.test.mjs",
|
"src/tools/queries/environment.test.mjs",
|
||||||
"src/tools/queries/maritime.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"
|
||||||
]
|
]
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import { createBrowserViteConfig } from '../../build/vite.js';
|
import { createBrowserViteConfig } from '../../build/vite.js';
|
||||||
import { localProviderPlugins } from '../providers/local.js';
|
import { localProviderPlugins } from '../providers/local.js';
|
||||||
|
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||||
|
|
||||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
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;
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
}
|
}
|
||||||
return createBrowserViteConfig({
|
return createBrowserViteConfig({
|
||||||
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
|
plugins: [...localProviderPlugins(), localMcpPlugin(), apiNotFoundPlugin()],
|
||||||
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||||
host: process.env.HOST,
|
host: process.env.HOST,
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ test('root config retains existing named exports and standalone provider order',
|
|||||||
assert.equal(compatibility[name], value, name);
|
assert.equal(compatibility[name], value, name);
|
||||||
const config = standaloneConfig({ mode: 'test' });
|
const config = standaloneConfig({ mode: 'test' });
|
||||||
assert.deepEqual(
|
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),
|
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');
|
assert.equal(config.plugins.at(-1).name, 'api-not-found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user