feat: add a tool catalog and local MCP server

Define tools that answer questions from the app's data once, independent
of the surface that exposes them. A tool declares its arguments and the
services it reads; applications compose tools with their own services and
optional call interceptors. Queries reuse the layers' portable source
factories, so they run wherever an application routes /api requests.

The first queries are get_earthquakes, get_active_fires and
get_recent_launches, with a shared area argument (place name, bounding
box, or point and radius) and capped lists.

gods-eye-view/tools/mcp adapts a catalog to the Model Context Protocol
with a small JSON-RPC handler and a stateless HTTP transport, adding no
dependencies. npm run mcp serves Core's tools over stdio to local clients
such as Claude Code, reading from a running app. Both exports are
portable and checked by check:boundaries; the app does not import them.
This commit is contained in:
Sameh Khamis committed 2026-10-01 11:51:59 -07:00
1 parent e7707d9a0f
commit c3dfa8e853
26 files changed
+1886 -3

No files matched your search

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