mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat: add a tool catalog and local MCP server
Define tools that answer questions from the app's data once, independent of the surface that exposes them. A tool declares its arguments and the services it reads; applications compose tools with their own services and optional call interceptors. Queries reuse the layers' portable source factories, so they run wherever an application routes /api requests. The first queries are get_earthquakes, get_active_fires and get_recent_launches, with a shared area argument (place name, bounding box, or point and radius) and capped lists. gods-eye-view/tools/mcp adapts a catalog to the Model Context Protocol with a small JSON-RPC handler and a stateless HTTP transport, adding no dependencies. npm run mcp serves Core's tools over stdio to local clients such as Claude Code, reading from a running app. Both exports are portable and checked by check:boundaries; the app does not import them.
This commit is contained in:
1 parent
e7707d9a0f
commit
c3dfa8e853
26 files changed
+1886
-3
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* The `area` argument shared by location-scoped tools: a bounding box, a
|
||||||
|
* center point with radius, or a place name resolved through the `places`
|
||||||
|
* service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ToolError } from './catalog.js';
|
||||||
|
|
||||||
|
const EARTH_RADIUS_KM = 6371.0088;
|
||||||
|
const MAX_RADIUS_KM = 2000;
|
||||||
|
|
||||||
|
/** JSON Schema for the `area` argument. */
|
||||||
|
export const AREA_SCHEMA = Object.freeze({
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'Where to look. Give exactly one of: place (a name such as "Taiwan" or ' +
|
||||||
|
'"San Francisco Bay"), bbox ([west, south, east, north] in degrees), or ' +
|
||||||
|
'lat and lon with radius_km.',
|
||||||
|
properties: {
|
||||||
|
place: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
bbox: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'number' },
|
||||||
|
minItems: 4,
|
||||||
|
maxItems: 4,
|
||||||
|
},
|
||||||
|
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||||
|
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||||
|
radius_km: { type: 'number', minimum: 0.1, maximum: MAX_RADIUS_KM },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an `area` argument to `{ label, west, south, east, north, center? }`.
|
||||||
|
* A box whose west edge exceeds its east edge crosses the antimeridian.
|
||||||
|
*/
|
||||||
|
export async function resolveArea(area, { services, signal } = {}) {
|
||||||
|
const given = ['place', 'bbox', 'lat'].filter((key) => area?.[key] != null);
|
||||||
|
if (given.length !== 1)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
'area needs exactly one of place, bbox, or lat/lon with radius_km',
|
||||||
|
);
|
||||||
|
if (area.bbox) {
|
||||||
|
const [west, south, east, north] = area.bbox;
|
||||||
|
if (
|
||||||
|
[west, east].some((value) => Math.abs(value) > 180) ||
|
||||||
|
[south, north].some((value) => Math.abs(value) > 90) ||
|
||||||
|
south >= north
|
||||||
|
)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
'area.bbox must be [west, south, east, north] with south below north',
|
||||||
|
);
|
||||||
|
return { label: 'the requested box', west, south, east, north };
|
||||||
|
}
|
||||||
|
if (area.lat != null) {
|
||||||
|
if (area.lon == null || area.radius_km == null)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
'area with lat also needs lon and radius_km',
|
||||||
|
);
|
||||||
|
return circleArea(area.lat, area.lon, area.radius_km);
|
||||||
|
}
|
||||||
|
const places = services?.places;
|
||||||
|
if (!places)
|
||||||
|
throw new ToolError(
|
||||||
|
'unsupported',
|
||||||
|
'Place names cannot be resolved here; give a bbox or lat/lon instead',
|
||||||
|
);
|
||||||
|
const place = await places.resolve(area.place, { signal });
|
||||||
|
if (!place)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
`No place matched "${area.place}"; try a more specific name or a bbox`,
|
||||||
|
);
|
||||||
|
return { label: place.name || area.place, ...place.bounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleArea(lat, lon, radiusKm) {
|
||||||
|
const latSpan = (radiusKm / EARTH_RADIUS_KM) * (180 / Math.PI);
|
||||||
|
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||||
|
const lonSpan = cosLat < 1e-6 ? 180 : Math.min(180, latSpan / cosLat);
|
||||||
|
const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180;
|
||||||
|
return {
|
||||||
|
label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`,
|
||||||
|
west: lonSpan >= 180 ? -180 : wrap(lon - lonSpan),
|
||||||
|
east: lonSpan >= 180 ? 180 : wrap(lon + lonSpan),
|
||||||
|
south: Math.max(-90, lat - latSpan),
|
||||||
|
north: Math.min(90, lat + latSpan),
|
||||||
|
center: { lat, lon, radiusKm },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Great-circle distance in kilometers. */
|
||||||
|
export function distanceKm(a, b) {
|
||||||
|
const toRad = Math.PI / 180;
|
||||||
|
const dLat = (b.lat - a.lat) * toRad;
|
||||||
|
const dLon = (b.lon - a.lon) * toRad;
|
||||||
|
const h =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a point lies inside a resolved area, including radius areas. */
|
||||||
|
export function areaContains(area, point) {
|
||||||
|
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
|
||||||
|
return false;
|
||||||
|
if (point.lat < area.south || point.lat > area.north) return false;
|
||||||
|
const inLongitude =
|
||||||
|
area.west <= area.east
|
||||||
|
? point.lon >= area.west && point.lon <= area.east
|
||||||
|
: point.lon >= area.west || point.lon <= area.east;
|
||||||
|
if (!inLongitude) return false;
|
||||||
|
return area.center
|
||||||
|
? distanceKm(area.center, point) <= area.center.radiusKm
|
||||||
|
: true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { areaContains, distanceKm, resolveArea } from './area.js';
|
||||||
|
import { placeFromGeocodeResult } from './places.js';
|
||||||
|
|
||||||
|
test('an area needs exactly one form', async () => {
|
||||||
|
for (const area of [
|
||||||
|
{},
|
||||||
|
{ place: 'Oslo', bbox: [0, 0, 1, 1] },
|
||||||
|
{ lat: 1, lon: 2 },
|
||||||
|
]) {
|
||||||
|
await assert.rejects(
|
||||||
|
resolveArea(area),
|
||||||
|
(error) => error.code === 'invalid_arguments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
resolveArea({ bbox: [0, 10, 1, 5] }),
|
||||||
|
/south below north/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boxes, including antimeridian boxes, contain the expected points', async () => {
|
||||||
|
const box = await resolveArea({ bbox: [-10, 40, 10, 50] });
|
||||||
|
assert.equal(areaContains(box, { lat: 45, lon: 0 }), true);
|
||||||
|
assert.equal(areaContains(box, { lat: 45, lon: 20 }), false);
|
||||||
|
const pacific = await resolveArea({ bbox: [170, -20, -170, 0] });
|
||||||
|
assert.equal(areaContains(pacific, { lat: -10, lon: 179 }), true);
|
||||||
|
assert.equal(areaContains(pacific, { lat: -10, lon: -175 }), true);
|
||||||
|
assert.equal(areaContains(pacific, { lat: -10, lon: 0 }), false);
|
||||||
|
assert.equal(areaContains(box, { lat: Number.NaN, lon: 0 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('radius areas use great-circle distance', async () => {
|
||||||
|
const circle = await resolveArea({ lat: 37.77, lon: -122.42, radius_km: 50 });
|
||||||
|
assert.equal(circle.center.radiusKm, 50);
|
||||||
|
assert.equal(areaContains(circle, { lat: 37.8, lon: -122.27 }), true);
|
||||||
|
assert.equal(areaContains(circle, { lat: 38.58, lon: -121.49 }), false);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.1,
|
||||||
|
);
|
||||||
|
// A circle across the antimeridian wraps its box.
|
||||||
|
const dateline = await resolveArea({ lat: 0, lon: 179.9, radius_km: 100 });
|
||||||
|
assert.ok(dateline.west > dateline.east);
|
||||||
|
assert.equal(areaContains(dateline, { lat: 0, lon: -179.9 }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('place names resolve through the places service', async () => {
|
||||||
|
const places = {
|
||||||
|
resolve: async (name) =>
|
||||||
|
name === 'Taiwan'
|
||||||
|
? {
|
||||||
|
name: 'Taiwan',
|
||||||
|
bounds: { west: 119, south: 21, east: 123, north: 26 },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const taiwan = await resolveArea(
|
||||||
|
{ place: 'Taiwan' },
|
||||||
|
{ services: { places } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(taiwan, {
|
||||||
|
label: 'Taiwan',
|
||||||
|
west: 119,
|
||||||
|
south: 21,
|
||||||
|
east: 123,
|
||||||
|
north: 26,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
resolveArea({ place: 'Nowhere' }, { services: { places } }),
|
||||||
|
/No place matched "Nowhere"/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
resolveArea({ place: 'Taiwan' }),
|
||||||
|
(error) => error.code === 'unsupported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geocode results become bounded places', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
placeFromGeocodeResult({
|
||||||
|
formatted_address: 'Oslo, Norway',
|
||||||
|
geometry: {
|
||||||
|
location: { lat: 59.9, lng: 10.7 },
|
||||||
|
viewport: {
|
||||||
|
southwest: { lat: 59.8, lng: 10.5 },
|
||||||
|
northeast: { lat: 60, lng: 10.9 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'Oslo, Norway',
|
||||||
|
bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
placeFromGeocodeResult({ geometry: { location: { lat: 0, lng: 179.9 } } })
|
||||||
|
.bounds,
|
||||||
|
{ west: 179.65, south: -0.25, east: 180, north: 0.25 },
|
||||||
|
);
|
||||||
|
assert.equal(placeFromGeocodeResult({ geometry: {} }), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Tool definitions and their composition into a callable catalog.
|
||||||
|
*
|
||||||
|
* A tool declares what it does, its arguments, and which services it reads.
|
||||||
|
* It knows nothing about the surface that exposes it (MCP, voice) or the
|
||||||
|
* transport its services use. Applications compose the tools they want with
|
||||||
|
* the services they supply, plus optional interceptors that wrap every call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assertSupportedSchema, validateValue } from './schema.js';
|
||||||
|
|
||||||
|
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
||||||
|
const KINDS = new Set(['query', 'action']);
|
||||||
|
|
||||||
|
/** Error codes a tool may report. Each is safe to show to a model or person. */
|
||||||
|
export const TOOL_ERROR_CODES = Object.freeze([
|
||||||
|
'invalid_arguments',
|
||||||
|
'unavailable',
|
||||||
|
'unsupported',
|
||||||
|
'malformed',
|
||||||
|
'retry_later',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** An expected failure with a stable code and a message written for the caller. */
|
||||||
|
export class ToolError extends Error {
|
||||||
|
constructor(code, message, { retryAfterSeconds = null } = {}) {
|
||||||
|
if (!TOOL_ERROR_CODES.includes(code))
|
||||||
|
throw new TypeError(`Unknown tool error code: ${code}`);
|
||||||
|
super(message);
|
||||||
|
this.name = 'ToolError';
|
||||||
|
this.code = code;
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and freeze a tool definition.
|
||||||
|
*
|
||||||
|
* `run(args, { services, signal })` resolves to `{ summary, data }`: a short
|
||||||
|
* sentence for people and a structured object for programs.
|
||||||
|
*/
|
||||||
|
export function defineTool({
|
||||||
|
name,
|
||||||
|
kind = 'query',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputSchema,
|
||||||
|
annotations = {},
|
||||||
|
requires = [],
|
||||||
|
run,
|
||||||
|
}) {
|
||||||
|
if (!TOOL_NAME.test(String(name)))
|
||||||
|
throw new TypeError(`Invalid tool name: ${name}`);
|
||||||
|
if (!KINDS.has(kind)) throw new TypeError(`Invalid tool kind: ${kind}`);
|
||||||
|
if (typeof title !== 'string' || !title)
|
||||||
|
throw new TypeError(`${name} needs a title`);
|
||||||
|
if (typeof description !== 'string' || !description)
|
||||||
|
throw new TypeError(`${name} needs a description`);
|
||||||
|
assertSupportedSchema(inputSchema, `${name}.inputSchema`);
|
||||||
|
if (inputSchema.type !== 'object')
|
||||||
|
throw new TypeError(`${name}.inputSchema must describe an object`);
|
||||||
|
if (
|
||||||
|
!Array.isArray(requires) ||
|
||||||
|
requires.some((key) => typeof key !== 'string')
|
||||||
|
)
|
||||||
|
throw new TypeError(`${name}.requires must list service names`);
|
||||||
|
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
|
||||||
|
return Object.freeze({
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputSchema: structuredClone(inputSchema),
|
||||||
|
annotations: Object.freeze({
|
||||||
|
readOnlyHint: kind === 'query',
|
||||||
|
...annotations,
|
||||||
|
}),
|
||||||
|
requires: Object.freeze([...requires]),
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose tools with the services an application supplies.
|
||||||
|
*
|
||||||
|
* - Duplicate names fail unless the later tool is listed in `replace`.
|
||||||
|
* - Tools whose required services are missing are left out.
|
||||||
|
* - Interceptors wrap every call in order, outermost first, as
|
||||||
|
* `(call, next) => next(call)` where `call` is `{ tool, args, signal }`.
|
||||||
|
*/
|
||||||
|
export function composeCatalog({
|
||||||
|
tools,
|
||||||
|
replace = [],
|
||||||
|
services = {},
|
||||||
|
interceptors = [],
|
||||||
|
}) {
|
||||||
|
const byName = new Map();
|
||||||
|
const replaceable = new Set(replace);
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (byName.has(tool.name) && !replaceable.has(tool.name))
|
||||||
|
throw new TypeError(`Duplicate tool name: ${tool.name}`);
|
||||||
|
byName.set(tool.name, tool);
|
||||||
|
}
|
||||||
|
for (const name of replaceable) {
|
||||||
|
if (!byName.has(name))
|
||||||
|
throw new TypeError(`Cannot replace unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
const available = [...byName.values()].filter((tool) =>
|
||||||
|
tool.requires.every((key) => services[key] != null),
|
||||||
|
);
|
||||||
|
const index = new Map(available.map((tool) => [tool.name, tool]));
|
||||||
|
|
||||||
|
const execute = async ({ tool, args, signal }) => {
|
||||||
|
const problems = validateValue(tool.inputSchema, args);
|
||||||
|
if (problems.length)
|
||||||
|
throw new ToolError('invalid_arguments', problems.join('; '));
|
||||||
|
const result = await tool.run(args, { services, signal });
|
||||||
|
if (!result || typeof result.summary !== 'string' || !result.data)
|
||||||
|
throw new TypeError(`${tool.name} returned no summary or data`);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const chain = interceptors.reduceRight(
|
||||||
|
(next, interceptor) => (call) => interceptor(call, next),
|
||||||
|
execute,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
/** Available tool definitions, in composition order. */
|
||||||
|
list: () => [...available],
|
||||||
|
/** The available tool with this name, or undefined. */
|
||||||
|
get: (name) => index.get(name),
|
||||||
|
/** Validate arguments and run a tool through the interceptors. */
|
||||||
|
async call(name, args = {}, { signal } = {}) {
|
||||||
|
const tool = index.get(name);
|
||||||
|
if (!tool)
|
||||||
|
throw new ToolError(
|
||||||
|
'unsupported',
|
||||||
|
`No tool named ${name} is available`,
|
||||||
|
);
|
||||||
|
return chain({ tool, args, signal });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, defineTool, ToolError } from './catalog.js';
|
||||||
|
import { validateValue } from './schema.js';
|
||||||
|
|
||||||
|
const echo = (name, extra = {}) =>
|
||||||
|
defineTool({
|
||||||
|
name,
|
||||||
|
title: `Echo ${name}`,
|
||||||
|
description: 'Returns its arguments.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { word: { type: 'string', maxLength: 5 } },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
run: async (args) => ({ summary: name, data: { args } }),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('definitions are validated, frozen and read-only by default', () => {
|
||||||
|
const tool = echo('echo');
|
||||||
|
assert.equal(Object.isFrozen(tool), true);
|
||||||
|
assert.equal(tool.kind, 'query');
|
||||||
|
assert.deepEqual(tool.annotations, { readOnlyHint: true });
|
||||||
|
assert.equal(echo('act', { kind: 'action' }).annotations.readOnlyHint, false);
|
||||||
|
assert.throws(() => echo('Bad-Name'), /Invalid tool name/);
|
||||||
|
assert.throws(() => echo('x', { kind: 'other' }), /Invalid tool kind/);
|
||||||
|
assert.throws(() => echo('x', { title: '' }), /needs a title/);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
echo('x', {
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { a: { type: 'string', pattern: '.' } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/unsupported keyword pattern/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => echo('x', { inputSchema: { type: 'string' } }),
|
||||||
|
/must describe an object/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('composition rejects duplicates unless replaced, and hides tools without services', () => {
|
||||||
|
const first = echo('echo');
|
||||||
|
const second = echo('echo', { description: 'Replacement.' });
|
||||||
|
assert.throws(
|
||||||
|
() => composeCatalog({ tools: [first, second] }),
|
||||||
|
/Duplicate tool name: echo/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => composeCatalog({ tools: [first], replace: ['other'] }),
|
||||||
|
/Cannot replace unknown tool/,
|
||||||
|
);
|
||||||
|
const replaced = composeCatalog({
|
||||||
|
tools: [first, second],
|
||||||
|
replace: ['echo'],
|
||||||
|
});
|
||||||
|
assert.equal(replaced.get('echo').description, 'Replacement.');
|
||||||
|
|
||||||
|
const needsFeed = echo('feed', { requires: ['feed'] });
|
||||||
|
assert.deepEqual(
|
||||||
|
composeCatalog({ tools: [first, needsFeed] })
|
||||||
|
.list()
|
||||||
|
.map((tool) => tool.name),
|
||||||
|
['echo'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
composeCatalog({ tools: [first, needsFeed], services: { feed: {} } })
|
||||||
|
.list()
|
||||||
|
.map((tool) => tool.name),
|
||||||
|
['echo', 'feed'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls validate arguments and pass through interceptors outermost first', async () => {
|
||||||
|
const order = [];
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: [echo('echo')],
|
||||||
|
interceptors: [
|
||||||
|
async (call, next) => {
|
||||||
|
order.push(`outer:${call.tool.name}`);
|
||||||
|
const result = await next(call);
|
||||||
|
order.push('outer:done');
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
(call, next) => {
|
||||||
|
order.push('inner');
|
||||||
|
return next({ ...call, args: { word: 'hi' } });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await catalog.call('echo', { word: 'ignored' });
|
||||||
|
assert.deepEqual(result, { summary: 'echo', data: { args: { word: 'hi' } } });
|
||||||
|
assert.deepEqual(order, ['outer:echo', 'inner', 'outer:done']);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
composeCatalog({ tools: [echo('echo')] }).call('echo', {
|
||||||
|
word: 'toolong',
|
||||||
|
extra: 1,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ToolError &&
|
||||||
|
error.code === 'invalid_arguments' &&
|
||||||
|
/arguments\.word must be at most 5 characters/.test(error.message) &&
|
||||||
|
/unknown property extra/.test(error.message),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
composeCatalog({ tools: [] }).call('missing'),
|
||||||
|
(error) => error.code === 'unsupported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tool that returns no summary is a programming error, not a tool error', async () => {
|
||||||
|
const broken = echo('broken', { run: async () => ({ data: {} }) });
|
||||||
|
await assert.rejects(
|
||||||
|
composeCatalog({ tools: [broken] }).call('broken'),
|
||||||
|
(error) => error instanceof TypeError && !(error instanceof ToolError),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool errors only use known codes', () => {
|
||||||
|
assert.throws(() => new ToolError('nope', 'x'), /Unknown tool error code/);
|
||||||
|
assert.equal(
|
||||||
|
new ToolError('retry_later', 'x', { retryAfterSeconds: 5 })
|
||||||
|
.retryAfterSeconds,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the validator checks every supported keyword', () => {
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['n'],
|
||||||
|
properties: {
|
||||||
|
n: { type: 'integer', minimum: 1, maximum: 3 },
|
||||||
|
list: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 2,
|
||||||
|
items: { type: 'number' },
|
||||||
|
},
|
||||||
|
mode: { type: 'string', enum: ['a', 'b'] },
|
||||||
|
flag: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
validateValue(schema, { n: 2, list: [1], mode: 'a', flag: true }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
validateValue(schema, { n: 1.5, list: [], mode: 'c', flag: 'yes' }),
|
||||||
|
[
|
||||||
|
'arguments.n must be an integer',
|
||||||
|
'arguments.list must have at least 1 items',
|
||||||
|
'arguments.mode must be one of "a", "b"',
|
||||||
|
'arguments.flag must be true or false',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(validateValue(schema, {}), ['arguments is missing n']);
|
||||||
|
assert.deepEqual(validateValue(schema, []), ['arguments must be an object']);
|
||||||
|
assert.deepEqual(validateValue(schema, { n: Number.NaN }), [
|
||||||
|
'arguments.n must be a number',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Tools that answer questions from God's Eye View data, independent of the
|
||||||
|
* surface that exposes them. See docs/TOOLS.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||||
|
import { getRecentLaunches } from './queries/space.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
defineTool,
|
||||||
|
composeCatalog,
|
||||||
|
ToolError,
|
||||||
|
TOOL_ERROR_CODES,
|
||||||
|
} from './catalog.js';
|
||||||
|
export { AREA_SCHEMA, resolveArea, areaContains, distanceKm } from './area.js';
|
||||||
|
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
||||||
|
export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js';
|
||||||
|
|
||||||
|
/** Every query Core defines, in a stable order. */
|
||||||
|
export const coreTools = Object.freeze([
|
||||||
|
getEarthquakes,
|
||||||
|
getActiveFires,
|
||||||
|
getRecentLaunches,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { createLocalMcpServer } from '../../server/mcp/server.js';
|
||||||
|
import { createApiFetch } from '../../server/mcp/services.js';
|
||||||
|
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
|
||||||
|
|
||||||
|
const usgs = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
id: 'us1',
|
||||||
|
geometry: { type: 'Point', coordinates: [121.5, 24, 10] },
|
||||||
|
properties: { mag: 5.1, place: 'near Hualien', time: 1767229200000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('relative API requests resolve against the configured base', async () => {
|
||||||
|
const seen = [];
|
||||||
|
const apiFetch = createApiFetch({
|
||||||
|
apiBase: 'http://127.0.0.1:5000',
|
||||||
|
fetchImpl: async (url) => seen.push(String(url)),
|
||||||
|
});
|
||||||
|
await apiFetch('/api/launches');
|
||||||
|
await apiFetch('https://earthquake.usgs.gov/feed.geojson');
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
'http://127.0.0.1:5000/api/launches',
|
||||||
|
'https://earthquake.usgs.gov/feed.geojson',
|
||||||
|
]);
|
||||||
|
assert.throws(
|
||||||
|
() => createApiFetch({ apiBase: 'file:///etc' }),
|
||||||
|
/http\(s\) URL/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the stdio server answers newline-delimited requests using only its data sources', async () => {
|
||||||
|
const contacted = [];
|
||||||
|
const server = createLocalMcpServer({
|
||||||
|
apiBase: 'http://127.0.0.1:5000',
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
contacted.push(new URL(url).origin);
|
||||||
|
return Response.json(usgs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const input = new PassThrough();
|
||||||
|
const output = new PassThrough();
|
||||||
|
let written = '';
|
||||||
|
output.on('data', (chunk) => (written += chunk));
|
||||||
|
const served = serveStdio(server, { input, output });
|
||||||
|
input.write(
|
||||||
|
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n',
|
||||||
|
);
|
||||||
|
input.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n\n');
|
||||||
|
input.write('not json\n');
|
||||||
|
input.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
|
||||||
|
input.end(
|
||||||
|
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_earthquakes","arguments":{}}}\n',
|
||||||
|
);
|
||||||
|
await served;
|
||||||
|
const responses = written
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
const byId = new Map(responses.map((response) => [response.id, response]));
|
||||||
|
assert.equal(responses.length, 4);
|
||||||
|
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
|
||||||
|
assert.equal(byId.get(null).error.code, -32700);
|
||||||
|
assert.deepEqual(
|
||||||
|
byId.get(2).result.tools.map((tool) => tool.name),
|
||||||
|
['get_earthquakes', 'get_active_fires', 'get_recent_launches'],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
byId.get(3).result.content[0].text,
|
||||||
|
'1 earthquake of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
|
||||||
|
);
|
||||||
|
// The earthquake feed is fetched directly; nothing else was contacted.
|
||||||
|
assert.deepEqual(contacted, ['https://earthquake.usgs.gov']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command-line arguments are strict', () => {
|
||||||
|
assert.deepEqual(parseArgs([]), {});
|
||||||
|
assert.deepEqual(parseArgs(['--api-base', 'http://localhost:5173']), {
|
||||||
|
apiBase: 'http://localhost:5173',
|
||||||
|
});
|
||||||
|
assert.throws(() => parseArgs(['--port', '1']), /Unknown argument/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Stateless Streamable HTTP transport for an MCP server: one JSON-RPC message
|
||||||
|
* per POST, answered with a JSON body. Uses standard Request and Response so
|
||||||
|
* any web-standard runtime can mount it. The host owns routing and any access
|
||||||
|
* control in front of it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MCP_PROTOCOL_VERSIONS, parseErrorResponse } from './protocol.js';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
/** Return `(request) => Promise<Response>` for an MCP server. */
|
||||||
|
export function createMcpHttpHandler(server) {
|
||||||
|
return async function handleMcpRequest(request) {
|
||||||
|
if (request.method !== 'POST')
|
||||||
|
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
|
||||||
|
const version = request.headers.get('mcp-protocol-version');
|
||||||
|
if (version && !MCP_PROTOCOL_VERSIONS.includes(version))
|
||||||
|
return json(400, {
|
||||||
|
error: `Unsupported MCP protocol version: ${version}`,
|
||||||
|
});
|
||||||
|
const type = request.headers.get('content-type') || '';
|
||||||
|
if (!/^application\/json\b/i.test(type))
|
||||||
|
return json(415, { error: 'Content-Type must be application/json' });
|
||||||
|
const declared = Number(request.headers.get('content-length'));
|
||||||
|
if (declared > MAX_BODY_BYTES)
|
||||||
|
return json(413, { error: 'Request too large' });
|
||||||
|
const text = await request.text();
|
||||||
|
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES)
|
||||||
|
return json(413, { error: 'Request too large' });
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return json(400, parseErrorResponse());
|
||||||
|
}
|
||||||
|
const response = await server.handle(message, { signal: request.signal });
|
||||||
|
return response ? json(200, response) : new Response(null, { status: 202 });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(status, body) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Model Context Protocol adapter for a tool catalog. See docs/TOOLS.md. */
|
||||||
|
|
||||||
|
export {
|
||||||
|
createMcpServer,
|
||||||
|
toMcpTools,
|
||||||
|
parseErrorResponse,
|
||||||
|
MCP_PROTOCOL_VERSIONS,
|
||||||
|
} from './protocol.js';
|
||||||
|
export { createMcpHttpHandler } from './http.js';
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, defineTool, ToolError } from '../catalog.js';
|
||||||
|
import {
|
||||||
|
createMcpHttpHandler,
|
||||||
|
createMcpServer,
|
||||||
|
MCP_PROTOCOL_VERSIONS,
|
||||||
|
toMcpTools,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
const tool = (name, run) =>
|
||||||
|
defineTool({
|
||||||
|
name,
|
||||||
|
title: 'Count',
|
||||||
|
description: 'Counts things.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { n: { type: 'integer' } },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: [
|
||||||
|
tool('count', async ({ n = 1 }) => ({
|
||||||
|
summary: `${n} things.`,
|
||||||
|
data: { n },
|
||||||
|
})),
|
||||||
|
tool('busy', async () => {
|
||||||
|
throw new ToolError('retry_later', 'Upstream is busy', {
|
||||||
|
retryAfterSeconds: 10,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
tool('crash', async () => {
|
||||||
|
throw new Error('database password is hunter2');
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const server = createMcpServer({
|
||||||
|
catalog,
|
||||||
|
name: 'test',
|
||||||
|
version: '1.0.0',
|
||||||
|
instructions: 'Be brief.',
|
||||||
|
});
|
||||||
|
const request = (method, params, id = 1) =>
|
||||||
|
server.handle({ jsonrpc: '2.0', id, method, params });
|
||||||
|
|
||||||
|
test('initialize negotiates a supported protocol version', async () => {
|
||||||
|
const known = await request('initialize', { protocolVersion: '2025-06-18' });
|
||||||
|
assert.deepEqual(known.result, {
|
||||||
|
protocolVersion: '2025-06-18',
|
||||||
|
capabilities: { tools: { listChanged: false } },
|
||||||
|
serverInfo: { name: 'test', version: '1.0.0' },
|
||||||
|
instructions: 'Be brief.',
|
||||||
|
});
|
||||||
|
const unknown = await request('initialize', {
|
||||||
|
protocolVersion: '1999-01-01',
|
||||||
|
});
|
||||||
|
assert.equal(unknown.result.protocolVersion, MCP_PROTOCOL_VERSIONS[0]);
|
||||||
|
assert.deepEqual((await request('ping')).result, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifications and client responses get no reply', async () => {
|
||||||
|
assert.equal(
|
||||||
|
await server.handle({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'notifications/initialized',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await server.handle({ jsonrpc: '2.0', id: 9, result: {} }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid messages and unknown methods are JSON-RPC errors', async () => {
|
||||||
|
assert.equal(
|
||||||
|
(await server.handle({ id: 1, method: 'ping' })).error.code,
|
||||||
|
-32600,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await server.handle([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).error
|
||||||
|
.code,
|
||||||
|
-32600,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await server.handle({ jsonrpc: '2.0', id: 1 })).error.code,
|
||||||
|
-32600,
|
||||||
|
);
|
||||||
|
assert.equal((await request('tools/remove')).error.code, -32601);
|
||||||
|
assert.equal((await request('toString')).error.code, -32601);
|
||||||
|
assert.equal((await request('tools/list', [])).error.code, -32602);
|
||||||
|
assert.equal(
|
||||||
|
(await request('tools/call', { name: 'missing' })).error.code,
|
||||||
|
-32602,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tools are listed with titles, schemas and annotations, with surface overrides', async () => {
|
||||||
|
const { tools } = (await request('tools/list')).result;
|
||||||
|
assert.deepEqual(tools[0], {
|
||||||
|
name: 'count',
|
||||||
|
title: 'Count',
|
||||||
|
description: 'Counts things.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { n: { type: 'integer' } },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
annotations: { title: 'Count', readOnlyHint: true },
|
||||||
|
});
|
||||||
|
const [custom] = toMcpTools(catalog, {
|
||||||
|
descriptions: { count: { description: 'Counts for this surface.' } },
|
||||||
|
decorate: (definition, source) => ({
|
||||||
|
_meta: { kind: source.kind, seen: definition.name },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(custom.description, 'Counts for this surface.');
|
||||||
|
assert.deepEqual(custom._meta, { kind: 'query', seen: 'count' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tool results carry text and structured content; failures stay generic', async () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
(await request('tools/call', { name: 'count', arguments: { n: 3 } }))
|
||||||
|
.result,
|
||||||
|
{
|
||||||
|
content: [{ type: 'text', text: '3 things.' }],
|
||||||
|
structuredContent: { n: 3 },
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual((await request('tools/call', { name: 'busy' })).result, {
|
||||||
|
content: [{ type: 'text', text: 'Upstream is busy' }],
|
||||||
|
structuredContent: { error: 'retry_later', retry_after_seconds: 10 },
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
const invalid = (
|
||||||
|
await request('tools/call', { name: 'count', arguments: { n: 'x' } })
|
||||||
|
).result;
|
||||||
|
assert.equal(invalid.isError, true);
|
||||||
|
assert.equal(invalid.structuredContent.error, 'invalid_arguments');
|
||||||
|
const crash = (await request('tools/call', { name: 'crash' })).result;
|
||||||
|
assert.equal(crash.isError, true);
|
||||||
|
assert.equal(JSON.stringify(crash).includes('hunter2'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HTTP transport accepts one JSON message per POST', async () => {
|
||||||
|
const handle = createMcpHttpHandler(server);
|
||||||
|
const post = (body, headers = {}) =>
|
||||||
|
handle(
|
||||||
|
new Request('http://localhost/mcp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(ok.headers.get('content-type'), 'application/json');
|
||||||
|
assert.deepEqual(await ok.json(), { jsonrpc: '2.0', id: 1, result: {} });
|
||||||
|
assert.equal(
|
||||||
|
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
|
||||||
|
.status,
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
const parse = await post('{');
|
||||||
|
assert.equal(parse.status, 400);
|
||||||
|
assert.equal((await parse.json()).error.code, -32700);
|
||||||
|
assert.equal(
|
||||||
|
(await post({}, { 'MCP-Protocol-Version': '1999-01-01' })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415);
|
||||||
|
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
|
||||||
|
const get = await handle(new Request('http://localhost/mcp'));
|
||||||
|
assert.equal(get.status, 405);
|
||||||
|
assert.equal(get.headers.get('allow'), 'POST');
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
|
||||||
|
* initialize, ping, tools/list and tools/call. Transports (HTTP, stdio) pass
|
||||||
|
* parsed messages in and send the returned responses out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ToolError } from '../catalog.js';
|
||||||
|
|
||||||
|
/** Protocol revisions this server implements, newest first. */
|
||||||
|
export const MCP_PROTOCOL_VERSIONS = Object.freeze([
|
||||||
|
'2025-11-25',
|
||||||
|
'2025-06-18',
|
||||||
|
'2025-03-26',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PARSE_ERROR = -32700;
|
||||||
|
const INVALID_REQUEST = -32600;
|
||||||
|
const METHOD_NOT_FOUND = -32601;
|
||||||
|
const INVALID_PARAMS = -32602;
|
||||||
|
const INTERNAL_ERROR = -32603;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describe catalog tools as MCP tool definitions.
|
||||||
|
*
|
||||||
|
* `descriptions` may override a tool's `title` and `description` by name.
|
||||||
|
* `decorate(definition, tool)` may return extra fields to merge into each
|
||||||
|
* definition, such as application metadata.
|
||||||
|
*/
|
||||||
|
export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
|
||||||
|
return catalog.list().map((tool) => {
|
||||||
|
const override = descriptions[tool.name] || {};
|
||||||
|
const title = override.title ?? tool.title;
|
||||||
|
const definition = {
|
||||||
|
name: tool.name,
|
||||||
|
title,
|
||||||
|
description: override.description ?? tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
annotations: { title, ...tool.annotations },
|
||||||
|
};
|
||||||
|
return decorate
|
||||||
|
? { ...definition, ...decorate(definition, tool) }
|
||||||
|
: definition;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a protocol handler. `handle(message, { signal })` resolves to the
|
||||||
|
* JSON-RPC response, or null for notifications.
|
||||||
|
*/
|
||||||
|
export function createMcpServer({
|
||||||
|
catalog,
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
instructions,
|
||||||
|
descriptions,
|
||||||
|
decorate,
|
||||||
|
}) {
|
||||||
|
const tools = () => toMcpTools(catalog, { descriptions, decorate });
|
||||||
|
const methods = {
|
||||||
|
initialize(params) {
|
||||||
|
const requested = params?.protocolVersion;
|
||||||
|
return {
|
||||||
|
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
|
||||||
|
? requested
|
||||||
|
: MCP_PROTOCOL_VERSIONS[0],
|
||||||
|
capabilities: { tools: { listChanged: false } },
|
||||||
|
serverInfo: { name, version },
|
||||||
|
...(instructions ? { instructions } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ping: () => ({}),
|
||||||
|
'tools/list': () => ({ tools: tools() }),
|
||||||
|
async 'tools/call'(params, { signal }) {
|
||||||
|
if (typeof params?.name !== 'string')
|
||||||
|
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
|
||||||
|
if (!catalog.get(params.name))
|
||||||
|
throw rpcError(INVALID_PARAMS, `Unknown tool: ${params.name}`);
|
||||||
|
try {
|
||||||
|
const result = await catalog.call(params.name, params.arguments ?? {}, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.summary }],
|
||||||
|
structuredContent: result.data,
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
// Expected failures are reported to the model so it can adjust; other
|
||||||
|
// errors are not described, so internal details never leave the server.
|
||||||
|
const known = error instanceof ToolError;
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: known ? error.message : 'The tool failed; try again later',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
error: known ? error.code : 'unavailable',
|
||||||
|
...(known && error.retryAfterSeconds != null
|
||||||
|
? { retry_after_seconds: error.retryAfterSeconds }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
async handle(message, { signal } = {}) {
|
||||||
|
if (!isObject(message) || message.jsonrpc !== '2.0')
|
||||||
|
return failure(
|
||||||
|
message?.id ?? null,
|
||||||
|
INVALID_REQUEST,
|
||||||
|
'Invalid JSON-RPC message',
|
||||||
|
);
|
||||||
|
const isRequest = message.id !== undefined;
|
||||||
|
if (typeof message.method !== 'string')
|
||||||
|
// Responses from the client are accepted and ignored: this server
|
||||||
|
// sends no requests of its own.
|
||||||
|
return isRequest &&
|
||||||
|
message.result === undefined &&
|
||||||
|
message.error === undefined
|
||||||
|
? failure(message.id, INVALID_REQUEST, 'Missing method')
|
||||||
|
: null;
|
||||||
|
if (!isRequest) return null;
|
||||||
|
const method = Object.hasOwn(methods, message.method)
|
||||||
|
? methods[message.method]
|
||||||
|
: null;
|
||||||
|
if (!method)
|
||||||
|
return failure(
|
||||||
|
message.id,
|
||||||
|
METHOD_NOT_FOUND,
|
||||||
|
`Unknown method: ${message.method}`,
|
||||||
|
);
|
||||||
|
if (message.params !== undefined && !isObject(message.params))
|
||||||
|
return failure(message.id, INVALID_PARAMS, 'params must be an object');
|
||||||
|
try {
|
||||||
|
const result = await method(message.params, { signal });
|
||||||
|
return { jsonrpc: '2.0', id: message.id, result };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.rpcCode)
|
||||||
|
return failure(message.id, error.rpcCode, error.message);
|
||||||
|
return failure(message.id, INTERNAL_ERROR, 'Internal error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A JSON-RPC parse-error response, for transports that cannot parse input. */
|
||||||
|
export function parseErrorResponse() {
|
||||||
|
return failure(null, PARSE_ERROR, 'Parse error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(id, code, message) {
|
||||||
|
return { jsonrpc: '2.0', id, error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcError(code, message) {
|
||||||
|
return Object.assign(new Error(message), { rpcCode: code });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value) {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* A `places` service backed by the app's `/api/geocode` route. Applications
|
||||||
|
* with a different place search can supply any object with the same
|
||||||
|
* `resolve(name, { signal })` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POINT_RADIUS_DEGREES = 0.25;
|
||||||
|
|
||||||
|
/** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */
|
||||||
|
export function createGeocodePlaceService({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
async resolve(name, { signal } = {}) {
|
||||||
|
const query = new URLSearchParams({ q: name });
|
||||||
|
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
|
||||||
|
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
return placeFromGeocodeResult(payload?.results?.[0]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert one geocode result to a place with bounds, or null. */
|
||||||
|
export function placeFromGeocodeResult(result) {
|
||||||
|
const location = result?.geometry?.location;
|
||||||
|
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
||||||
|
return null;
|
||||||
|
const viewport = result.geometry.viewport;
|
||||||
|
const bounds =
|
||||||
|
viewport &&
|
||||||
|
[
|
||||||
|
viewport.southwest?.lat,
|
||||||
|
viewport.southwest?.lng,
|
||||||
|
viewport.northeast?.lat,
|
||||||
|
viewport.northeast?.lng,
|
||||||
|
].every(Number.isFinite)
|
||||||
|
? {
|
||||||
|
west: viewport.southwest.lng,
|
||||||
|
south: viewport.southwest.lat,
|
||||||
|
east: viewport.northeast.lng,
|
||||||
|
north: viewport.northeast.lat,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
|
||||||
|
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
|
||||||
|
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
|
||||||
|
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
|
||||||
|
};
|
||||||
|
return { name: result.formatted_address || null, bounds };
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/** Natural hazard queries: earthquakes and active fires. */
|
||||||
|
|
||||||
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
|
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
|
||||||
|
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||||
|
|
||||||
|
export const getEarthquakes = defineTool({
|
||||||
|
name: 'get_earthquakes',
|
||||||
|
title: 'Recent earthquakes',
|
||||||
|
description:
|
||||||
|
'Earthquakes of magnitude 2.5 or more from the USGS feed for the last 24 ' +
|
||||||
|
'hours, strongest first. Optionally limited to an area and a minimum magnitude.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
area: AREA_SCHEMA,
|
||||||
|
min_magnitude: { type: 'number', minimum: 2.5, maximum: 10 },
|
||||||
|
limit: LIMIT_SCHEMA,
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['earthquakes'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const area = args.area
|
||||||
|
? await resolveArea(args.area, { services, signal })
|
||||||
|
: null;
|
||||||
|
const minimum = args.min_magnitude ?? 2.5;
|
||||||
|
const events = (await services.earthquakes.getSnapshot({ signal }))
|
||||||
|
.filter(
|
||||||
|
(event) => event.mag >= minimum && (!area || areaContains(area, event)),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.mag - a.mag || (b.time ?? 0) - (a.time ?? 0));
|
||||||
|
const result = capRows(events, args.limit);
|
||||||
|
const where = area ? ` in ${area.label}` : ' worldwide';
|
||||||
|
const strongest = events[0]
|
||||||
|
? `; strongest M${events[0].mag.toFixed(1)}${events[0].place ? ` ${events[0].place}` : ''}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(events.length, 'earthquake')} of M${minimum}+ in the last 24 hours${where}${strongest}.`,
|
||||||
|
data: {
|
||||||
|
...result,
|
||||||
|
rows: result.rows.map((event) => ({
|
||||||
|
id: event.usgsId ?? event.stableId,
|
||||||
|
magnitude: event.mag,
|
||||||
|
place: event.place,
|
||||||
|
time: isoTime(event.time),
|
||||||
|
lat: event.lat,
|
||||||
|
lon: event.lon,
|
||||||
|
depth_km: event.depthKm,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getActiveFires = defineTool({
|
||||||
|
name: 'get_active_fires',
|
||||||
|
title: 'Active fires',
|
||||||
|
description:
|
||||||
|
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
|
||||||
|
'area, most intense (fire radiative power) first.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||||
|
required: ['area'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['fires'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const area = await resolveArea(args.area, { services, signal });
|
||||||
|
const snapshot = await services.fires.getSnapshot({ signal });
|
||||||
|
if (snapshot.keyRequired)
|
||||||
|
throw new ToolError(
|
||||||
|
'unavailable',
|
||||||
|
'Fire detections need a NASA FIRMS key configured for this server',
|
||||||
|
);
|
||||||
|
const fires = snapshot.fires
|
||||||
|
.filter((fire) => areaContains(area, fire))
|
||||||
|
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
|
||||||
|
const result = capRows(fires, args.limit);
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours.`,
|
||||||
|
data: {
|
||||||
|
...result,
|
||||||
|
rows: result.rows.map((fire) => ({
|
||||||
|
lat: fire.lat,
|
||||||
|
lon: fire.lon,
|
||||||
|
frp_mw: fire.frp ?? null,
|
||||||
|
confidence: fire.confidence ?? null,
|
||||||
|
acquired:
|
||||||
|
[fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null,
|
||||||
|
satellite: fire.satellite ?? null,
|
||||||
|
day_night: fire.daynight ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, coreTools } from '../index.js';
|
||||||
|
|
||||||
|
const earthquakes = {
|
||||||
|
getSnapshot: async () => [
|
||||||
|
{
|
||||||
|
stableId: 'a',
|
||||||
|
usgsId: 'us1',
|
||||||
|
lon: 121.5,
|
||||||
|
lat: 24,
|
||||||
|
depthKm: 10,
|
||||||
|
mag: 5.1,
|
||||||
|
place: 'near Hualien',
|
||||||
|
time: Date.UTC(2026, 0, 1, 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stableId: 'b',
|
||||||
|
usgsId: 'us2',
|
||||||
|
lon: -120,
|
||||||
|
lat: 36,
|
||||||
|
depthKm: 5,
|
||||||
|
mag: 3.2,
|
||||||
|
place: 'Central California',
|
||||||
|
time: Date.UTC(2026, 0, 1, 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stableId: 'c',
|
||||||
|
usgsId: null,
|
||||||
|
lon: 122,
|
||||||
|
lat: 23.5,
|
||||||
|
depthKm: null,
|
||||||
|
mag: 2.6,
|
||||||
|
place: null,
|
||||||
|
time: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const fires = (snapshot) => ({ getSnapshot: async () => snapshot });
|
||||||
|
const launches = (payload) => ({ getLaunches: async () => payload });
|
||||||
|
const taiwan = {
|
||||||
|
resolve: async () => ({
|
||||||
|
name: 'Taiwan',
|
||||||
|
bounds: { west: 119, south: 21, east: 123, north: 26 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('earthquakes are filtered, ordered strongest first and capped', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { earthquakes, places: taiwan },
|
||||||
|
});
|
||||||
|
const all = await catalog.call('get_earthquakes', {});
|
||||||
|
assert.equal(
|
||||||
|
all.summary,
|
||||||
|
'3 earthquakes of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
all.data.rows.map((row) => row.id),
|
||||||
|
['us1', 'us2', 'c'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(all.data.rows[0], {
|
||||||
|
id: 'us1',
|
||||||
|
magnitude: 5.1,
|
||||||
|
place: 'near Hualien',
|
||||||
|
time: '2026-01-01T01:00:00.000Z',
|
||||||
|
lat: 24,
|
||||||
|
lon: 121.5,
|
||||||
|
depth_km: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const local = await catalog.call('get_earthquakes', {
|
||||||
|
area: { place: 'Taiwan' },
|
||||||
|
min_magnitude: 3,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
local.summary,
|
||||||
|
'1 earthquake of M3+ in the last 24 hours in Taiwan; strongest M5.1 near Hualien.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
total: local.data.total,
|
||||||
|
returned: local.data.returned,
|
||||||
|
truncated: local.data.truncated,
|
||||||
|
},
|
||||||
|
{ total: 1, returned: 1, truncated: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const capped = await catalog.call('get_earthquakes', { limit: 2 });
|
||||||
|
assert.equal(capped.data.truncated, true);
|
||||||
|
assert.equal(capped.data.total, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires need an area and report a missing key as unavailable', async () => {
|
||||||
|
const snapshot = {
|
||||||
|
fires: [
|
||||||
|
{
|
||||||
|
lat: 24,
|
||||||
|
lon: 121,
|
||||||
|
frp: 12,
|
||||||
|
confidence: 'h',
|
||||||
|
acqDate: '2026-01-01',
|
||||||
|
acqTime: '0130',
|
||||||
|
satellite: 'N',
|
||||||
|
daynight: 'N',
|
||||||
|
},
|
||||||
|
{ lat: 25, lon: 122, frp: 40 },
|
||||||
|
{ lat: -33, lon: 151, frp: 99 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { fires: fires(snapshot), places: taiwan },
|
||||||
|
});
|
||||||
|
await assert.rejects(catalog.call('get_active_fires', {}), /missing area/);
|
||||||
|
const result = await catalog.call('get_active_fires', {
|
||||||
|
area: { place: 'Taiwan' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'2 fire detections in Taiwan in the last 24 hours.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => row.frp_mw),
|
||||||
|
[40, 12],
|
||||||
|
);
|
||||||
|
assert.equal(result.data.rows[1].acquired, '2026-01-01 0130');
|
||||||
|
|
||||||
|
const keyless = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { fires: fires({ keyRequired: true }) },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
keyless.call('get_active_fires', { area: { bbox: [0, 0, 1, 1] } }),
|
||||||
|
(error) =>
|
||||||
|
error.code === 'unavailable' && /NASA FIRMS key/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent launches are newest first with missing fields as null', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
launches: launches({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 'old',
|
||||||
|
name: 'Falcon 9 | Starlink',
|
||||||
|
net: '2026-01-01T00:00:00Z',
|
||||||
|
status: { name: 'Launch Successful' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new',
|
||||||
|
name: 'Electron | Mission',
|
||||||
|
net: '2026-01-05T00:00:00Z',
|
||||||
|
launch_service_provider: { name: 'Rocket Lab' },
|
||||||
|
rocket: { configuration: { full_name: 'Electron' } },
|
||||||
|
pad: { name: 'LC-1A', location: { name: 'Mahia' } },
|
||||||
|
mission: { name: 'Mission', orbit: { name: 'Low Earth Orbit' } },
|
||||||
|
},
|
||||||
|
{ id: 'unnamed' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await catalog.call('get_recent_launches', {});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'2 launches in the last 30 days; latest Electron | Mission.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.data.rows[0], {
|
||||||
|
id: 'new',
|
||||||
|
name: 'Electron | Mission',
|
||||||
|
time: '2026-01-05T00:00:00Z',
|
||||||
|
status: null,
|
||||||
|
provider: 'Rocket Lab',
|
||||||
|
rocket: 'Electron',
|
||||||
|
pad: 'LC-1A',
|
||||||
|
location: 'Mahia',
|
||||||
|
mission: 'Mission',
|
||||||
|
orbit: 'Low Earth Orbit',
|
||||||
|
});
|
||||||
|
const broken = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { launches: launches({}) },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
broken.call('get_recent_launches', {}),
|
||||||
|
(error) => error.code === 'malformed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every core tool is a read-only query with a unique name', () => {
|
||||||
|
const names = coreTools.map((tool) => tool.name);
|
||||||
|
assert.equal(new Set(names).size, names.length);
|
||||||
|
for (const tool of coreTools) {
|
||||||
|
assert.equal(tool.kind, 'query', tool.name);
|
||||||
|
assert.equal(tool.annotations.readOnlyHint, true, tool.name);
|
||||||
|
assert.ok(tool.requires.length > 0, tool.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** Space queries: launches. */
|
||||||
|
|
||||||
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
|
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||||
|
|
||||||
|
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||||
|
|
||||||
|
export const getRecentLaunches = defineTool({
|
||||||
|
name: 'get_recent_launches',
|
||||||
|
title: 'Recent launches',
|
||||||
|
description:
|
||||||
|
'Orbital launches from Launch Library 2 over the last 30 days, newest ' +
|
||||||
|
'first, with provider, rocket, pad, mission and outcome.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { limit: LIMIT_SCHEMA },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['launches'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const payload = await services.launches.getLaunches({ signal });
|
||||||
|
const launches = Array.isArray(payload) ? payload : payload.results;
|
||||||
|
if (!Array.isArray(launches))
|
||||||
|
throw new ToolError('malformed', 'The launch feed returned no launches');
|
||||||
|
const rows = launches
|
||||||
|
.map((launch) => ({
|
||||||
|
id: text(launch?.id),
|
||||||
|
name: text(launch?.name),
|
||||||
|
time: text(launch?.net),
|
||||||
|
status: text(launch?.status?.name),
|
||||||
|
provider: text(launch?.launch_service_provider?.name),
|
||||||
|
rocket: text(
|
||||||
|
launch?.rocket?.configuration?.full_name ??
|
||||||
|
launch?.rocket?.configuration?.name,
|
||||||
|
),
|
||||||
|
pad: text(launch?.pad?.name),
|
||||||
|
location: text(launch?.pad?.location?.name),
|
||||||
|
mission: text(launch?.mission?.name),
|
||||||
|
orbit: text(launch?.mission?.orbit?.name),
|
||||||
|
}))
|
||||||
|
.filter((launch) => launch.name)
|
||||||
|
.sort((a, b) => String(b.time).localeCompare(String(a.time)));
|
||||||
|
const result = capRows(rows, args.limit);
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}.`,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** Result sizing shared by tools that return lists. */
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT = 25;
|
||||||
|
export const MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
/** JSON Schema for the `limit` argument. */
|
||||||
|
export const LIMIT_SCHEMA = Object.freeze({
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: MAX_LIMIT,
|
||||||
|
description: `Most rows to return (default ${DEFAULT_LIMIT}, at most ${MAX_LIMIT}).`,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Keep the first `limit` rows and report how many matched in total. */
|
||||||
|
export function capRows(rows, limit = DEFAULT_LIMIT) {
|
||||||
|
const kept = rows.slice(0, Math.min(limit, MAX_LIMIT));
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
returned: kept.length,
|
||||||
|
truncated: kept.length < rows.length,
|
||||||
|
rows: kept,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an epoch millisecond time as ISO 8601, or null. */
|
||||||
|
export function isoTime(ms) {
|
||||||
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1 earthquake", "3 earthquakes". */
|
||||||
|
export function countNoun(count, singular, plural = `${singular}s`) {
|
||||||
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Validates tool arguments against the JSON Schema subset tool definitions use:
|
||||||
|
* object, array, string, number, integer and boolean types; required,
|
||||||
|
* properties, additionalProperties, items, enum, minimum, maximum, minLength,
|
||||||
|
* maxLength, minItems and maxItems. Unsupported keywords are rejected when a
|
||||||
|
* tool is defined, so a schema can never promise checks that are not made.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEYWORDS = new Set([
|
||||||
|
'type',
|
||||||
|
'description',
|
||||||
|
'title',
|
||||||
|
'properties',
|
||||||
|
'required',
|
||||||
|
'additionalProperties',
|
||||||
|
'items',
|
||||||
|
'enum',
|
||||||
|
'minimum',
|
||||||
|
'maximum',
|
||||||
|
'minLength',
|
||||||
|
'maxLength',
|
||||||
|
'minItems',
|
||||||
|
'maxItems',
|
||||||
|
'default',
|
||||||
|
]);
|
||||||
|
const TYPES = new Set([
|
||||||
|
'object',
|
||||||
|
'array',
|
||||||
|
'string',
|
||||||
|
'number',
|
||||||
|
'integer',
|
||||||
|
'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Throw when a schema uses a keyword or type this validator does not check. */
|
||||||
|
export function assertSupportedSchema(schema, path = 'schema') {
|
||||||
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
|
||||||
|
throw new TypeError(`${path} must be an object`);
|
||||||
|
for (const key of Object.keys(schema)) {
|
||||||
|
if (!KEYWORDS.has(key))
|
||||||
|
throw new TypeError(`${path} uses unsupported keyword ${key}`);
|
||||||
|
}
|
||||||
|
if (!TYPES.has(schema.type))
|
||||||
|
throw new TypeError(`${path} has unsupported type ${schema.type}`);
|
||||||
|
for (const [name, child] of Object.entries(schema.properties || {}))
|
||||||
|
assertSupportedSchema(child, `${path}.${name}`);
|
||||||
|
if (schema.items) assertSupportedSchema(schema.items, `${path}[]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of problems with a value, empty when it conforms. Messages
|
||||||
|
* name the argument path so a model can correct its call.
|
||||||
|
*/
|
||||||
|
export function validateValue(schema, value, path = 'arguments') {
|
||||||
|
const problems = [];
|
||||||
|
const fail = (message) => problems.push(`${path} ${message}`);
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'object': {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
fail('must be an object');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (const name of schema.required || []) {
|
||||||
|
if (value[name] === undefined) fail(`is missing ${name}`);
|
||||||
|
}
|
||||||
|
const properties = schema.properties || {};
|
||||||
|
for (const [name, child] of Object.entries(value)) {
|
||||||
|
if (properties[name])
|
||||||
|
problems.push(
|
||||||
|
...validateValue(properties[name], child, `${path}.${name}`),
|
||||||
|
);
|
||||||
|
else if (schema.additionalProperties === false)
|
||||||
|
fail(`has unknown property ${name}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'array':
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
fail('must be an array');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (schema.minItems != null && value.length < schema.minItems)
|
||||||
|
fail(`must have at least ${schema.minItems} items`);
|
||||||
|
if (schema.maxItems != null && value.length > schema.maxItems)
|
||||||
|
fail(`must have at most ${schema.maxItems} items`);
|
||||||
|
if (schema.items)
|
||||||
|
value.forEach((item, index) =>
|
||||||
|
problems.push(
|
||||||
|
...validateValue(schema.items, item, `${path}[${index}]`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'string':
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
fail('must be a string');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (schema.minLength != null && value.length < schema.minLength)
|
||||||
|
fail(`must be at least ${schema.minLength} characters`);
|
||||||
|
if (schema.maxLength != null && value.length > schema.maxLength)
|
||||||
|
fail(`must be at most ${schema.maxLength} characters`);
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
fail('must be a number');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (schema.type === 'integer' && !Number.isInteger(value))
|
||||||
|
fail('must be an integer');
|
||||||
|
if (schema.minimum != null && value < schema.minimum)
|
||||||
|
fail(`must be at least ${schema.minimum}`);
|
||||||
|
if (schema.maximum != null && value > schema.maximum)
|
||||||
|
fail(`must be at most ${schema.maximum}`);
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
if (typeof value !== 'boolean') fail('must be true or false');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (schema.enum && !schema.enum.includes(value))
|
||||||
|
fail(
|
||||||
|
`must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(', ')}`,
|
||||||
|
);
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user