mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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,143 @@
|
||||
/**
|
||||
* Tool definitions and their composition into a callable catalog.
|
||||
*
|
||||
* A tool declares what it does, its arguments, and which services it reads.
|
||||
* It knows nothing about the surface that exposes it (MCP, voice) or the
|
||||
* transport its services use. Applications compose the tools they want with
|
||||
* the services they supply, plus optional interceptors that wrap every call.
|
||||
*/
|
||||
|
||||
import { assertSupportedSchema, validateValue } from './schema.js';
|
||||
|
||||
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const KINDS = new Set(['query', 'action']);
|
||||
|
||||
/** Error codes a tool may report. Each is safe to show to a model or person. */
|
||||
export const TOOL_ERROR_CODES = Object.freeze([
|
||||
'invalid_arguments',
|
||||
'unavailable',
|
||||
'unsupported',
|
||||
'malformed',
|
||||
'retry_later',
|
||||
]);
|
||||
|
||||
/** An expected failure with a stable code and a message written for the caller. */
|
||||
export class ToolError extends Error {
|
||||
constructor(code, message, { retryAfterSeconds = null } = {}) {
|
||||
if (!TOOL_ERROR_CODES.includes(code))
|
||||
throw new TypeError(`Unknown tool error code: ${code}`);
|
||||
super(message);
|
||||
this.name = 'ToolError';
|
||||
this.code = code;
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and freeze a tool definition.
|
||||
*
|
||||
* `run(args, { services, signal })` resolves to `{ summary, data }`: a short
|
||||
* sentence for people and a structured object for programs.
|
||||
*/
|
||||
export function defineTool({
|
||||
name,
|
||||
kind = 'query',
|
||||
title,
|
||||
description,
|
||||
inputSchema,
|
||||
annotations = {},
|
||||
requires = [],
|
||||
run,
|
||||
}) {
|
||||
if (!TOOL_NAME.test(String(name)))
|
||||
throw new TypeError(`Invalid tool name: ${name}`);
|
||||
if (!KINDS.has(kind)) throw new TypeError(`Invalid tool kind: ${kind}`);
|
||||
if (typeof title !== 'string' || !title)
|
||||
throw new TypeError(`${name} needs a title`);
|
||||
if (typeof description !== 'string' || !description)
|
||||
throw new TypeError(`${name} needs a description`);
|
||||
assertSupportedSchema(inputSchema, `${name}.inputSchema`);
|
||||
if (inputSchema.type !== 'object')
|
||||
throw new TypeError(`${name}.inputSchema must describe an object`);
|
||||
if (
|
||||
!Array.isArray(requires) ||
|
||||
requires.some((key) => typeof key !== 'string')
|
||||
)
|
||||
throw new TypeError(`${name}.requires must list service names`);
|
||||
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
|
||||
return Object.freeze({
|
||||
name,
|
||||
kind,
|
||||
title,
|
||||
description,
|
||||
inputSchema: structuredClone(inputSchema),
|
||||
annotations: Object.freeze({
|
||||
readOnlyHint: kind === 'query',
|
||||
...annotations,
|
||||
}),
|
||||
requires: Object.freeze([...requires]),
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose tools with the services an application supplies.
|
||||
*
|
||||
* - Duplicate names fail unless the later tool is listed in `replace`.
|
||||
* - Tools whose required services are missing are left out.
|
||||
* - Interceptors wrap every call in order, outermost first, as
|
||||
* `(call, next) => next(call)` where `call` is `{ tool, args, signal }`.
|
||||
*/
|
||||
export function composeCatalog({
|
||||
tools,
|
||||
replace = [],
|
||||
services = {},
|
||||
interceptors = [],
|
||||
}) {
|
||||
const byName = new Map();
|
||||
const replaceable = new Set(replace);
|
||||
for (const tool of tools) {
|
||||
if (byName.has(tool.name) && !replaceable.has(tool.name))
|
||||
throw new TypeError(`Duplicate tool name: ${tool.name}`);
|
||||
byName.set(tool.name, tool);
|
||||
}
|
||||
for (const name of replaceable) {
|
||||
if (!byName.has(name))
|
||||
throw new TypeError(`Cannot replace unknown tool: ${name}`);
|
||||
}
|
||||
const available = [...byName.values()].filter((tool) =>
|
||||
tool.requires.every((key) => services[key] != null),
|
||||
);
|
||||
const index = new Map(available.map((tool) => [tool.name, tool]));
|
||||
|
||||
const execute = async ({ tool, args, signal }) => {
|
||||
const problems = validateValue(tool.inputSchema, args);
|
||||
if (problems.length)
|
||||
throw new ToolError('invalid_arguments', problems.join('; '));
|
||||
const result = await tool.run(args, { services, signal });
|
||||
if (!result || typeof result.summary !== 'string' || !result.data)
|
||||
throw new TypeError(`${tool.name} returned no summary or data`);
|
||||
return result;
|
||||
};
|
||||
const chain = interceptors.reduceRight(
|
||||
(next, interceptor) => (call) => interceptor(call, next),
|
||||
execute,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
/** Available tool definitions, in composition order. */
|
||||
list: () => [...available],
|
||||
/** The available tool with this name, or undefined. */
|
||||
get: (name) => index.get(name),
|
||||
/** Validate arguments and run a tool through the interceptors. */
|
||||
async call(name, args = {}, { signal } = {}) {
|
||||
const tool = index.get(name);
|
||||
if (!tool)
|
||||
throw new ToolError(
|
||||
'unsupported',
|
||||
`No tool named ${name} is available`,
|
||||
);
|
||||
return chain({ tool, args, signal });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user