From df0e0f32e81940235c0a2848c7dd884c0ae04f2a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:36:12 -0700 Subject: [PATCH] feat: choose per tool whether MCP, voice or both offer it src/tools/surfaces.js lists the tools a surface leaves out, and the MCP server, the voice session's tool list and the browser catalog voice calls all filter through it. Voice leaves out tools that answer with images, link to the app, or repeat what its app actions already answer. --- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 3 +- server/mcp/server.js | 8 +++-- server/standalone/voiceTools.js | 14 ++++---- src/standalone/toolCatalog.js | 18 ++++++---- src/tooling/localServices.test.mjs | 2 ++ src/tools/index.js | 1 + src/tools/surfaces.js | 56 ++++++++++++++++++++++++++++++ src/tools/surfaces.test.mjs | 50 ++++++++++++++++++++++++++ 9 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 src/tools/surfaces.js create mode 100644 src/tools/surfaces.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 69312e248..9fd4c4a4c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -886,5 +886,6 @@ "src/tools/queries/surveillance.test.mjs", "src/tools/queries/infrastructure.test.mjs", "src/tools/queries/events.test.mjs", - "src/tools/functions.test.mjs" + "src/tools/functions.test.mjs", + "src/tools/surfaces.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9b14f6392..03a648a71 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3122,7 +3122,8 @@ "src/tools/queries/infrastructure.js", "src/tools/queries/events.js", "src/data/bhoteKoshiFloodPath.js", - "src/tools/functions.js" + "src/tools/functions.js", + "src/tools/surfaces.js" ], "external": ["satellite.js"] }, diff --git a/server/mcp/server.js b/server/mcp/server.js index daacd3d3c..3cf75ee0f 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -1,7 +1,11 @@ /** 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 { + composeCatalog, + coreTools, + toolsForSurface, +} from '../../src/tools/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -20,7 +24,7 @@ export function createLocalMcpServer({ ); return createMcpServer({ catalog: composeCatalog({ - tools: coreTools, + tools: toolsForSurface(coreTools, 'mcp'), services: createLocalToolServices({ apiBase, fetchImpl }), }), name: 'gods-eye-view', diff --git a/server/standalone/voiceTools.js b/server/standalone/voiceTools.js index 9e7c26dce..35dc47440 100644 --- a/server/standalone/voiceTools.js +++ b/server/standalone/voiceTools.js @@ -1,16 +1,18 @@ /** * The voice session's tools for the standalone server: the app actions plus - * Core's catalog queries, which the browser runs through the same catalog. - * Links to the app are left out, since voice runs inside it. + * the catalog tools voice offers (see src/tools/surfaces.js), which the + * browser runs through the same catalog. */ -import { coreTools, toFunctionTools } from '../../src/tools/index.js'; +import { + coreTools, + toFunctionTools, + toolsForSurface, +} from '../../src/tools/index.js'; import { realtimeSessionTools } from '../providers/openai/tools.js'; -export const VOICE_EXCLUDED_QUERIES = Object.freeze(['open_in_gods_eye_view']); - export function standaloneVoiceTools() { return realtimeSessionTools( - toFunctionTools(coreTools, { exclude: VOICE_EXCLUDED_QUERIES }), + toFunctionTools(toolsForSurface(coreTools, 'voice')), ); } diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index b8c550427..ef1aa709a 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -6,14 +6,18 @@ export function loadToolCatalog() { pending ??= Promise.all([ import('../tools/index.js'), import('../tools/services.js'), - ]).then(([{ composeCatalog, coreTools }, { createToolServices }]) => - composeCatalog({ - tools: coreTools, - services: createToolServices({ - fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + ]).then( + ([ + { composeCatalog, coreTools, toolsForSurface }, + { createToolServices }, + ]) => + composeCatalog({ + tools: toolsForSurface(coreTools, 'voice'), + services: createToolServices({ + fetchImpl: (...args) => globalThis.fetch(...args), + appUrl: globalThis.location.origin, + }), }), - }), ); pending.catch(() => { pending = null; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index e8ff998a0..d6d6d4325 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -221,6 +221,8 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the assert.ok(names.includes('get_weather')); assert.ok(names.includes('military_awareness')); assert.ok(!names.includes('open_in_gods_eye_view')); + assert.ok(!names.includes('aircraft_in_area')); + assert.ok(!names.includes('get_weather_map')); // The action of the same name answers satellite passes. assert.equal( tools.findLast((tool) => tool.name === 'next_satellite_pass'), diff --git a/src/tools/index.js b/src/tools/index.js index 0ba01004b..66aa90bac 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -68,6 +68,7 @@ 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 { createGeocodePlaceService, createPlaceSearchService, diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js new file mode 100644 index 000000000..45c18f015 --- /dev/null +++ b/src/tools/surfaces.js @@ -0,0 +1,56 @@ +/** + * Which surfaces offer each tool. A tool is on every surface unless this + * table turns it off; edit an entry to turn a tool on or off for MCP, voice, + * or both. Applications can pass overrides when they compose a surface. + */ + +export const SURFACES = Object.freeze(['mcp', 'voice']); + +const voiceOff = Object.freeze({ voice: false }); + +export const TOOL_SURFACES = Object.freeze({ + // Voice runs inside the app, so a link to it adds nothing. + open_in_gods_eye_view: voiceOff, + // Voice has an app action of the same name. + next_satellite_pass: voiceOff, + // Voice receives text only, so tools that answer with an image are off. + get_weather_map: voiceOff, + get_recent_imagery: voiceOff, + get_cctv_snapshot: voiceOff, + // Voice shows the HUD caption in the app itself. + get_hud_caption: voiceOff, + // Voice answers these from the loaded layers with analyst_query. + aircraft_in_area: voiceOff, + vessels_in_area: voiceOff, + find_aircraft: voiceOff, + find_vessel: voiceOff, + get_earthquakes: voiceOff, + get_active_fires: voiceOff, + find_infrastructure: voiceOff, + satellites_overhead: voiceOff, +}); + +/** + * 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. + */ +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}`); + } + } + } + return tools.filter( + (tool) => + (overrides[tool.name]?.[surface] ?? + TOOL_SURFACES[tool.name]?.[surface]) !== false, + ); +} diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs new file mode 100644 index 000000000..4305783f9 --- /dev/null +++ b/src/tools/surfaces.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TOOL_SURFACES, coreTools, toolsForSurface } from './index.js'; + +const names = (tools) => tools.map((tool) => tool.name); + +test('MCP offers every tool and voice leaves out the table entries', () => { + assert.deepEqual(names(toolsForSurface(coreTools, 'mcp')), names(coreTools)); + const voice = names(toolsForSurface(coreTools, 'voice')); + assert.equal( + voice.length, + coreTools.length - Object.keys(TOOL_SURFACES).length, + ); + for (const name of Object.keys(TOOL_SURFACES)) + assert.ok(!voice.includes(name), name); + assert.ok(voice.includes('get_weather')); + assert.ok(voice.includes('military_awareness')); +}); + +test('overrides turn a tool on or off for one surface', () => { + const voice = names( + toolsForSurface(coreTools, 'voice', { + get_weather_map: { voice: true }, + get_weather: { voice: false }, + }), + ); + assert.ok(voice.includes('get_weather_map')); + assert.ok(!voice.includes('get_weather')); + assert.ok( + !names( + toolsForSurface(coreTools, 'mcp', { get_weather: { mcp: false } }), + ).includes('get_weather'), + ); +}); + +test('unknown surfaces and tool names are rejected', () => { + assert.throws( + () => toolsForSurface(coreTools, 'email'), + /Unknown tool surface/, + ); + assert.throws( + () => toolsForSurface(coreTools, 'voice', { get_wether: { voice: false } }), + /Unknown tool: get_wether/, + ); + assert.throws( + () => + toolsForSurface(coreTools, 'voice', { get_weather: { voise: false } }), + /Unknown tool surface for get_weather: voise/, + ); +});