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.
This commit is contained in:
Sameh Khamis committed 2026-10-01 14:36:12 -07:00
1 parent b4067f9859
commit df0e0f32e8
9 files changed
+138 -17

No files matched your search

+2 -1
View File
@@ -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"
]
+2 -1
View File
@@ -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"]
},
+6 -2
View File
@@ -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',
+8 -6
View File
@@ -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')),
);
}
+11 -7
View File
@@ -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;
+2
View File
@@ -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'),
+1
View File
@@ -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,
+56
View File
@@ -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,
);
}
+50
View File
@@ -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/,
);
});