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:` 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([ '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; ALL_TOOL_DEFS?: Record; } /** * 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> | null = null; async function loadBuiltinEntries(): Promise>> { 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(); // 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> = []; 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 { 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. " — 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 => { 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); }