diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..e8915b417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..bd68e4879 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,17 @@ # 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 Display > HUD > Layout includes Cyber, also available through the HUD voice diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 000000000..f955f32ba --- /dev/null +++ b/docs/TOOLS.md @@ -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 | diff --git a/package.json b/package.json index e48881c6d..13b7bd8fd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "test:track": "node scripts/track-regression.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", - "qa:transit": "node scripts/qa-transit.mjs" + "qa:transit": "node scripts/qa-transit.mjs", + "mcp": "node server/mcp/stdio.js" }, "dependencies": { "@jtarrio/signals": "^0.10.0", @@ -250,6 +251,8 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.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" } } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index a5e61f039..72e163988 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -37,6 +37,8 @@ const portableExport = (key) => ) || [ './director', + './tools', + './tools/mcp', './voice/action-schemas', './voice/session', './data/lifecycle', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..b2358d5ca 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -864,5 +864,10 @@ "src/layers/traffic/motion.test.mjs", "scripts/qa-journey-recorder.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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..636004286 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3072,5 +3072,30 @@ "src/layers/perimeters/source.js" ], "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": [] } } diff --git a/server/mcp/server.js b/server/mcp/server.js new file mode 100644 index 000000000..daacd3d3c --- /dev/null +++ b/server/mcp/server.js @@ -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, + }); +} diff --git a/server/mcp/services.js b/server/mcp/services.js new file mode 100644 index 000000000..07a492692 --- /dev/null +++ b/server/mcp/services.js @@ -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 }), + }; +} diff --git a/server/mcp/stdio.js b/server/mcp/stdio.js new file mode 100644 index 000000000..c372e1f6c --- /dev/null +++ b/server/mcp/stdio.js @@ -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 ` 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; + } +} diff --git a/src/tools/area.js b/src/tools/area.js new file mode 100644 index 000000000..35b27315f --- /dev/null +++ b/src/tools/area.js @@ -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; +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs new file mode 100644 index 000000000..fac64690a --- /dev/null +++ b/src/tools/area.test.mjs @@ -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); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js new file mode 100644 index 000000000..a6e3db530 --- /dev/null +++ b/src/tools/catalog.js @@ -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 }); + }, + }); +} diff --git a/src/tools/catalog.test.mjs b/src/tools/catalog.test.mjs new file mode 100644 index 000000000..997bf002f --- /dev/null +++ b/src/tools/catalog.test.mjs @@ -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', + ]); +}); diff --git a/src/tools/index.js b/src/tools/index.js new file mode 100644 index 000000000..c8502261b --- /dev/null +++ b/src/tools/index.js @@ -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, +]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs new file mode 100644 index 000000000..7b193aac4 --- /dev/null +++ b/src/tools/localServer.test.mjs @@ -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/); +}); diff --git a/src/tools/mcp/http.js b/src/tools/mcp/http.js new file mode 100644 index 000000000..fc8549e19 --- /dev/null +++ b/src/tools/mcp/http.js @@ -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` 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', + }, + }); +} diff --git a/src/tools/mcp/index.js b/src/tools/mcp/index.js new file mode 100644 index 000000000..5c1e985bb --- /dev/null +++ b/src/tools/mcp/index.js @@ -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'; diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs new file mode 100644 index 000000000..61a2ff63f --- /dev/null +++ b/src/tools/mcp/mcp.test.mjs @@ -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'); +}); diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js new file mode 100644 index 000000000..0450b66ea --- /dev/null +++ b/src/tools/mcp/protocol.js @@ -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); +} diff --git a/src/tools/places.js b/src/tools/places.js new file mode 100644 index 000000000..7e5005ce3 --- /dev/null +++ b/src/tools/places.js @@ -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 }; +} diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js new file mode 100644 index 000000000..15e80507a --- /dev/null +++ b/src/tools/queries/hazards.js @@ -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, + })), + }, + }; + }, +}); diff --git a/src/tools/queries/queries.test.mjs b/src/tools/queries/queries.test.mjs new file mode 100644 index 000000000..b64f5e009 --- /dev/null +++ b/src/tools/queries/queries.test.mjs @@ -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); + } +}); diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js new file mode 100644 index 000000000..371d8ecbd --- /dev/null +++ b/src/tools/queries/space.js @@ -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, + }; + }, +}); diff --git a/src/tools/results.js b/src/tools/results.js new file mode 100644 index 000000000..331e56941 --- /dev/null +++ b/src/tools/results.js @@ -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}`; +} diff --git a/src/tools/schema.js b/src/tools/schema.js new file mode 100644 index 000000000..02fc44396 --- /dev/null +++ b/src/tools/schema.js @@ -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; +}