mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24: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,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