mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat: add a tool catalog and local MCP server
Define tools that answer questions from the app's data once, independent of the surface that exposes them. A tool declares its arguments and the services it reads; applications compose tools with their own services and optional call interceptors. Queries reuse the layers' portable source factories, so they run wherever an application routes /api requests. The first queries are get_earthquakes, get_active_fires and get_recent_launches, with a shared area argument (place name, bounding box, or point and radius) and capped lists. gods-eye-view/tools/mcp adapts a catalog to the Model Context Protocol with a small JSON-RPC handler and a stateless HTTP transport, adding no dependencies. npm run mcp serves Core's tools over stdio to local clients such as Claude Code, reading from a running app. Both exports are portable and checked by check:boundaries; the app does not import them.
This commit is contained in:
1 parent
e7707d9a0f
commit
c3dfa8e853
26 files changed
+1886
-3
No files matched your search
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Stateless Streamable HTTP transport for an MCP server: one JSON-RPC message
|
||||
* per POST, answered with a JSON body. Uses standard Request and Response so
|
||||
* any web-standard runtime can mount it. The host owns routing and any access
|
||||
* control in front of it.
|
||||
*/
|
||||
|
||||
import { MCP_PROTOCOL_VERSIONS, parseErrorResponse } from './protocol.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
/** Return `(request) => Promise<Response>` for an MCP server. */
|
||||
export function createMcpHttpHandler(server) {
|
||||
return async function handleMcpRequest(request) {
|
||||
if (request.method !== 'POST')
|
||||
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
|
||||
const version = request.headers.get('mcp-protocol-version');
|
||||
if (version && !MCP_PROTOCOL_VERSIONS.includes(version))
|
||||
return json(400, {
|
||||
error: `Unsupported MCP protocol version: ${version}`,
|
||||
});
|
||||
const type = request.headers.get('content-type') || '';
|
||||
if (!/^application\/json\b/i.test(type))
|
||||
return json(415, { error: 'Content-Type must be application/json' });
|
||||
const declared = Number(request.headers.get('content-length'));
|
||||
if (declared > MAX_BODY_BYTES)
|
||||
return json(413, { error: 'Request too large' });
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES)
|
||||
return json(413, { error: 'Request too large' });
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(text);
|
||||
} catch {
|
||||
return json(400, parseErrorResponse());
|
||||
}
|
||||
const response = await server.handle(message, { signal: request.signal });
|
||||
return response ? json(200, response) : new Response(null, { status: 202 });
|
||||
};
|
||||
}
|
||||
|
||||
function json(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Model Context Protocol adapter for a tool catalog. See docs/TOOLS.md. */
|
||||
|
||||
export {
|
||||
createMcpServer,
|
||||
toMcpTools,
|
||||
parseErrorResponse,
|
||||
MCP_PROTOCOL_VERSIONS,
|
||||
} from './protocol.js';
|
||||
export { createMcpHttpHandler } from './http.js';
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
createMcpHttpHandler,
|
||||
createMcpServer,
|
||||
MCP_PROTOCOL_VERSIONS,
|
||||
toMcpTools,
|
||||
} from './index.js';
|
||||
|
||||
const tool = (name, run) =>
|
||||
defineTool({
|
||||
name,
|
||||
title: 'Count',
|
||||
description: 'Counts things.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { n: { type: 'integer' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
run,
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: [
|
||||
tool('count', async ({ n = 1 }) => ({
|
||||
summary: `${n} things.`,
|
||||
data: { n },
|
||||
})),
|
||||
tool('busy', async () => {
|
||||
throw new ToolError('retry_later', 'Upstream is busy', {
|
||||
retryAfterSeconds: 10,
|
||||
});
|
||||
}),
|
||||
tool('crash', async () => {
|
||||
throw new Error('database password is hunter2');
|
||||
}),
|
||||
],
|
||||
});
|
||||
const server = createMcpServer({
|
||||
catalog,
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
instructions: 'Be brief.',
|
||||
});
|
||||
const request = (method, params, id = 1) =>
|
||||
server.handle({ jsonrpc: '2.0', id, method, params });
|
||||
|
||||
test('initialize negotiates a supported protocol version', async () => {
|
||||
const known = await request('initialize', { protocolVersion: '2025-06-18' });
|
||||
assert.deepEqual(known.result, {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: 'test', version: '1.0.0' },
|
||||
instructions: 'Be brief.',
|
||||
});
|
||||
const unknown = await request('initialize', {
|
||||
protocolVersion: '1999-01-01',
|
||||
});
|
||||
assert.equal(unknown.result.protocolVersion, MCP_PROTOCOL_VERSIONS[0]);
|
||||
assert.deepEqual((await request('ping')).result, {});
|
||||
});
|
||||
|
||||
test('notifications and client responses get no reply', async () => {
|
||||
assert.equal(
|
||||
await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await server.handle({ jsonrpc: '2.0', id: 9, result: {} }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid messages and unknown methods are JSON-RPC errors', async () => {
|
||||
assert.equal(
|
||||
(await server.handle({ id: 1, method: 'ping' })).error.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal(
|
||||
(await server.handle([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).error
|
||||
.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal(
|
||||
(await server.handle({ jsonrpc: '2.0', id: 1 })).error.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal((await request('tools/remove')).error.code, -32601);
|
||||
assert.equal((await request('toString')).error.code, -32601);
|
||||
assert.equal((await request('tools/list', [])).error.code, -32602);
|
||||
assert.equal(
|
||||
(await request('tools/call', { name: 'missing' })).error.code,
|
||||
-32602,
|
||||
);
|
||||
});
|
||||
|
||||
test('tools are listed with titles, schemas and annotations, with surface overrides', async () => {
|
||||
const { tools } = (await request('tools/list')).result;
|
||||
assert.deepEqual(tools[0], {
|
||||
name: 'count',
|
||||
title: 'Count',
|
||||
description: 'Counts things.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { n: { type: 'integer' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { title: 'Count', readOnlyHint: true },
|
||||
});
|
||||
const [custom] = toMcpTools(catalog, {
|
||||
descriptions: { count: { description: 'Counts for this surface.' } },
|
||||
decorate: (definition, source) => ({
|
||||
_meta: { kind: source.kind, seen: definition.name },
|
||||
}),
|
||||
});
|
||||
assert.equal(custom.description, 'Counts for this surface.');
|
||||
assert.deepEqual(custom._meta, { kind: 'query', seen: 'count' });
|
||||
});
|
||||
|
||||
test('tool results carry text and structured content; failures stay generic', async () => {
|
||||
assert.deepEqual(
|
||||
(await request('tools/call', { name: 'count', arguments: { n: 3 } }))
|
||||
.result,
|
||||
{
|
||||
content: [{ type: 'text', text: '3 things.' }],
|
||||
structuredContent: { n: 3 },
|
||||
isError: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual((await request('tools/call', { name: 'busy' })).result, {
|
||||
content: [{ type: 'text', text: 'Upstream is busy' }],
|
||||
structuredContent: { error: 'retry_later', retry_after_seconds: 10 },
|
||||
isError: true,
|
||||
});
|
||||
const invalid = (
|
||||
await request('tools/call', { name: 'count', arguments: { n: 'x' } })
|
||||
).result;
|
||||
assert.equal(invalid.isError, true);
|
||||
assert.equal(invalid.structuredContent.error, 'invalid_arguments');
|
||||
const crash = (await request('tools/call', { name: 'crash' })).result;
|
||||
assert.equal(crash.isError, true);
|
||||
assert.equal(JSON.stringify(crash).includes('hunter2'), false);
|
||||
});
|
||||
|
||||
test('the HTTP transport accepts one JSON message per POST', async () => {
|
||||
const handle = createMcpHttpHandler(server);
|
||||
const post = (body, headers = {}) =>
|
||||
handle(
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.headers.get('content-type'), 'application/json');
|
||||
assert.deepEqual(await ok.json(), { jsonrpc: '2.0', id: 1, result: {} });
|
||||
assert.equal(
|
||||
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
|
||||
.status,
|
||||
202,
|
||||
);
|
||||
const parse = await post('{');
|
||||
assert.equal(parse.status, 400);
|
||||
assert.equal((await parse.json()).error.code, -32700);
|
||||
assert.equal(
|
||||
(await post({}, { 'MCP-Protocol-Version': '1999-01-01' })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415);
|
||||
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
|
||||
const get = await handle(new Request('http://localhost/mcp'));
|
||||
assert.equal(get.status, 405);
|
||||
assert.equal(get.headers.get('allow'), 'POST');
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
|
||||
* initialize, ping, tools/list and tools/call. Transports (HTTP, stdio) pass
|
||||
* parsed messages in and send the returned responses out.
|
||||
*/
|
||||
|
||||
import { ToolError } from '../catalog.js';
|
||||
|
||||
/** Protocol revisions this server implements, newest first. */
|
||||
export const MCP_PROTOCOL_VERSIONS = Object.freeze([
|
||||
'2025-11-25',
|
||||
'2025-06-18',
|
||||
'2025-03-26',
|
||||
]);
|
||||
|
||||
const PARSE_ERROR = -32700;
|
||||
const INVALID_REQUEST = -32600;
|
||||
const METHOD_NOT_FOUND = -32601;
|
||||
const INVALID_PARAMS = -32602;
|
||||
const INTERNAL_ERROR = -32603;
|
||||
|
||||
/**
|
||||
* Describe catalog tools as MCP tool definitions.
|
||||
*
|
||||
* `descriptions` may override a tool's `title` and `description` by name.
|
||||
* `decorate(definition, tool)` may return extra fields to merge into each
|
||||
* definition, such as application metadata.
|
||||
*/
|
||||
export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
|
||||
return catalog.list().map((tool) => {
|
||||
const override = descriptions[tool.name] || {};
|
||||
const title = override.title ?? tool.title;
|
||||
const definition = {
|
||||
name: tool.name,
|
||||
title,
|
||||
description: override.description ?? tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: { title, ...tool.annotations },
|
||||
};
|
||||
return decorate
|
||||
? { ...definition, ...decorate(definition, tool) }
|
||||
: definition;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protocol handler. `handle(message, { signal })` resolves to the
|
||||
* JSON-RPC response, or null for notifications.
|
||||
*/
|
||||
export function createMcpServer({
|
||||
catalog,
|
||||
name,
|
||||
version,
|
||||
instructions,
|
||||
descriptions,
|
||||
decorate,
|
||||
}) {
|
||||
const tools = () => toMcpTools(catalog, { descriptions, decorate });
|
||||
const methods = {
|
||||
initialize(params) {
|
||||
const requested = params?.protocolVersion;
|
||||
return {
|
||||
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
|
||||
? requested
|
||||
: MCP_PROTOCOL_VERSIONS[0],
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name, version },
|
||||
...(instructions ? { instructions } : {}),
|
||||
};
|
||||
},
|
||||
ping: () => ({}),
|
||||
'tools/list': () => ({ tools: tools() }),
|
||||
async 'tools/call'(params, { signal }) {
|
||||
if (typeof params?.name !== 'string')
|
||||
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
|
||||
if (!catalog.get(params.name))
|
||||
throw rpcError(INVALID_PARAMS, `Unknown tool: ${params.name}`);
|
||||
try {
|
||||
const result = await catalog.call(params.name, params.arguments ?? {}, {
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: result.summary }],
|
||||
structuredContent: result.data,
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
// Expected failures are reported to the model so it can adjust; other
|
||||
// errors are not described, so internal details never leave the server.
|
||||
const known = error instanceof ToolError;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: known ? error.message : 'The tool failed; try again later',
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: known ? error.code : 'unavailable',
|
||||
...(known && error.retryAfterSeconds != null
|
||||
? { retry_after_seconds: error.retryAfterSeconds }
|
||||
: {}),
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async handle(message, { signal } = {}) {
|
||||
if (!isObject(message) || message.jsonrpc !== '2.0')
|
||||
return failure(
|
||||
message?.id ?? null,
|
||||
INVALID_REQUEST,
|
||||
'Invalid JSON-RPC message',
|
||||
);
|
||||
const isRequest = message.id !== undefined;
|
||||
if (typeof message.method !== 'string')
|
||||
// Responses from the client are accepted and ignored: this server
|
||||
// sends no requests of its own.
|
||||
return isRequest &&
|
||||
message.result === undefined &&
|
||||
message.error === undefined
|
||||
? failure(message.id, INVALID_REQUEST, 'Missing method')
|
||||
: null;
|
||||
if (!isRequest) return null;
|
||||
const method = Object.hasOwn(methods, message.method)
|
||||
? methods[message.method]
|
||||
: null;
|
||||
if (!method)
|
||||
return failure(
|
||||
message.id,
|
||||
METHOD_NOT_FOUND,
|
||||
`Unknown method: ${message.method}`,
|
||||
);
|
||||
if (message.params !== undefined && !isObject(message.params))
|
||||
return failure(message.id, INVALID_PARAMS, 'params must be an object');
|
||||
try {
|
||||
const result = await method(message.params, { signal });
|
||||
return { jsonrpc: '2.0', id: message.id, result };
|
||||
} catch (error) {
|
||||
if (error?.rpcCode)
|
||||
return failure(message.id, error.rpcCode, error.message);
|
||||
return failure(message.id, INTERNAL_ERROR, 'Internal error');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A JSON-RPC parse-error response, for transports that cannot parse input. */
|
||||
export function parseErrorResponse() {
|
||||
return failure(null, PARSE_ERROR, 'Parse error');
|
||||
}
|
||||
|
||||
function failure(id, code, message) {
|
||||
return { jsonrpc: '2.0', id, error: { code, message } };
|
||||
}
|
||||
|
||||
function rpcError(code, message) {
|
||||
return Object.assign(new Error(message), { rpcCode: code });
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
Reference in new issue
Block a user