309 lines
12 KiB
TypeScript
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);
|
|
}
|