diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ed9f8c280..5797cbad5 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -43,6 +43,21 @@ as `structuredContent`, for clients that read only one of them. 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. +- **Composite tools**: `run` receives `tools`, with `has(name)` and + `call(name, args)`, to call other tools through the same catalog, so + replaced tools and interceptors apply. Interceptors see such calls with + `parent`, the calling tool's name. + +### Surfaces + +`src/tools/surfaces.js` lists which tools MCP and voice offer. A tool is on +both unless `TOOL_SURFACES` turns it off; edit an entry to turn a tool on or +off for one surface. `catalogForSurface(catalog, surface, overrides)` is the +view a surface exposes: it lists and calls only the tools it offers, while +composite tools still reach the whole catalog. `toolsForSurface` gives the +same selection as a list of definitions, such as for the voice session's tool +list. Voice leaves out tools that answer with images, link to the app, or +repeat what its app actions answer. Expected failures throw `ToolError` with one of `invalid_arguments`, `unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are diff --git a/server/mcp/server.js b/server/mcp/server.js index 3cf75ee0f..0ddb79795 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { composeCatalog, coreTools, - toolsForSurface, + catalogForSurface, } from '../../src/tools/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -23,10 +23,13 @@ export function createLocalMcpServer({ readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); return createMcpServer({ - catalog: composeCatalog({ - tools: toolsForSurface(coreTools, 'mcp'), - services: createLocalToolServices({ apiBase, fetchImpl }), - }), + catalog: catalogForSurface( + composeCatalog({ + tools: coreTools, + services: createLocalToolServices({ apiBase, fetchImpl }), + }), + 'mcp', + ), name: 'gods-eye-view', version, instructions: INSTRUCTIONS, diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index ef1aa709a..53a1f6a7b 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -8,16 +8,19 @@ export function loadToolCatalog() { import('../tools/services.js'), ]).then( ([ - { composeCatalog, coreTools, toolsForSurface }, + { composeCatalog, coreTools, catalogForSurface }, { createToolServices }, ]) => - composeCatalog({ - tools: toolsForSurface(coreTools, 'voice'), - services: createToolServices({ - fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + catalogForSurface( + composeCatalog({ + tools: coreTools, + services: createToolServices({ + fetchImpl: (...args) => globalThis.fetch(...args), + appUrl: globalThis.location.origin, + }), }), - }), + 'voice', + ), ); pending.catch(() => { pending = null; diff --git a/src/tools/catalog.js b/src/tools/catalog.js index aa993e765..e525bbce0 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -60,9 +60,11 @@ export function fromSourceError(error) { /** * 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. It may add - * `images`, each `{ mimeType, data }` with base64 data. + * `run(args, { services, signal, tools })` resolves to `{ summary, data }`: a + * short sentence for people and a structured object for programs. It may add + * `images`, each `{ mimeType, data }` with base64 data. `tools` reaches other + * tools through the same catalog: `tools.has(name)` and + * `tools.call(name, args)`. */ export function defineTool({ name, @@ -111,7 +113,8 @@ export function defineTool({ * - 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 }`. + * `(call, next) => next(call)` where `call` is `{ tool, args, signal }`, + * plus `parent`, the calling tool's name, when one tool calls another. */ export function composeCatalog({ tools, @@ -135,13 +138,28 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); + const invoke = (name, args, signal, parent) => { + const tool = index.get(name); + if (!tool) + return Promise.reject( + new ToolError('unsupported', `No tool named ${name} is available`), + ); + return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + }; const execute = async ({ tool, args, signal }) => { const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); let result; try { - result = await tool.run(args, { services, signal }); + result = await tool.run(args, { + services, + signal, + tools: { + has: (name) => index.has(name), + call: (name, nested = {}) => invoke(name, nested, signal, tool.name), + }, + }); } catch (error) { throw fromSourceError(error); } @@ -171,13 +189,7 @@ export function composeCatalog({ 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 }); + return invoke(name, args, signal); }, }); } diff --git a/src/tools/index.js b/src/tools/index.js index 66aa90bac..6f0fd8dfd 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -68,7 +68,12 @@ export { } from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; export { toFunctionOutput, toFunctionTools } from './functions.js'; -export { SURFACES, TOOL_SURFACES, toolsForSurface } from './surfaces.js'; +export { + SURFACES, + TOOL_SURFACES, + catalogForSurface, + toolsForSurface, +} from './surfaces.js'; export { createGeocodePlaceService, createPlaceSearchService, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 917f8d15b..4997dd02d 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -8,25 +8,17 @@ import { resolveArea, resolvePoint, } from '../area.js'; -import { aircraftInArea } from './aviation.js'; -import { - findMilitaryInstallations, - getCyclones, - getWeather, -} from './environment.js'; -import { getActiveFires, getEarthquakes } from './hazards.js'; -import { vesselsInArea } from './maritime.js'; const SECTION_LIMIT = 5; const AWARENESS_LIMIT = 10; const AWARENESS_RADIUS_KM = 250; -/** Brief sections: the tool each one reuses and the services it needs. */ +/** Brief sections: the tool each one calls and the arguments it passes. */ const SECTIONS = [ { key: 'weather', label: 'Weather', - tool: getWeather, + tool: 'get_weather', args: (area, center) => ({ location: { lat: center.lat, lon: center.lon }, }), @@ -34,31 +26,31 @@ const SECTIONS = [ { key: 'earthquakes', label: 'Earthquakes', - tool: getEarthquakes, + tool: 'get_earthquakes', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'fires', label: 'Active fires', - tool: getActiveFires, + tool: 'get_active_fires', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'aircraft', label: 'Aircraft', - tool: aircraftInArea, + tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'vessels', label: 'Ships', - tool: vesselsInArea, + tool: 'vessels_in_area', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'cyclones', label: 'Tropical cyclones', - tool: getCyclones, + tool: 'get_cyclones', args: (area) => ({ area: area.argument }), }, ]; @@ -67,7 +59,7 @@ const SECTIONS = [ const AWARENESS_SECTIONS = [ { key: 'military_aircraft', - tool: aircraftInArea, + tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, military: true, @@ -76,17 +68,17 @@ const AWARENESS_SECTIONS = [ }, { key: 'aircraft', - tool: aircraftInArea, + tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), }, { key: 'vessels', - tool: vesselsInArea, + tool: 'vessels_in_area', args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), }, { key: 'installations', - tool: findMilitaryInstallations, + tool: 'find_military_installations', args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), }, ]; @@ -108,17 +100,16 @@ function sectionArea(resolved) { } /** - * Run every section whose services are supplied. A failing section is - * reported as unavailable instead of failing the whole answer. + * Run every section whose tool the catalog offers, through the catalog so + * replaced tools and interceptors apply. A failing section is reported as + * unavailable instead of failing the whole answer. */ -async function runSections(all, area, { services, signal }) { +async function runSections(all, area, { tools, signal }) { const center = areaCenter(area); - const sections = all.filter(({ tool }) => - tool.requires.every((key) => services[key] != null), - ); + const sections = all.filter(({ tool }) => tools.has(tool)); const results = await Promise.allSettled( sections.map(({ tool, args: build }) => - tool.run(build(area, center), { services, signal }), + tools.call(tool, build(area, center)), ), ); signal?.throwIfAborted(); @@ -140,11 +131,11 @@ async function runSections(all, area, { services, signal }) { return { center, answers, lines }; } -async function buildBrief(args, { services, signal }) { +async function buildBrief(args, { services, signal, tools }) { // Sections receive the resolved box so a place name is looked up once. const area = sectionArea(await resolveArea(args.area, { services, signal })); const { center, answers, lines } = await runSections(SECTIONS, area, { - services, + tools, signal, }); return { area, center, brief: answers, lines }; @@ -241,7 +232,7 @@ export const militaryAwareness = defineTool({ additionalProperties: false, }, requires: ['military'], - async run(args, { services, signal }) { + async run(args, { services, signal, tools }) { const point = await resolvePoint(args.location, { services, signal }); const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM; const area = sectionArea( @@ -251,7 +242,7 @@ export const militaryAwareness = defineTool({ ), ); const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, { - services, + tools, signal, }); return { diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 40fdd4793..d1df866c9 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { composeCatalog, coreTools, ToolError } from '../index.js'; +import { + catalogForSurface, + composeCatalog, + coreTools, + defineTool, + ToolError, +} from '../index.js'; const conditions = { observedAt: '2026-10-01T19:15:00.000Z', @@ -536,6 +542,19 @@ test('military awareness gathers contacts around a point and marks failures', as reason: 'unavailable right now', }); assert.equal(result.data.radius_km, 100); + const voice = catalogForSurface(catalog, 'voice'); + assert.equal(voice.get('aircraft_in_area'), undefined); + assert.deepEqual( + Object.keys( + ( + await voice.call('military_awareness', { + location: { lat: 32.7, lon: -117.2 }, + radius_km: 100, + }) + ).data.sections, + ), + ['military_aircraft', 'aircraft', 'installations'], + ); assert.equal( (await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } })) .data.radius_km, @@ -549,3 +568,36 @@ test('military awareness gathers contacts around a point and marks failures', as /radius_km/, ); }); + +test('composites reach their sections through the catalog', async () => { + const replacement = defineTool({ + name: 'get_weather', + title: 'Weather', + description: 'Replaced weather.', + inputSchema: { + type: 'object', + properties: { location: { type: 'object' } }, + }, + run: async () => ({ summary: 'Replaced weather.', data: {} }), + }); + const seen = []; + const catalog = composeCatalog({ + tools: [...coreTools, replacement], + replace: ['get_weather'], + services: { weather, places }, + interceptors: [ + (call, next) => { + seen.push([call.tool.name, call.parent ?? null]); + return next(call); + }, + ], + }); + const result = await catalog.call('situation_brief', { + area: { place: 'Austin' }, + }); + assert.equal(result.data.sections.weather.summary, 'Replaced weather.'); + assert.deepEqual(seen, [ + ['situation_brief', null], + ['get_weather', 'situation_brief'], + ]); +}); diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js index 45c18f015..73a45e499 100644 --- a/src/tools/surfaces.js +++ b/src/tools/surfaces.js @@ -4,6 +4,8 @@ * or both. Applications can pass overrides when they compose a surface. */ +import { ToolError } from './catalog.js'; + export const SURFACES = Object.freeze(['mcp', 'voice']); const voiceOff = Object.freeze({ voice: false }); @@ -33,19 +35,18 @@ export const TOOL_SURFACES = Object.freeze({ /** * The tools a surface offers, in their original order. `overrides` uses the * table's shape and wins over it, such as `{ get_weather_map: { voice: true } }`. - * Unknown tool names or surfaces throw, so a typo cannot silently do nothing. + * An override naming an unknown tool or surface throws, so a typo cannot + * silently do nothing. */ export function toolsForSurface(tools, surface, overrides = {}) { if (!SURFACES.includes(surface)) throw new TypeError(`Unknown tool surface: ${surface}`); const names = new Set(tools.map((tool) => tool.name)); - for (const table of [TOOL_SURFACES, overrides]) { - for (const [name, entry] of Object.entries(table)) { - if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`); - for (const key of Object.keys(entry)) { - if (!SURFACES.includes(key)) - throw new TypeError(`Unknown tool surface for ${name}: ${key}`); - } + for (const [name, entry] of Object.entries(overrides)) { + if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`); + for (const key of Object.keys(entry)) { + if (!SURFACES.includes(key)) + throw new TypeError(`Unknown tool surface for ${name}: ${key}`); } } return tools.filter( @@ -54,3 +55,28 @@ export function toolsForSurface(tools, surface, overrides = {}) { TOOL_SURFACES[tool.name]?.[surface]) !== false, ); } + +/** + * A view of a composed catalog that lists and calls only the tools a surface + * offers. Tools that combine others still reach the whole catalog, so a + * composite such as military_awareness keeps its sections on every surface. + */ +export function catalogForSurface(catalog, surface, overrides = {}) { + const offered = new Set( + toolsForSurface(catalog.list(), surface, overrides).map( + (tool) => tool.name, + ), + ); + return Object.freeze({ + list: () => catalog.list().filter((tool) => offered.has(tool.name)), + get: (name) => (offered.has(name) ? catalog.get(name) : undefined), + async call(name, args, options) { + if (!offered.has(name)) + throw new ToolError( + 'unsupported', + `No tool named ${name} is available`, + ); + return catalog.call(name, args, options); + }, + }); +} diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs index 4305783f9..5fc7424da 100644 --- a/src/tools/surfaces.test.mjs +++ b/src/tools/surfaces.test.mjs @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { TOOL_SURFACES, coreTools, toolsForSurface } from './index.js'; +import { + TOOL_SURFACES, + catalogForSurface, + composeCatalog, + coreTools, + defineTool, + toolsForSurface, +} from './index.js'; const names = (tools) => tools.map((tool) => tool.name); @@ -33,6 +40,46 @@ test('overrides turn a tool on or off for one surface', () => { ); }); +test('every table entry names a core tool and a known surface', () => { + const names = new Set(coreTools.map((tool) => tool.name)); + for (const [name, entry] of Object.entries(TOOL_SURFACES)) { + assert.ok(names.has(name), name); + assert.deepEqual(Object.keys(entry), ['voice']); + } +}); + +test('a surface view hides tools from clients but not from composites', async () => { + const leaf = defineTool({ + name: 'leaf', + title: 'Leaf', + description: 'A hidden building block.', + inputSchema: { type: 'object', properties: {} }, + run: async () => ({ summary: 'leaf', data: {} }), + }); + const outer = defineTool({ + name: 'outer', + title: 'Outer', + description: 'Calls leaf.', + inputSchema: { type: 'object', properties: {} }, + run: async (args, { tools }) => tools.call('leaf', {}), + }); + const view = catalogForSurface( + composeCatalog({ tools: [leaf, outer] }), + 'voice', + { leaf: { voice: false } }, + ); + assert.deepEqual( + view.list().map((tool) => tool.name), + ['outer'], + ); + assert.equal(view.get('leaf'), undefined); + await assert.rejects( + view.call('leaf', {}), + (error) => error.code === 'unsupported', + ); + assert.equal((await view.call('outer', {})).summary, 'leaf'); +}); + test('unknown surfaces and tool names are rejected', () => { assert.throws( () => toolsForSurface(coreTools, 'email'),