mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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/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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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