mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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,120 @@
|
||||
/**
|
||||
* The `area` argument shared by location-scoped tools: a bounding box, a
|
||||
* center point with radius, or a place name resolved through the `places`
|
||||
* service.
|
||||
*/
|
||||
|
||||
import { ToolError } from './catalog.js';
|
||||
|
||||
const EARTH_RADIUS_KM = 6371.0088;
|
||||
const MAX_RADIUS_KM = 2000;
|
||||
|
||||
/** JSON Schema for the `area` argument. */
|
||||
export const AREA_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
description:
|
||||
'Where to look. Give exactly one of: place (a name such as "Taiwan" or ' +
|
||||
'"San Francisco Bay"), bbox ([west, south, east, north] in degrees), or ' +
|
||||
'lat and lon with radius_km.',
|
||||
properties: {
|
||||
place: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
bbox: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 4,
|
||||
maxItems: 4,
|
||||
},
|
||||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||
radius_km: { type: 'number', minimum: 0.1, maximum: MAX_RADIUS_KM },
|
||||
},
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve an `area` argument to `{ label, west, south, east, north, center? }`.
|
||||
* A box whose west edge exceeds its east edge crosses the antimeridian.
|
||||
*/
|
||||
export async function resolveArea(area, { services, signal } = {}) {
|
||||
const given = ['place', 'bbox', 'lat'].filter((key) => area?.[key] != null);
|
||||
if (given.length !== 1)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'area needs exactly one of place, bbox, or lat/lon with radius_km',
|
||||
);
|
||||
if (area.bbox) {
|
||||
const [west, south, east, north] = area.bbox;
|
||||
if (
|
||||
[west, east].some((value) => Math.abs(value) > 180) ||
|
||||
[south, north].some((value) => Math.abs(value) > 90) ||
|
||||
south >= north
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'area.bbox must be [west, south, east, north] with south below north',
|
||||
);
|
||||
return { label: 'the requested box', west, south, east, north };
|
||||
}
|
||||
if (area.lat != null) {
|
||||
if (area.lon == null || area.radius_km == null)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'area with lat also needs lon and radius_km',
|
||||
);
|
||||
return circleArea(area.lat, area.lon, area.radius_km);
|
||||
}
|
||||
const places = services?.places;
|
||||
if (!places)
|
||||
throw new ToolError(
|
||||
'unsupported',
|
||||
'Place names cannot be resolved here; give a bbox or lat/lon instead',
|
||||
);
|
||||
const place = await places.resolve(area.place, { signal });
|
||||
if (!place)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`No place matched "${area.place}"; try a more specific name or a bbox`,
|
||||
);
|
||||
return { label: place.name || area.place, ...place.bounds };
|
||||
}
|
||||
|
||||
function circleArea(lat, lon, radiusKm) {
|
||||
const latSpan = (radiusKm / EARTH_RADIUS_KM) * (180 / Math.PI);
|
||||
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||
const lonSpan = cosLat < 1e-6 ? 180 : Math.min(180, latSpan / cosLat);
|
||||
const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180;
|
||||
return {
|
||||
label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`,
|
||||
west: lonSpan >= 180 ? -180 : wrap(lon - lonSpan),
|
||||
east: lonSpan >= 180 ? 180 : wrap(lon + lonSpan),
|
||||
south: Math.max(-90, lat - latSpan),
|
||||
north: Math.min(90, lat + latSpan),
|
||||
center: { lat, lon, radiusKm },
|
||||
};
|
||||
}
|
||||
|
||||
/** Great-circle distance in kilometers. */
|
||||
export function distanceKm(a, b) {
|
||||
const toRad = Math.PI / 180;
|
||||
const dLat = (b.lat - a.lat) * toRad;
|
||||
const dLon = (b.lon - a.lon) * toRad;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/** Whether a point lies inside a resolved area, including radius areas. */
|
||||
export function areaContains(area, point) {
|
||||
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
|
||||
return false;
|
||||
if (point.lat < area.south || point.lat > area.north) return false;
|
||||
const inLongitude =
|
||||
area.west <= area.east
|
||||
? point.lon >= area.west && point.lon <= area.east
|
||||
: point.lon >= area.west || point.lon <= area.east;
|
||||
if (!inLongitude) return false;
|
||||
return area.center
|
||||
? distanceKm(area.center, point) <= area.center.radiusKm
|
||||
: true;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { areaContains, distanceKm, resolveArea } from './area.js';
|
||||
import { placeFromGeocodeResult } from './places.js';
|
||||
|
||||
test('an area needs exactly one form', async () => {
|
||||
for (const area of [
|
||||
{},
|
||||
{ place: 'Oslo', bbox: [0, 0, 1, 1] },
|
||||
{ lat: 1, lon: 2 },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
resolveArea(area),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
resolveArea({ bbox: [0, 10, 1, 5] }),
|
||||
/south below north/,
|
||||
);
|
||||
});
|
||||
|
||||
test('boxes, including antimeridian boxes, contain the expected points', async () => {
|
||||
const box = await resolveArea({ bbox: [-10, 40, 10, 50] });
|
||||
assert.equal(areaContains(box, { lat: 45, lon: 0 }), true);
|
||||
assert.equal(areaContains(box, { lat: 45, lon: 20 }), false);
|
||||
const pacific = await resolveArea({ bbox: [170, -20, -170, 0] });
|
||||
assert.equal(areaContains(pacific, { lat: -10, lon: 179 }), true);
|
||||
assert.equal(areaContains(pacific, { lat: -10, lon: -175 }), true);
|
||||
assert.equal(areaContains(pacific, { lat: -10, lon: 0 }), false);
|
||||
assert.equal(areaContains(box, { lat: Number.NaN, lon: 0 }), false);
|
||||
});
|
||||
|
||||
test('radius areas use great-circle distance', async () => {
|
||||
const circle = await resolveArea({ lat: 37.77, lon: -122.42, radius_km: 50 });
|
||||
assert.equal(circle.center.radiusKm, 50);
|
||||
assert.equal(areaContains(circle, { lat: 37.8, lon: -122.27 }), true);
|
||||
assert.equal(areaContains(circle, { lat: 38.58, lon: -121.49 }), false);
|
||||
assert.ok(
|
||||
Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.1,
|
||||
);
|
||||
// A circle across the antimeridian wraps its box.
|
||||
const dateline = await resolveArea({ lat: 0, lon: 179.9, radius_km: 100 });
|
||||
assert.ok(dateline.west > dateline.east);
|
||||
assert.equal(areaContains(dateline, { lat: 0, lon: -179.9 }), true);
|
||||
});
|
||||
|
||||
test('place names resolve through the places service', async () => {
|
||||
const places = {
|
||||
resolve: async (name) =>
|
||||
name === 'Taiwan'
|
||||
? {
|
||||
name: 'Taiwan',
|
||||
bounds: { west: 119, south: 21, east: 123, north: 26 },
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const taiwan = await resolveArea(
|
||||
{ place: 'Taiwan' },
|
||||
{ services: { places } },
|
||||
);
|
||||
assert.deepEqual(taiwan, {
|
||||
label: 'Taiwan',
|
||||
west: 119,
|
||||
south: 21,
|
||||
east: 123,
|
||||
north: 26,
|
||||
});
|
||||
await assert.rejects(
|
||||
resolveArea({ place: 'Nowhere' }, { services: { places } }),
|
||||
/No place matched "Nowhere"/,
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveArea({ place: 'Taiwan' }),
|
||||
(error) => error.code === 'unsupported',
|
||||
);
|
||||
});
|
||||
|
||||
test('geocode results become bounded places', () => {
|
||||
assert.deepEqual(
|
||||
placeFromGeocodeResult({
|
||||
formatted_address: 'Oslo, Norway',
|
||||
geometry: {
|
||||
location: { lat: 59.9, lng: 10.7 },
|
||||
viewport: {
|
||||
southwest: { lat: 59.8, lng: 10.5 },
|
||||
northeast: { lat: 60, lng: 10.9 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'Oslo, Norway',
|
||||
bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeFromGeocodeResult({ geometry: { location: { lat: 0, lng: 179.9 } } })
|
||||
.bounds,
|
||||
{ west: 179.65, south: -0.25, east: 180, north: 0.25 },
|
||||
);
|
||||
assert.equal(placeFromGeocodeResult({ geometry: {} }), null);
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, defineTool, ToolError } from './catalog.js';
|
||||
import { validateValue } from './schema.js';
|
||||
|
||||
const echo = (name, extra = {}) =>
|
||||
defineTool({
|
||||
name,
|
||||
title: `Echo ${name}`,
|
||||
description: 'Returns its arguments.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { word: { type: 'string', maxLength: 5 } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
run: async (args) => ({ summary: name, data: { args } }),
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('definitions are validated, frozen and read-only by default', () => {
|
||||
const tool = echo('echo');
|
||||
assert.equal(Object.isFrozen(tool), true);
|
||||
assert.equal(tool.kind, 'query');
|
||||
assert.deepEqual(tool.annotations, { readOnlyHint: true });
|
||||
assert.equal(echo('act', { kind: 'action' }).annotations.readOnlyHint, false);
|
||||
assert.throws(() => echo('Bad-Name'), /Invalid tool name/);
|
||||
assert.throws(() => echo('x', { kind: 'other' }), /Invalid tool kind/);
|
||||
assert.throws(() => echo('x', { title: '' }), /needs a title/);
|
||||
assert.throws(
|
||||
() =>
|
||||
echo('x', {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string', pattern: '.' } },
|
||||
},
|
||||
}),
|
||||
/unsupported keyword pattern/,
|
||||
);
|
||||
assert.throws(
|
||||
() => echo('x', { inputSchema: { type: 'string' } }),
|
||||
/must describe an object/,
|
||||
);
|
||||
});
|
||||
|
||||
test('composition rejects duplicates unless replaced, and hides tools without services', () => {
|
||||
const first = echo('echo');
|
||||
const second = echo('echo', { description: 'Replacement.' });
|
||||
assert.throws(
|
||||
() => composeCatalog({ tools: [first, second] }),
|
||||
/Duplicate tool name: echo/,
|
||||
);
|
||||
assert.throws(
|
||||
() => composeCatalog({ tools: [first], replace: ['other'] }),
|
||||
/Cannot replace unknown tool/,
|
||||
);
|
||||
const replaced = composeCatalog({
|
||||
tools: [first, second],
|
||||
replace: ['echo'],
|
||||
});
|
||||
assert.equal(replaced.get('echo').description, 'Replacement.');
|
||||
|
||||
const needsFeed = echo('feed', { requires: ['feed'] });
|
||||
assert.deepEqual(
|
||||
composeCatalog({ tools: [first, needsFeed] })
|
||||
.list()
|
||||
.map((tool) => tool.name),
|
||||
['echo'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
composeCatalog({ tools: [first, needsFeed], services: { feed: {} } })
|
||||
.list()
|
||||
.map((tool) => tool.name),
|
||||
['echo', 'feed'],
|
||||
);
|
||||
});
|
||||
|
||||
test('calls validate arguments and pass through interceptors outermost first', async () => {
|
||||
const order = [];
|
||||
const catalog = composeCatalog({
|
||||
tools: [echo('echo')],
|
||||
interceptors: [
|
||||
async (call, next) => {
|
||||
order.push(`outer:${call.tool.name}`);
|
||||
const result = await next(call);
|
||||
order.push('outer:done');
|
||||
return result;
|
||||
},
|
||||
(call, next) => {
|
||||
order.push('inner');
|
||||
return next({ ...call, args: { word: 'hi' } });
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await catalog.call('echo', { word: 'ignored' });
|
||||
assert.deepEqual(result, { summary: 'echo', data: { args: { word: 'hi' } } });
|
||||
assert.deepEqual(order, ['outer:echo', 'inner', 'outer:done']);
|
||||
|
||||
await assert.rejects(
|
||||
composeCatalog({ tools: [echo('echo')] }).call('echo', {
|
||||
word: 'toolong',
|
||||
extra: 1,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof ToolError &&
|
||||
error.code === 'invalid_arguments' &&
|
||||
/arguments\.word must be at most 5 characters/.test(error.message) &&
|
||||
/unknown property extra/.test(error.message),
|
||||
);
|
||||
await assert.rejects(
|
||||
composeCatalog({ tools: [] }).call('missing'),
|
||||
(error) => error.code === 'unsupported',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tool that returns no summary is a programming error, not a tool error', async () => {
|
||||
const broken = echo('broken', { run: async () => ({ data: {} }) });
|
||||
await assert.rejects(
|
||||
composeCatalog({ tools: [broken] }).call('broken'),
|
||||
(error) => error instanceof TypeError && !(error instanceof ToolError),
|
||||
);
|
||||
});
|
||||
|
||||
test('tool errors only use known codes', () => {
|
||||
assert.throws(() => new ToolError('nope', 'x'), /Unknown tool error code/);
|
||||
assert.equal(
|
||||
new ToolError('retry_later', 'x', { retryAfterSeconds: 5 })
|
||||
.retryAfterSeconds,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
test('the validator checks every supported keyword', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
required: ['n'],
|
||||
properties: {
|
||||
n: { type: 'integer', minimum: 1, maximum: 3 },
|
||||
list: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
items: { type: 'number' },
|
||||
},
|
||||
mode: { type: 'string', enum: ['a', 'b'] },
|
||||
flag: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
validateValue(schema, { n: 2, list: [1], mode: 'a', flag: true }),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateValue(schema, { n: 1.5, list: [], mode: 'c', flag: 'yes' }),
|
||||
[
|
||||
'arguments.n must be an integer',
|
||||
'arguments.list must have at least 1 items',
|
||||
'arguments.mode must be one of "a", "b"',
|
||||
'arguments.flag must be true or false',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(validateValue(schema, {}), ['arguments is missing n']);
|
||||
assert.deepEqual(validateValue(schema, []), ['arguments must be an object']);
|
||||
assert.deepEqual(validateValue(schema, { n: Number.NaN }), [
|
||||
'arguments.n must be a number',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Tools that answer questions from God's Eye View data, independent of the
|
||||
* surface that exposes them. See docs/TOOLS.md.
|
||||
*/
|
||||
|
||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||
import { getRecentLaunches } from './queries/space.js';
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
composeCatalog,
|
||||
ToolError,
|
||||
TOOL_ERROR_CODES,
|
||||
} from './catalog.js';
|
||||
export { AREA_SCHEMA, resolveArea, areaContains, distanceKm } from './area.js';
|
||||
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
||||
export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js';
|
||||
|
||||
/** Every query Core defines, in a stable order. */
|
||||
export const coreTools = Object.freeze([
|
||||
getEarthquakes,
|
||||
getActiveFires,
|
||||
getRecentLaunches,
|
||||
]);
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createLocalMcpServer } from '../../server/mcp/server.js';
|
||||
import { createApiFetch } from '../../server/mcp/services.js';
|
||||
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
|
||||
|
||||
const usgs = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
id: 'us1',
|
||||
geometry: { type: 'Point', coordinates: [121.5, 24, 10] },
|
||||
properties: { mag: 5.1, place: 'near Hualien', time: 1767229200000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('relative API requests resolve against the configured base', async () => {
|
||||
const seen = [];
|
||||
const apiFetch = createApiFetch({
|
||||
apiBase: 'http://127.0.0.1:5000',
|
||||
fetchImpl: async (url) => seen.push(String(url)),
|
||||
});
|
||||
await apiFetch('/api/launches');
|
||||
await apiFetch('https://earthquake.usgs.gov/feed.geojson');
|
||||
assert.deepEqual(seen, [
|
||||
'http://127.0.0.1:5000/api/launches',
|
||||
'https://earthquake.usgs.gov/feed.geojson',
|
||||
]);
|
||||
assert.throws(
|
||||
() => createApiFetch({ apiBase: 'file:///etc' }),
|
||||
/http\(s\) URL/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the stdio server answers newline-delimited requests using only its data sources', async () => {
|
||||
const contacted = [];
|
||||
const server = createLocalMcpServer({
|
||||
apiBase: 'http://127.0.0.1:5000',
|
||||
fetchImpl: async (url) => {
|
||||
contacted.push(new URL(url).origin);
|
||||
return Response.json(usgs);
|
||||
},
|
||||
});
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
let written = '';
|
||||
output.on('data', (chunk) => (written += chunk));
|
||||
const served = serveStdio(server, { input, output });
|
||||
input.write(
|
||||
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n',
|
||||
);
|
||||
input.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n\n');
|
||||
input.write('not json\n');
|
||||
input.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
|
||||
input.end(
|
||||
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_earthquakes","arguments":{}}}\n',
|
||||
);
|
||||
await served;
|
||||
const responses = written
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
const byId = new Map(responses.map((response) => [response.id, response]));
|
||||
assert.equal(responses.length, 4);
|
||||
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
|
||||
assert.equal(byId.get(null).error.code, -32700);
|
||||
assert.deepEqual(
|
||||
byId.get(2).result.tools.map((tool) => tool.name),
|
||||
['get_earthquakes', 'get_active_fires', 'get_recent_launches'],
|
||||
);
|
||||
assert.equal(
|
||||
byId.get(3).result.content[0].text,
|
||||
'1 earthquake of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
|
||||
);
|
||||
// The earthquake feed is fetched directly; nothing else was contacted.
|
||||
assert.deepEqual(contacted, ['https://earthquake.usgs.gov']);
|
||||
});
|
||||
|
||||
test('command-line arguments are strict', () => {
|
||||
assert.deepEqual(parseArgs([]), {});
|
||||
assert.deepEqual(parseArgs(['--api-base', 'http://localhost:5173']), {
|
||||
apiBase: 'http://localhost:5173',
|
||||
});
|
||||
assert.throws(() => parseArgs(['--port', '1']), /Unknown argument/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* A `places` service backed by the app's `/api/geocode` route. Applications
|
||||
* with a different place search can supply any object with the same
|
||||
* `resolve(name, { signal })` contract.
|
||||
*/
|
||||
|
||||
const POINT_RADIUS_DEGREES = 0.25;
|
||||
|
||||
/** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */
|
||||
export function createGeocodePlaceService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async resolve(name, { signal } = {}) {
|
||||
const query = new URLSearchParams({ q: name });
|
||||
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
|
||||
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
return placeFromGeocodeResult(payload?.results?.[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert one geocode result to a place with bounds, or null. */
|
||||
export function placeFromGeocodeResult(result) {
|
||||
const location = result?.geometry?.location;
|
||||
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
||||
return null;
|
||||
const viewport = result.geometry.viewport;
|
||||
const bounds =
|
||||
viewport &&
|
||||
[
|
||||
viewport.southwest?.lat,
|
||||
viewport.southwest?.lng,
|
||||
viewport.northeast?.lat,
|
||||
viewport.northeast?.lng,
|
||||
].every(Number.isFinite)
|
||||
? {
|
||||
west: viewport.southwest.lng,
|
||||
south: viewport.southwest.lat,
|
||||
east: viewport.northeast.lng,
|
||||
north: viewport.northeast.lat,
|
||||
}
|
||||
: {
|
||||
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
|
||||
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
|
||||
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
|
||||
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
|
||||
};
|
||||
return { name: result.formatted_address || null, bounds };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Natural hazard queries: earthquakes and active fires. */
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
export const getEarthquakes = defineTool({
|
||||
name: 'get_earthquakes',
|
||||
title: 'Recent earthquakes',
|
||||
description:
|
||||
'Earthquakes of magnitude 2.5 or more from the USGS feed for the last 24 ' +
|
||||
'hours, strongest first. Optionally limited to an area and a minimum magnitude.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
min_magnitude: { type: 'number', minimum: 2.5, maximum: 10 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['earthquakes'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const minimum = args.min_magnitude ?? 2.5;
|
||||
const events = (await services.earthquakes.getSnapshot({ signal }))
|
||||
.filter(
|
||||
(event) => event.mag >= minimum && (!area || areaContains(area, event)),
|
||||
)
|
||||
.sort((a, b) => b.mag - a.mag || (b.time ?? 0) - (a.time ?? 0));
|
||||
const result = capRows(events, args.limit);
|
||||
const where = area ? ` in ${area.label}` : ' worldwide';
|
||||
const strongest = events[0]
|
||||
? `; strongest M${events[0].mag.toFixed(1)}${events[0].place ? ` ${events[0].place}` : ''}`
|
||||
: '';
|
||||
return {
|
||||
summary: `${countNoun(events.length, 'earthquake')} of M${minimum}+ in the last 24 hours${where}${strongest}.`,
|
||||
data: {
|
||||
...result,
|
||||
rows: result.rows.map((event) => ({
|
||||
id: event.usgsId ?? event.stableId,
|
||||
magnitude: event.mag,
|
||||
place: event.place,
|
||||
time: isoTime(event.time),
|
||||
lat: event.lat,
|
||||
lon: event.lon,
|
||||
depth_km: event.depthKm,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getActiveFires = defineTool({
|
||||
name: 'get_active_fires',
|
||||
title: 'Active fires',
|
||||
description:
|
||||
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
|
||||
'area, most intense (fire radiative power) first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['fires'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const snapshot = await services.fires.getSnapshot({ signal });
|
||||
if (snapshot.keyRequired)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Fire detections need a NASA FIRMS key configured for this server',
|
||||
);
|
||||
const fires = snapshot.fires
|
||||
.filter((fire) => areaContains(area, fire))
|
||||
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
|
||||
const result = capRows(fires, args.limit);
|
||||
return {
|
||||
summary: `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours.`,
|
||||
data: {
|
||||
...result,
|
||||
rows: result.rows.map((fire) => ({
|
||||
lat: fire.lat,
|
||||
lon: fire.lon,
|
||||
frp_mw: fire.frp ?? null,
|
||||
confidence: fire.confidence ?? null,
|
||||
acquired:
|
||||
[fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null,
|
||||
satellite: fire.satellite ?? null,
|
||||
day_night: fire.daynight ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
|
||||
const earthquakes = {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'a',
|
||||
usgsId: 'us1',
|
||||
lon: 121.5,
|
||||
lat: 24,
|
||||
depthKm: 10,
|
||||
mag: 5.1,
|
||||
place: 'near Hualien',
|
||||
time: Date.UTC(2026, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
stableId: 'b',
|
||||
usgsId: 'us2',
|
||||
lon: -120,
|
||||
lat: 36,
|
||||
depthKm: 5,
|
||||
mag: 3.2,
|
||||
place: 'Central California',
|
||||
time: Date.UTC(2026, 0, 1, 2),
|
||||
},
|
||||
{
|
||||
stableId: 'c',
|
||||
usgsId: null,
|
||||
lon: 122,
|
||||
lat: 23.5,
|
||||
depthKm: null,
|
||||
mag: 2.6,
|
||||
place: null,
|
||||
time: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fires = (snapshot) => ({ getSnapshot: async () => snapshot });
|
||||
const launches = (payload) => ({ getLaunches: async () => payload });
|
||||
const taiwan = {
|
||||
resolve: async () => ({
|
||||
name: 'Taiwan',
|
||||
bounds: { west: 119, south: 21, east: 123, north: 26 },
|
||||
}),
|
||||
};
|
||||
|
||||
test('earthquakes are filtered, ordered strongest first and capped', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { earthquakes, places: taiwan },
|
||||
});
|
||||
const all = await catalog.call('get_earthquakes', {});
|
||||
assert.equal(
|
||||
all.summary,
|
||||
'3 earthquakes of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.rows.map((row) => row.id),
|
||||
['us1', 'us2', 'c'],
|
||||
);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
id: 'us1',
|
||||
magnitude: 5.1,
|
||||
place: 'near Hualien',
|
||||
time: '2026-01-01T01:00:00.000Z',
|
||||
lat: 24,
|
||||
lon: 121.5,
|
||||
depth_km: 10,
|
||||
});
|
||||
|
||||
const local = await catalog.call('get_earthquakes', {
|
||||
area: { place: 'Taiwan' },
|
||||
min_magnitude: 3,
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(
|
||||
local.summary,
|
||||
'1 earthquake of M3+ in the last 24 hours in Taiwan; strongest M5.1 near Hualien.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
total: local.data.total,
|
||||
returned: local.data.returned,
|
||||
truncated: local.data.truncated,
|
||||
},
|
||||
{ total: 1, returned: 1, truncated: false },
|
||||
);
|
||||
|
||||
const capped = await catalog.call('get_earthquakes', { limit: 2 });
|
||||
assert.equal(capped.data.truncated, true);
|
||||
assert.equal(capped.data.total, 3);
|
||||
});
|
||||
|
||||
test('fires need an area and report a missing key as unavailable', async () => {
|
||||
const snapshot = {
|
||||
fires: [
|
||||
{
|
||||
lat: 24,
|
||||
lon: 121,
|
||||
frp: 12,
|
||||
confidence: 'h',
|
||||
acqDate: '2026-01-01',
|
||||
acqTime: '0130',
|
||||
satellite: 'N',
|
||||
daynight: 'N',
|
||||
},
|
||||
{ lat: 25, lon: 122, frp: 40 },
|
||||
{ lat: -33, lon: 151, frp: 99 },
|
||||
],
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { fires: fires(snapshot), places: taiwan },
|
||||
});
|
||||
await assert.rejects(catalog.call('get_active_fires', {}), /missing area/);
|
||||
const result = await catalog.call('get_active_fires', {
|
||||
area: { place: 'Taiwan' },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 fire detections in Taiwan in the last 24 hours.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.frp_mw),
|
||||
[40, 12],
|
||||
);
|
||||
assert.equal(result.data.rows[1].acquired, '2026-01-01 0130');
|
||||
|
||||
const keyless = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { fires: fires({ keyRequired: true }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
keyless.call('get_active_fires', { area: { bbox: [0, 0, 1, 1] } }),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && /NASA FIRMS key/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('recent launches are newest first with missing fields as null', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
launches: launches({
|
||||
results: [
|
||||
{
|
||||
id: 'old',
|
||||
name: 'Falcon 9 | Starlink',
|
||||
net: '2026-01-01T00:00:00Z',
|
||||
status: { name: 'Launch Successful' },
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
name: 'Electron | Mission',
|
||||
net: '2026-01-05T00:00:00Z',
|
||||
launch_service_provider: { name: 'Rocket Lab' },
|
||||
rocket: { configuration: { full_name: 'Electron' } },
|
||||
pad: { name: 'LC-1A', location: { name: 'Mahia' } },
|
||||
mission: { name: 'Mission', orbit: { name: 'Low Earth Orbit' } },
|
||||
},
|
||||
{ id: 'unnamed' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_recent_launches', {});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 launches in the last 30 days; latest Electron | Mission.',
|
||||
);
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
id: 'new',
|
||||
name: 'Electron | Mission',
|
||||
time: '2026-01-05T00:00:00Z',
|
||||
status: null,
|
||||
provider: 'Rocket Lab',
|
||||
rocket: 'Electron',
|
||||
pad: 'LC-1A',
|
||||
location: 'Mahia',
|
||||
mission: 'Mission',
|
||||
orbit: 'Low Earth Orbit',
|
||||
});
|
||||
const broken = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { launches: launches({}) },
|
||||
});
|
||||
await assert.rejects(
|
||||
broken.call('get_recent_launches', {}),
|
||||
(error) => error.code === 'malformed',
|
||||
);
|
||||
});
|
||||
|
||||
test('every core tool is a read-only query with a unique name', () => {
|
||||
const names = coreTools.map((tool) => tool.name);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
for (const tool of coreTools) {
|
||||
assert.equal(tool.kind, 'query', tool.name);
|
||||
assert.equal(tool.annotations.readOnlyHint, true, tool.name);
|
||||
assert.ok(tool.requires.length > 0, tool.name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Space queries: launches. */
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
|
||||
export const getRecentLaunches = defineTool({
|
||||
name: 'get_recent_launches',
|
||||
title: 'Recent launches',
|
||||
description:
|
||||
'Orbital launches from Launch Library 2 over the last 30 days, newest ' +
|
||||
'first, with provider, rocket, pad, mission and outcome.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { limit: LIMIT_SCHEMA },
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['launches'],
|
||||
async run(args, { services, signal }) {
|
||||
const payload = await services.launches.getLaunches({ signal });
|
||||
const launches = Array.isArray(payload) ? payload : payload.results;
|
||||
if (!Array.isArray(launches))
|
||||
throw new ToolError('malformed', 'The launch feed returned no launches');
|
||||
const rows = launches
|
||||
.map((launch) => ({
|
||||
id: text(launch?.id),
|
||||
name: text(launch?.name),
|
||||
time: text(launch?.net),
|
||||
status: text(launch?.status?.name),
|
||||
provider: text(launch?.launch_service_provider?.name),
|
||||
rocket: text(
|
||||
launch?.rocket?.configuration?.full_name ??
|
||||
launch?.rocket?.configuration?.name,
|
||||
),
|
||||
pad: text(launch?.pad?.name),
|
||||
location: text(launch?.pad?.location?.name),
|
||||
mission: text(launch?.mission?.name),
|
||||
orbit: text(launch?.mission?.orbit?.name),
|
||||
}))
|
||||
.filter((launch) => launch.name)
|
||||
.sort((a, b) => String(b.time).localeCompare(String(a.time)));
|
||||
const result = capRows(rows, args.limit);
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}.`,
|
||||
data: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Result sizing shared by tools that return lists. */
|
||||
|
||||
export const DEFAULT_LIMIT = 25;
|
||||
export const MAX_LIMIT = 200;
|
||||
|
||||
/** JSON Schema for the `limit` argument. */
|
||||
export const LIMIT_SCHEMA = Object.freeze({
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: MAX_LIMIT,
|
||||
description: `Most rows to return (default ${DEFAULT_LIMIT}, at most ${MAX_LIMIT}).`,
|
||||
});
|
||||
|
||||
/** Keep the first `limit` rows and report how many matched in total. */
|
||||
export function capRows(rows, limit = DEFAULT_LIMIT) {
|
||||
const kept = rows.slice(0, Math.min(limit, MAX_LIMIT));
|
||||
return {
|
||||
total: rows.length,
|
||||
returned: kept.length,
|
||||
truncated: kept.length < rows.length,
|
||||
rows: kept,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format an epoch millisecond time as ISO 8601, or null. */
|
||||
export function isoTime(ms) {
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
|
||||
/** "1 earthquake", "3 earthquakes". */
|
||||
export function countNoun(count, singular, plural = `${singular}s`) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user