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

+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
- Add tools for language-model clients and a local MCP server. `npm run mcp`
serves earthquake, active-fire and recent-launch queries over stdio to
clients such as Claude Code, reading from a running app. Tools are defined
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
- Public Overpass instances are no longer used by default. Street Traffic - Public Overpass instances are no longer used by default. Street Traffic
roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen
on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a
+12
View File
@@ -1,5 +1,17 @@
# God's Eye View Current State # God's Eye View Current State
## Tools and local MCP server — October 1, 2026
`gods-eye-view/tools` defines queries that answer questions from the app's data,
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
reading from a running app's `/api` routes (default `http://localhost:4173`).
The first queries are `get_earthquakes`, `get_active_fires` and
`get_recent_launches`. Tools reuse the layers' portable source factories, take a
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. The app does not import the tools, so its runtime
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
## Cyber HUD — September 23, 2026 ## Cyber HUD — September 23, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice Display > HUD > Layout includes Cyber, also available through the HUD voice
+89
View File
@@ -0,0 +1,89 @@
# Tools and the MCP server
Tools answer questions from God's Eye View data for language-model clients.
They are defined once and exposed through adapters; the Model Context Protocol
(MCP) is the first.
## Layers
| Owner | Responsibility |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers |
| `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts |
| `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries |
| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes and serves stdio |
Dependencies point downward only. `gods-eye-view/tools` and
`gods-eye-view/tools/mcp` are portable exports: they reach no application,
rendering, Node, Cesium or browser-global code, which
`npm run check:boundaries` enforces. Nothing in the application imports them.
## Definitions and composition
`defineTool({ name, kind, title, description, inputSchema, requires, run })`
validates and freezes a tool. `kind` is `query` (answers from data, read-only)
or `action`. `inputSchema` uses a JSON Schema subset that `src/tools/schema.js`
checks completely; unsupported keywords are rejected at definition time.
`run(args, { services, signal })` resolves to `{ summary, data }`: one sentence
for people and a structured object for programs.
`composeCatalog({ tools, services, replace, interceptors })` builds a catalog:
- **Tools**: an application adds its own tools to `coreTools`. Reusing a name
fails unless the name is listed in `replace`.
- **Services**: each tool names the services it reads in `requires`. Tools
whose services are not supplied are left out.
- **Interceptors**: `(call, next) => next(call)` functions wrap every call,
outermost first. They can observe, reject or change a call.
Expected failures throw `ToolError` with one of `invalid_arguments`,
`unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are
reported to clients without details.
## Services
Services are the portable source factories the layers already use, such as
`createUsgsEarthquakeSource`, `createFirmsSource` and `createLaunchSource`,
plus a `places` service with `resolve(name, { signal })`. Sources request
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
code runs wherever an application routes those paths.
`createGeocodePlaceService` resolves place names through `/api/geocode`.
## The `area` argument
Location-scoped tools take `area` as exactly one of a `place` name, a `bbox`
(`[west, south, east, north]`, crossing the antimeridian when west exceeds
east), or `lat`, `lon` and `radius_km`. Lists default to 25 rows, at most 200,
and report `total`, `returned` and `truncated`.
## MCP
`createMcpServer({ catalog, name, version, instructions, descriptions, decorate })`
implements `initialize`, `ping`, `tools/list` and `tools/call` for protocol
revisions 2025-11-25, 2025-06-18 and 2025-03-26. `descriptions` overrides a
tool's title or description for this surface; `decorate(definition, tool)`
merges extra fields into each listed definition. `createMcpHttpHandler(server)`
returns a `Request`-to-`Response` handler for stateless Streamable HTTP: one
JSON-RPC message per POST, answered with JSON. The host owns routing and any
access control in front of it.
## Running locally
Start the app (`npm run dev` or `npm run preview`), then register the stdio
server with an MCP client, for example Claude Code:
```bash
claude mcp add gods-eye-view -- npm --prefix /path/to/gods-eye-view run --silent mcp
```
`npm run mcp -- --api-base http://localhost:4173` selects another server. The
local server makes no requests other than to the app's `/api` routes and the
public feeds the sources already use.
## Tools
| Tool | Reads | Returns |
| --------------------- | ------------- | ------------------------------------------------------------- |
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
+5 -2
View File
@@ -46,7 +46,8 @@
"test:track": "node scripts/track-regression.mjs", "test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs" "qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js"
}, },
"dependencies": { "dependencies": {
"@jtarrio/signals": "^0.10.0", "@jtarrio/signals": "^0.10.0",
@@ -250,6 +251,8 @@
"./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/standalone": "./src/data/transit.js",
"./layers/transit/source": "./src/layers/transit/source.js", "./layers/transit/source": "./src/layers/transit/source.js",
"./sources/transit-service": "./src/sources/transitService.js", "./sources/transit-service": "./src/sources/transitService.js",
"./sources/map-features": "./src/sources/overpassFeatures.js" "./sources/map-features": "./src/sources/overpassFeatures.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js"
} }
} }
+2
View File
@@ -37,6 +37,8 @@ const portableExport = (key) =>
) || ) ||
[ [
'./director', './director',
'./tools',
'./tools/mcp',
'./voice/action-schemas', './voice/action-schemas',
'./voice/session', './voice/session',
'./data/lifecycle', './data/lifecycle',
+6 -1
View File
@@ -864,5 +864,10 @@
"src/layers/traffic/motion.test.mjs", "src/layers/traffic/motion.test.mjs",
"scripts/qa-journey-recorder.mjs", "scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs", "scripts/qa-alpr-journey.mjs",
"scripts/qa-context-arrivals.mjs" "scripts/qa-context-arrivals.mjs",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs"
] ]
+25
View File
@@ -3072,5 +3072,30 @@
"src/layers/perimeters/source.js" "src/layers/perimeters/source.js"
], ],
"external": [] "external": []
},
"tools": {
"exports": ["./tools"],
"modules": [
"src/tools/index.js",
"src/tools/catalog.js",
"src/tools/schema.js",
"src/tools/area.js",
"src/tools/results.js",
"src/tools/places.js",
"src/tools/queries/hazards.js",
"src/tools/queries/space.js"
],
"external": []
},
"tools-mcp": {
"exports": ["./tools/mcp"],
"modules": [
"src/tools/mcp/index.js",
"src/tools/mcp/protocol.js",
"src/tools/mcp/http.js",
"src/tools/catalog.js",
"src/tools/schema.js"
],
"external": []
} }
} }
+30
View File
@@ -0,0 +1,30 @@
/** The local MCP server: Core's tools over services backed by a running app. */
import { readFileSync } from 'node:fs';
import { composeCatalog, coreTools } from '../../src/tools/index.js';
import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
const INSTRUCTIONS =
"Tools answer questions from God's Eye View's live public data. Location " +
'tools take an area: a place name, a bbox, or lat/lon with radius_km. ' +
'Results are capped; check truncated and total before concluding there is nothing more.';
/** Construct the local MCP server for Core's tools. */
export function createLocalMcpServer({
apiBase = DEFAULT_API_BASE,
fetchImpl,
} = {}) {
const { version } = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
);
return createMcpServer({
catalog: composeCatalog({
tools: coreTools,
services: createLocalToolServices({ apiBase, fetchImpl }),
}),
name: 'gods-eye-view',
version,
instructions: INSTRUCTIONS,
});
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Local composition of tool services: the portable source factories, pointed
* at a running God's Eye View server's `/api` routes.
*/
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
import { createFirmsSource } from '../../src/layers/firms/source.js';
import { createLaunchSource } from '../../src/layers/launches/source.js';
import { createGeocodePlaceService } from '../../src/tools/places.js';
export const DEFAULT_API_BASE = 'http://localhost:4173';
/** Resolve the sources' relative `/api/...` requests against `apiBase`. */
export function createApiFetch({
apiBase = DEFAULT_API_BASE,
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const base = new URL(apiBase);
if (!['http:', 'https:'].includes(base.protocol))
throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`);
return (input, init) =>
fetchImpl(
typeof input === 'string' && input.startsWith('/')
? new URL(input, base)
: input,
init,
);
}
/** Construct every service Core's tools read, backed by the local server. */
export function createLocalToolServices(options = {}) {
const fetchImpl = createApiFetch(options);
return {
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }),
places: createGeocodePlaceService({ fetchImpl }),
};
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Local MCP server over stdio for agents such as Claude Code and Codex.
*
* npm run mcp -- [--api-base http://localhost:4173]
*
* Reads newline-delimited JSON-RPC from stdin and writes responses to stdout.
* Tools read data from a running God's Eye View server (`npm run dev` or
* `npm run preview`) at the API base. Diagnostics go to stderr.
*/
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { parseErrorResponse } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
import { DEFAULT_API_BASE } from './services.js';
/** Serve newline-delimited JSON-RPC until `input` ends. */
export async function serveStdio(server, { input, output }) {
const lines = createInterface({ input, crlfDelay: Infinity });
const pending = new Set();
for await (const line of lines) {
if (!line.trim()) continue;
const work = respond(server, line).then((response) => {
if (response) output.write(`${JSON.stringify(response)}\n`);
});
pending.add(work);
work.finally(() => pending.delete(work));
}
await Promise.all(pending);
}
async function respond(server, line) {
let message;
try {
message = JSON.parse(line);
} catch {
return parseErrorResponse();
}
return server.handle(message);
}
/** Read `--api-base <url>` from command-line arguments. */
export function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--api-base' && argv[index + 1])
options.apiBase = argv[(index += 1)];
else throw new Error(`Unknown argument: ${argv[index]}`);
}
return options;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
const options = parseArgs(process.argv.slice(2));
console.error(
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
);
await serveStdio(createLocalMcpServer(options), {
input: process.stdin,
output: process.stdout,
});
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+120
View File
@@ -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;
}
+102
View File
@@ -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);
});
+143
View File
@@ -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 });
},
});
}
+166
View File
@@ -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',
]);
});
+24
View File
@@ -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,
]);
+87
View File
@@ -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/);
});
+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);
}
+51
View File
@@ -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 };
}
+98
View File
@@ -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,
})),
},
};
},
});
+202
View File
@@ -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);
}
});
+49
View File
@@ -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,
};
},
});
+33
View File
@@ -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}`;
}
+125
View File
@@ -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;
}