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:
Sameh Khamis committed 2026-10-01 11:51:59 -07:00
1 parent e7707d9a0f
commit c3dfa8e853
26 files changed
+1886 -3

No files matched your search

+50
View File
@@ -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',
},
});
}
+9
View File
@@ -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';
+179
View File
@@ -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');
});
+167
View File
@@ -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);
}