maestro/src/bridge/tools-api.ts
oss-sync 9f8958c4a2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (ce93095)
2026-06-10 03:52:37 +00:00

309 lines
12 KiB
TypeScript

import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { ToolDef } from '../llm/openai-compat.js';
import { getSshSubsystem } from '../engine/tools/ssh.js';
/**
* Tool catalog entry exposed by GET /api/tools.
*
* The catalog is built at request-time from the same module set the agent loop
* uses, plus per-caller MCP context. UI consumers (Piece allowed_tools editor)
* should rely on this rather than the previous hand-maintained static list.
*
* See docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md
* step 4 for the design rationale.
*/
export interface ToolCatalogEntry {
name: string;
source: 'builtin' | 'meta' | 'mcp';
/**
* Coarse grouping for UI. Values are stable strings derived from the source
* module file name (e.g. 'core', 'web', 'office'). MCP tools use
* `mcp:<serverId>` so the UI can group them by server.
*/
category: string;
/** MCP server id (source: 'mcp' only). */
serverId?: string;
/** Whether the tool can be invoked at this moment. */
available: boolean;
/** Human-readable explanation when available=false. */
reason?: string;
/**
* Where the tool is "switched on":
* - 'global' → always injected (meta tools like ReadToolDoc)
* - 'piece' → must appear in a piece's `allowed_tools`
* - 'user' → per-user resource (MCP)
*/
scope: 'global' | 'piece' | 'user';
}
export interface ToolCatalogResponse {
tools: ToolCatalogEntry[];
}
/**
* Meta tools are auto-injected by `agent-loop.buildSystemPrompt()` regardless of
* a piece's `allowed_tools`. Keep this list in sync with `META_TOOLS` in
* `src/engine/tools/index.ts`.
*/
const META_TOOLS = new Set<string>([
'ReadToolDoc',
'CreateChecklist',
'CheckItem',
'GetChecklist',
'MissionUpdate',
'ListUserAssets',
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'WriteUserScript',
'Brainstorm',
'ReadAppDoc',
'ListAppDocs',
'GetMyOrchestratorState',
'ReadSkill',
'ListSkills',
'InstallSkill',
]);
/**
* Modules to load. The key becomes the `category` field for builtin tools.
* `core` is loaded separately because its export name is `ALL_TOOL_DEFS`.
*
* Categories that map to "user-scoped" assets (per-user MCP servers, SSH) get
* scope='user' below — see categoryScope().
*/
const MODULE_SPECS: Array<{ category: string; specifier: string }> = [
{ category: 'web', specifier: '../engine/tools/web.js' },
{ category: 'image', specifier: '../engine/tools/image.js' },
{ category: 'data', specifier: '../engine/tools/data.js' },
{ category: 'office', specifier: '../engine/tools/office.js' },
{ category: 'review', specifier: '../engine/tools/review.js' },
{ category: 'x', specifier: '../engine/tools/x.js' },
{ category: 'orchestration', specifier: '../engine/tools/orchestration.js' },
{ category: 'browser', specifier: '../engine/tools/browser.js' },
{ category: 'maps', specifier: '../engine/tools/maps.js' },
{ category: 'youtube', specifier: '../engine/tools/youtube.js' },
{ category: 'pieces', specifier: '../engine/tools/pieces.js' },
{ category: 'amazon', specifier: '../engine/tools/amazon.js' },
{ category: 'speech', specifier: '../engine/tools/speech.js' },
{ category: 'checklist', specifier: '../engine/tools/checklist.js' },
{ category: 'knowledge', specifier: '../engine/tools/knowledge.js' },
{ category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' },
{ category: 'slide', specifier: '../engine/tools/slide.js' },
{ category: 'docs', specifier: '../engine/tools/docs.js' },
{ category: 'mission', specifier: '../engine/tools/mission.js' },
{ category: 'user-folder', specifier: '../engine/tools/user-folder.js' },
{ category: 'brainstorm', specifier: '../engine/tools/brainstorm.js' },
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh-console.js' },
{ category: 'notes', specifier: '../engine/tools/notes.js' },
{ category: 'dashboard', specifier: '../engine/tools/dashboard.js' },
{ category: 'skills', specifier: '../engine/tools/skills.js' },
];
interface ToolModule {
TOOL_DEFS?: Record<string, ToolDef>;
ALL_TOOL_DEFS?: Record<string, ToolDef>;
}
/**
* Deps the catalog needs to enumerate per-user MCP tools. Optional — if
* absent, MCP tools are omitted entirely (e.g. when MCP_ENCRYPTION_KEY is
* not configured and the aggregator was never set up).
*/
export interface McpCatalogDeps {
registry: {
listEnabledForUser(userId: string): Array<{ id: string; name: string; enabled: boolean }>;
};
tokenManager: {
hasToken(userId: string, serverId: string): boolean;
};
toolCache: {
getAllForServers(serverIds: string[]): Array<{ serverId: string; toolName: string }>;
};
}
export interface MountToolsApiOptions {
/** When true, /api/tools is gated behind requireAuth. */
authActive?: boolean;
/** requireAuth middleware (only consulted when authActive is true). */
requireAuth?: RequestHandler;
/** Subsystems used to enumerate per-user MCP tools. */
mcp?: McpCatalogDeps | null;
}
// ──────────────────────────────────────────────────────────────────────
// Module / category caches
// ──────────────────────────────────────────────────────────────────────
let _cachedBuiltinEntries: Array<Omit<ToolCatalogEntry, 'available' | 'reason'>> | null = null;
async function loadBuiltinEntries(): Promise<Array<Omit<ToolCatalogEntry, 'available' | 'reason'>>> {
if (_cachedBuiltinEntries) return _cachedBuiltinEntries;
// name → category info. First-write-wins so a later module specifying the
// same tool name keeps the original category — matches runtime tools/index.ts.
const seen = new Map<string, { category: string; source: 'builtin' | 'meta' }>();
// Core tools (Read/Write/Edit/Bash/Glob/Grep) — always categorised 'core'.
try {
const coreMod = (await import('../engine/tools/core.js')) as ToolModule;
const defs = coreMod.ALL_TOOL_DEFS ?? {};
for (const name of Object.keys(defs)) {
if (!seen.has(name)) {
seen.set(name, { category: 'core', source: META_TOOLS.has(name) ? 'meta' : 'builtin' });
}
}
} catch {
// core should always load; if not, we have bigger problems
}
for (const { category, specifier } of MODULE_SPECS) {
try {
const mod = (await import(specifier)) as ToolModule;
const defs = mod.TOOL_DEFS ?? {};
for (const name of Object.keys(defs)) {
if (!seen.has(name)) {
seen.set(name, {
category,
source: META_TOOLS.has(name) ? 'meta' : 'builtin',
});
}
}
} catch {
// module not available — skip
}
}
const entries: Array<Omit<ToolCatalogEntry, 'available' | 'reason'>> = [];
for (const [name, info] of seen) {
entries.push({
name,
source: info.source,
category: info.category,
scope: info.source === 'meta' ? 'global' : 'piece',
});
}
entries.sort((a, b) => a.name.localeCompare(b.name));
_cachedBuiltinEntries = entries;
return entries;
}
/** Test-only: reset the module-load cache. */
export function _resetToolCatalogCacheForTests(): void {
_cachedBuiltinEntries = null;
}
// ──────────────────────────────────────────────────────────────────────
// SSH availability
// ──────────────────────────────────────────────────────────────────────
function annotateSshAvailability(entry: Omit<ToolCatalogEntry, 'available' | 'reason'>): ToolCatalogEntry {
if (entry.category !== 'ssh') {
return { ...entry, available: true };
}
const sub = getSshSubsystem();
if (sub) {
return { ...entry, available: true };
}
return {
...entry,
available: false,
reason: 'SSH subsystem not initialised',
};
}
// ──────────────────────────────────────────────────────────────────────
// MCP catalog
// ──────────────────────────────────────────────────────────────────────
function buildMcpEntries(userId: string, mcp: McpCatalogDeps): ToolCatalogEntry[] {
const servers = mcp.registry.listEnabledForUser(userId);
if (servers.length === 0) return [];
const cache = mcp.toolCache.getAllForServers(servers.map((s) => s.id));
const entries: ToolCatalogEntry[] = [];
for (const server of servers) {
const connected = mcp.tokenManager.hasToken(userId, server.id);
const serverTools = cache.filter((t) => t.serverId === server.id);
if (serverTools.length === 0) {
// No cached tools yet — surface the server as a single placeholder so
// the UI can still show it (e.g. "<server> — not yet connected").
entries.push({
name: `mcp__${server.id}__`,
source: 'mcp',
category: `mcp:${server.id}`,
serverId: server.id,
available: false,
reason: connected
? `mcp server ${server.name} has no cached tools`
: `mcp server ${server.name} offline`,
scope: 'user',
});
continue;
}
for (const t of serverTools) {
entries.push({
name: `mcp__${server.id}__${t.toolName}`,
source: 'mcp',
category: `mcp:${server.id}`,
serverId: server.id,
available: connected,
reason: connected ? undefined : `mcp server ${server.name} offline`,
scope: 'user',
});
}
}
entries.sort((a, b) => a.name.localeCompare(b.name));
return entries;
}
// ──────────────────────────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────────────────────────
export function mountToolsApi(app: Application, options: MountToolsApiOptions = {}): void {
const handler = async (req: Request, res: Response): Promise<void> => {
const builtinBase = await loadBuiltinEntries();
const builtin = builtinBase.map(annotateSshAvailability);
// MCP entries require an authenticated caller. When auth is disabled the
// request still carries no user; we treat that case as "no MCP catalog"
// because MCP is inherently per-user.
const user = (req.user as { id?: string } | undefined) ?? null;
let mcpEntries: ToolCatalogEntry[] = [];
if (options.mcp && user?.id) {
try {
mcpEntries = buildMcpEntries(user.id, options.mcp);
} catch {
// Defensive: never let MCP enumeration crash the whole catalog.
mcpEntries = [];
}
}
const all: ToolCatalogEntry[] = [...builtin, ...mcpEntries];
// Legacy shape: flat array of names. Maintained so the existing
// ui/src/api.ts fetchTools() (and any external consumer treating
// response.tools as string[]) keeps working until step 5.
if (req.query.legacy === '1') {
res.json({ tools: all.map((t) => t.name) });
return;
}
const payload: ToolCatalogResponse = { tools: all };
res.json(payload);
};
const guards: RequestHandler[] = [];
if (options.authActive && options.requireAuth) {
guards.push(options.requireAuth);
}
app.get('/api/tools', ...guards, handler);
}