Files
gods-eye-view/src/tools/schema.js
T
Sameh Khamis c3dfa8e853 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.
2026-10-01 11:51:59 -07:00

126 lines
4.1 KiB
JavaScript

/**
* Validates tool arguments against the JSON Schema subset tool definitions use:
* object, array, string, number, integer and boolean types; required,
* properties, additionalProperties, items, enum, minimum, maximum, minLength,
* maxLength, minItems and maxItems. Unsupported keywords are rejected when a
* tool is defined, so a schema can never promise checks that are not made.
*/
const KEYWORDS = new Set([
'type',
'description',
'title',
'properties',
'required',
'additionalProperties',
'items',
'enum',
'minimum',
'maximum',
'minLength',
'maxLength',
'minItems',
'maxItems',
'default',
]);
const TYPES = new Set([
'object',
'array',
'string',
'number',
'integer',
'boolean',
]);
/** Throw when a schema uses a keyword or type this validator does not check. */
export function assertSupportedSchema(schema, path = 'schema') {
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
throw new TypeError(`${path} must be an object`);
for (const key of Object.keys(schema)) {
if (!KEYWORDS.has(key))
throw new TypeError(`${path} uses unsupported keyword ${key}`);
}
if (!TYPES.has(schema.type))
throw new TypeError(`${path} has unsupported type ${schema.type}`);
for (const [name, child] of Object.entries(schema.properties || {}))
assertSupportedSchema(child, `${path}.${name}`);
if (schema.items) assertSupportedSchema(schema.items, `${path}[]`);
}
/**
* Return a list of problems with a value, empty when it conforms. Messages
* name the argument path so a model can correct its call.
*/
export function validateValue(schema, value, path = 'arguments') {
const problems = [];
const fail = (message) => problems.push(`${path} ${message}`);
switch (schema.type) {
case 'object': {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
fail('must be an object');
break;
}
for (const name of schema.required || []) {
if (value[name] === undefined) fail(`is missing ${name}`);
}
const properties = schema.properties || {};
for (const [name, child] of Object.entries(value)) {
if (properties[name])
problems.push(
...validateValue(properties[name], child, `${path}.${name}`),
);
else if (schema.additionalProperties === false)
fail(`has unknown property ${name}`);
}
break;
}
case 'array':
if (!Array.isArray(value)) {
fail('must be an array');
break;
}
if (schema.minItems != null && value.length < schema.minItems)
fail(`must have at least ${schema.minItems} items`);
if (schema.maxItems != null && value.length > schema.maxItems)
fail(`must have at most ${schema.maxItems} items`);
if (schema.items)
value.forEach((item, index) =>
problems.push(
...validateValue(schema.items, item, `${path}[${index}]`),
),
);
break;
case 'string':
if (typeof value !== 'string') {
fail('must be a string');
break;
}
if (schema.minLength != null && value.length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && value.length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
break;
case 'number':
case 'integer':
if (typeof value !== 'number' || !Number.isFinite(value)) {
fail('must be a number');
break;
}
if (schema.type === 'integer' && !Number.isInteger(value))
fail('must be an integer');
if (schema.minimum != null && value < schema.minimum)
fail(`must be at least ${schema.minimum}`);
if (schema.maximum != null && value > schema.maximum)
fail(`must be at most ${schema.maximum}`);
break;
case 'boolean':
if (typeof value !== 'boolean') fail('must be true or false');
break;
}
if (schema.enum && !schema.enum.includes(value))
fail(
`must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(', ')}`,
);
return problems;
}