/** * MCP Hub REST surface (UX-Refactor Phase 4, S08/S17). * * The runtime engine (`McpRuntime`, stdio JSON-RPC) existed but was never * wired to any route; this file is that wiring. Persisted config lives at * `/.mcp.json` (../mcp-config.ts) — the boot loader (C4) registers * those entries so installs survive restarts. * * Ratified gates honoured: * - A4 — real-where-substrate-exists: installs go through the EXISTING * marketplace installer (SecurityGate + audit ride along); * the static catalog renders honest not-installed states. * - B5 — install is free (Solo): MCP install + custom-add are personal * features (PRO removed). SecurityGate + audit still ride along via * the marketplace installer. * - C19 — scope = single workspaceId v1 (the runtime's native model). * - C20 — stdio-only; no remote-registry transport. * - C21 — /test runs a LIVE spawn + initialize/tools-list round-trip when the * server is registered (already-running servers get a real tools/list * re-issue, never cached state); otherwise a STATIC manifest * validation. Responses label which ran via `mode`. A server mid-start * answers 409 busy. * - C2 — GET /api/mcps/:id/logs is OMITTED: McpServerInstance pipes stderr * but retains nothing (no ring buffer). Deferred with the caveat that * PRD §12.8 "view logs" is unmet at route level in v1. */ import type { FastifyInstance } from 'fastify'; import { MCP_CATALOG, type McpInstance, type McpServer, } from '@waggle/shared'; import type { McpRuntime, McpServerState } from '@waggle/agent'; import { scanForInjection, type RecordAuditInput } from '@waggle/core'; import { MCP_SERVERS, MarketplaceInstaller, createMarketplaceMcpProvenance, type InstallManifest, type MarketplaceMcpProvenance, type McpServerConfig, } from '@waggle/marketplace'; import { loadMcpConfig, saveMcpServerEntry, removeMcpServerEntry, validateMcpEntry, refreshMcpIfChanged, type PersistedMcpEntry, } from '../mcp-config.js'; import { authHeaders, clampStr, clampStrArray } from './validate.js'; /** Live-test budget. Must answer BEFORE the FE adapter's 10s request timeout * (15s here meant the route's honest {ok:false} arrived after the client had * already aborted — caught by the 2026-06-10 Extend live smoke), and stays * under the instance's own 30s per-request timeout. */ const TEST_TIMEOUT_MS = 8_000; const RESERVED_MARKETPLACE_MCP_NAMES = new Set( [ ...MCP_CATALOG.map((server) => server.id), ...MCP_SERVERS.flatMap((pkg) => pkg.install_manifest?.mcp_config?.name ?? []), ], ); /** Clamp env to ≤64 pairs with bounded key/value lengths. Non-record shapes * (and non-string values) pass through untouched so validateMcpEntry still * rejects them with its precise error message. */ function clampEnv(env: unknown): Record { if (env === null || typeof env !== 'object' || Array.isArray(env)) { return env as Record; } const out: Record = {}; for (const [k, v] of Object.entries(env as Record).slice(0, 64)) { out[clampStr(k, 200)] = (typeof v === 'string' ? clampStr(v, 1000) : v) as string; } return out; } /** Map the runtime's 4-state machine onto the shared McpInstance status. */ function toInstanceStatus(state: McpServerState | undefined): McpInstance['status'] { switch (state) { case 'ready': return 'running'; case 'starting': return 'running'; // transitional — raw `state` is also emitted case 'error': return 'error'; case 'stopped': return 'stopped'; default: return 'installed'; // persisted but not registered in the runtime } } function withTimeout(p: Promise, ms: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); p.then( (v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); }, ); }); } export async function mcpRoutes(fastify: FastifyInstance) { const dataDir = () => fastify.localConfig?.dataDir ?? ''; const getRuntime = (): McpRuntime | undefined => (fastify.agentState as { mcpRuntime?: McpRuntime } | undefined)?.mcpRuntime; /** Install-audit write — non-blocking like every other auditStore caller. */ function recordMcpAudit(input: Omit): void { try { fastify.auditStore?.record({ ...input, capabilityType: 'mcp' }); } catch (err) { fastify.log.warn({ err, capability: input.capabilityName }, 'mcp install-audit write failed'); } } /** Build the shared McpInstance for one persisted/registered server. */ function buildInstance(name: string, entry: PersistedMcpEntry | undefined, runtime: McpRuntime | undefined): McpInstance & { state?: McpServerState; tools: string[] } { const server = runtime?.getServer(name); const state = server?.getState(); const workspaceId = entry?.workspaceId ?? server?.config.workspaceId; return { id: name, name, status: toInstanceStatus(state), scope: workspaceId ? 'workspace' : 'personal', ...(workspaceId ? { connectedTo: [workspaceId] } : {}), ...(state ? { state } : {}), tools: server?.isHealthy() ? server.getTools().map((t) => t.name) : [], }; } /** The instance fields merged into a list item (id/name stay the catalog's). */ function instanceFields(name: string, entry: PersistedMcpEntry | undefined, runtime: McpRuntime | undefined) { const { id: _id, name: _name, ...fields } = buildInstance(name, entry, runtime); return fields; } // ── GET /api/mcps — catalog ⋈ persisted config ⋈ runtime state ──────── // Honest states (A4): catalog entries that aren't installed say so; no fake // instances. Custom (non-catalog) persisted servers are included too. interface McpListItem { id: string; name: string; description: string; category: string; official: boolean; installCmd: string; source: 'catalog' | 'custom'; installed: boolean; tools: string[]; // McpInstance fields, present only when installed: status?: McpInstance['status']; scope?: McpInstance['scope']; connectedTo?: string[]; state?: McpServerState; } /** Adapt fastify's logger to the mcp-config helper's minimal shape. */ const reloadLog = { info: (msg: string) => fastify.log.info(msg), warn: (msg: string) => fastify.log.warn(msg), }; fastify.get('/api/mcps', async () => { const runtime = getRuntime(); // Cheap signature-checked hot-reload (steal #7): a no-op when .mcp.json is // unchanged; picks up out-of-band edits (e.g. marketplace install to a // different dataDir) without a restart. Never blocks the listing. if (runtime) { try { await refreshMcpIfChanged(runtime, dataDir(), reloadLog); } catch (err) { fastify.log.warn({ err }, 'mcp hot-reload (GET piggyback) failed'); } } const persisted = loadMcpConfig(dataDir()).mcpServers; const runtimeNames = new Set(Object.keys(runtime?.getServerStates() ?? {})); const installedNames = new Set([...Object.keys(persisted), ...runtimeNames]); const catalogIds = new Set(); const mcps: McpListItem[] = MCP_CATALOG.map((cat: McpServer) => { catalogIds.add(cat.id); const installed = installedNames.has(cat.id); return { id: cat.id, name: cat.name, description: cat.description, category: cat.category, official: cat.official ?? false, installCmd: cat.installCmd, source: 'catalog' as const, installed, tools: [], ...(installed ? instanceFields(cat.id, persisted[cat.id], runtime) : {}), }; }); // Custom servers (persisted or registered, not in the catalog) for (const name of installedNames) { if (catalogIds.has(name)) continue; mcps.push({ id: name, name, description: '', category: 'Custom', official: false, installCmd: '', source: 'custom', installed: true, ...instanceFields(name, persisted[name], runtime), }); } return { mcps, total: mcps.length, installed: installedNames.size }; }); // ── POST /api/mcps/reload — explicit hot-reload of .mcp.json (steal #7) ── // Reconciles the runtime with the on-disk config: added servers registered // (stopped), removed servers stopped + dropped, changed servers re-registered // (and restarted only if they were running). A no-op when nothing changed. fastify.post('/api/mcps/reload', async (_request, reply) => { const runtime = getRuntime(); if (!runtime) { return reply.code(503).send({ error: 'MCP runtime not available' }); } const result = await refreshMcpIfChanged(runtime, dataDir(), reloadLog); return result; }); // ── POST /api/mcps/install — free (Solo), delegates to the marketplace ── // installer so SecurityGate + install_audit + .mcp.json write ride along // unchanged; then registers + starts the server in the live runtime. fastify.post('/api/mcps/install', async (request, reply) => { const body = request.body as { mcpId?: string; settings?: Record; force?: boolean; forceInsecure?: boolean; }; const mcpId = clampStr(body?.mcpId, 200); if (!mcpId) return reply.code(400).send({ error: 'mcpId is required' }); const db = fastify.marketplace; if (!db) { return reply.code(503).send({ error: 'Marketplace not available', hint: 'MCP installs route through the marketplace installer (A4)', }); } // Resolve catalog id → marketplace package (mcp-registry seeds share ids) const row = db.getRawDb().prepare(` SELECT p.id, p.name, p.version, p.install_manifest AS installManifest, s.name AS sourceName, s.source_type AS sourceType, s.is_custom AS sourceIsCustom FROM packages p INNER JOIN sources s ON s.id = p.source_id WHERE p.name = ? AND p.waggle_install_type = 'mcp' AND s.name = 'mcp_registry' AND s.source_type = 'registry' AND s.is_custom = 0 ORDER BY p.id LIMIT 1 `).get(mcpId) as { id: number; name: string; version: string; installManifest: string | InstallManifest; sourceName: string; sourceType: 'registry'; sourceIsCustom: number; } | undefined; if (!row) { return reply.code(404).send({ error: `No marketplace MCP package named "${mcpId}"` }); } let expectedMcpProvenance: MarketplaceMcpProvenance; try { const manifest = (typeof row.installManifest === 'string' ? JSON.parse(row.installManifest) : row.installManifest) as InstallManifest; const mcpConfig = manifest.mcp_config; if (!mcpConfig) throw new Error('Marketplace MCP snapshot has no configuration.'); expectedMcpProvenance = createMarketplaceMcpProvenance( { name: row.sourceName, source_type: row.sourceType, is_custom: Boolean(row.sourceIsCustom), }, { name: row.name, version: row.version }, mcpConfig, ); } catch (err) { return reply.code(422).send({ installed: false, success: false, error: `Canonical marketplace MCP snapshot is invalid: ${(err as Error).message}`, }); } const res = await fastify.inject({ method: 'POST', url: '/api/marketplace/install', headers: authHeaders(request), payload: { packageId: row.id, settings: body.settings, force: body.force, forceInsecure: body.forceInsecure, expectedInstallType: 'mcp', expectedMcpProvenance, }, }); const result = res.json() as { success?: boolean; blocked?: boolean; scanResult?: { blocked?: boolean; overall_severity?: string }; mcpSourceConfig?: McpServerConfig; mcpProvenance?: MarketplaceMcpProvenance; errorCode?: 'PACKAGE_IDENTITY_CHANGED'; }; if (res.statusCode >= 400 || result.success === false) { // A SecurityGate block (route-level 403 OR installer-level 422 with // scanResult.blocked) surfaces as the "risk approval required" state — // the server is NOT registered or started (PRD line 743). The audit row // is written by the marketplace route in both cases. const securityBlocked = result.blocked === true || result.scanResult?.blocked === true; return reply.code(res.statusCode >= 400 ? res.statusCode : 422).send({ installed: false, requiresApproval: securityBlocked, ...result, }); } // Only the installer's exact validated source snapshot may reach an // execution sink. The receipt is secret-free; settings are normalized by // the same pure helper used for the installer's own .mcp.json. if (!result.mcpSourceConfig || !result.mcpProvenance) { return reply.code(500).send({ installed: false, error: 'Marketplace installer returned no complete validated MCP receipt', }); } if ( row.name !== mcpId || !MarketplaceInstaller.mcpProvenanceMatches(result.mcpProvenance, expectedMcpProvenance) || result.mcpSourceConfig.name !== row.name ) { return reply.code(409).send({ installed: false, error: 'Marketplace MCP package changed during installation; retry from the refreshed catalog', }); } let configured: McpServerConfig; try { configured = MarketplaceInstaller.configureMcpServer(result.mcpSourceConfig, body.settings); } catch (err) { return reply.code(422).send({ installed: false, error: `Marketplace MCP receipt validation failed: ${(err as Error).message}`, }); } const entry: PersistedMcpEntry = { command: configured.command, args: configured.args, ...(configured.env ? { env: configured.env } : {}), provenance: result.mcpProvenance, }; const entryInvalid = validateMcpEntry(configured.name, entry); if (entryInvalid) { return reply.code(422).send({ installed: false, error: `Configured marketplace MCP would not survive a restart (boot-loader validation): ${entryInvalid}`, }); } // Persist at the server's dataDir too — the installer writes to // WAGGLE_DATA_DIR/~/.waggle, which may differ from a custom dataDir. saveMcpServerEntry(dataDir(), configured.name, entry); const runtime = getRuntime(); let status: McpServerState | 'unregistered' = 'unregistered'; let startError: string | undefined; if (runtime) { if (runtime.getServer(configured.name)) await runtime.removeServer(configured.name); runtime.addServer({ name: configured.name, command: entry.command, args: entry.args, env: entry.env }); const instance = runtime.getServer(configured.name)!; try { await withTimeout(instance.start(), TEST_TIMEOUT_MS, 'MCP start'); } catch (err) { startError = (err as Error).message; // withTimeout only rejects OUR promise — the underlying start() (and // its spawned child) keeps running otherwise. stop() kills it so a // timed-out start never leaks a zombie process. await instance.stop().catch(() => { /* best effort */ }); } status = instance.getState(); } // The marketplace route only audits non-clean scans — guarantee an // 'installed' trail entry for every MCP install (PRD §12.8 auditable). // Risk/approval derive from the installer-level scan: a forceInsecure // override of a blocked scan must NOT read like a plain medium-risk // install (the marketplace route writes the dedicated override row). const scanSeverity = result.scanResult?.overall_severity; const overrode = result.scanResult?.blocked === true; recordMcpAudit({ capabilityName: configured.name, source: 'marketplace', riskLevel: scanSeverity === 'CRITICAL' ? 'critical' : scanSeverity === 'HIGH' ? 'high' : 'medium', trustSource: 'third_party_verified', approvalClass: overrode ? 'elevated' : 'standard', action: 'installed', initiator: 'user', detail: `MCP server installed via marketplace (package ${row.id}); runtime status: ${status}` + (scanSeverity ? `; scan severity: ${scanSeverity}` : '') + (overrode ? '; SECURITY OVERRIDE: installed despite blocked scan (forceInsecure)' : ''), }); return { installed: true, mcpId, server: configured.name, status, mcpProvenance: result.mcpProvenance, ...(startError ? { startError } : {}), }; }); // ── POST /api/mcps — add a CUSTOM stdio server (C19 single workspaceId) ── // Free (Solo), like /api/mcps/install — registering + starting an arbitrary // stdio server is a personal feature (PRO removed). fastify.post('/api/mcps', async (request, reply) => { const body = request.body as { name?: string; command?: string; args?: string[]; env?: Record; workspaceId?: string; }; const name = clampStr(body?.name, 100); // Defense-in-depth size clamps: everything below is persisted verbatim to // the boot-read .mcp.json — a multi-MB body must not bloat it permanently. const candidate: PersistedMcpEntry = { command: typeof body?.command === 'string' ? clampStr(body.command, 1000) : '', ...(Array.isArray(body?.args) && body.args.every((a) => typeof a === 'string') ? { args: clampStrArray(body.args, 64, 1000) } : body?.args !== undefined ? { args: body.args } : {}), ...(body?.env !== undefined ? { env: clampEnv(body.env) } : {}), ...(body?.workspaceId !== undefined ? { workspaceId: typeof body.workspaceId === 'string' ? clampStr(body.workspaceId, 200) : body.workspaceId } : {}), }; if (RESERVED_MARKETPLACE_MCP_NAMES.has(name)) { return reply.code(409).send({ error: `MCP server name "${name}" is reserved for verified marketplace installs`, }); } const invalid = validateMcpEntry(name, candidate); if (invalid) return reply.code(400).send({ error: invalid }); // Injection scan on the external config (S08 card). Scope: this defends // the LLM-facing surface (server names/args/env keys later embedded in // prompts and tool descriptions) — it is NOT command vetting; spawn risk // for user-supplied local servers is accepted by design (same trust model // as the marketplace installer running local commands). const scanText = [ candidate.command, ...(candidate.args ?? []), ...Object.entries(candidate.env ?? {}).flatMap(([k, v]) => [k, v]), ].join(' '); const scan = scanForInjection(scanText, 'user_input'); if (!scan.safe) { return reply.code(400).send({ error: 'Config rejected by injection scan', flags: scan.flags }); } const runtime = getRuntime(); const persisted = loadMcpConfig(dataDir()).mcpServers; if (persisted[name] || runtime?.getServer(name)) { return reply.code(409).send({ error: `MCP server "${name}" already exists` }); } saveMcpServerEntry(dataDir(), name, candidate); runtime?.addServer({ name, command: candidate.command, args: candidate.args, env: candidate.env, workspaceId: candidate.workspaceId, }); recordMcpAudit({ capabilityName: name, source: 'custom', riskLevel: 'medium', trustSource: 'local_user', approvalClass: 'standard', action: 'installed', initiator: 'user', detail: `Custom MCP server added (command: ${clampStr(candidate.command, 200)})`, }); return reply.code(201).send({ id: name, registered: !!runtime }); }); // ── POST /api/mcps/:id/test — C21 live handshake / static fallback ──── fastify.post('/api/mcps/:id/test', async (request, reply) => { const { id } = request.params as { id: string }; const runtime = getRuntime(); const instance = runtime?.getServer(id); if (instance) { const stateBefore = instance.getState(); // A concurrent /start is mid-flight: start() would no-op (still // unhealthy → instant false negative) and our cleanup stop() would kill // the in-flight start. Busy is the only honest answer. if (stateBefore === 'starting') { return reply.code(409).send({ ok: false, error: `MCP server "${id}" is currently starting — retry shortly`, }); } // Already running: a REAL tools/list round-trip (C21) — never report // cached state as a live test result (a wedged process must test false). if (stateBefore === 'ready') { try { const tools = await withTimeout(instance.refreshTools(), TEST_TIMEOUT_MS, 'MCP test'); return { ok: true, mode: 'live', tools: tools.map((t) => t.name) }; } catch (err) { return { ok: false, mode: 'live', tools: [], error: (err as Error).message }; } } // LIVE: spawn + initialize + tools/list round-trip, leave-as-found. try { await withTimeout(instance.start(), TEST_TIMEOUT_MS, 'MCP test'); const ok = instance.isHealthy(); const tools = instance.getTools().map((t) => t.name); await instance.stop(); return { ok, mode: 'live', tools }; } catch (err) { await instance.stop().catch(() => { /* best effort */ }); return { ok: false, mode: 'live', tools: [], error: (err as Error).message }; } } // STATIC: persisted-but-unregistered entry → validate the manifest shape. const persisted = loadMcpConfig(dataDir()).mcpServers[id]; if (persisted) { const invalid = validateMcpEntry(id, persisted); return { ok: invalid === null, mode: 'static', tools: [], ...(invalid ? { error: invalid } : {}), }; } // STATIC: catalog-only (not installed) → validate the catalog manifest. const cat = MCP_CATALOG.find((c) => c.id === id); if (cat) { const ok = typeof cat.installCmd === 'string' && cat.installCmd.trim().length > 0; return { ok, mode: 'static', tools: [], ...(ok ? {} : { error: 'Catalog entry has no install command' }), note: 'Not installed — static manifest validation only (C21 fallback)', }; } return reply.code(404).send({ error: `MCP server "${id}" not found` }); }); // ── POST /api/mcps/:id/start ─────────────────────────────────────────── fastify.post('/api/mcps/:id/start', async (request, reply) => { const { id } = request.params as { id: string }; const instance = getRuntime()?.getServer(id); if (!instance) return reply.code(404).send({ error: `MCP server "${id}" not registered` }); try { await withTimeout(instance.start(), TEST_TIMEOUT_MS, 'MCP start'); return { status: instance.getState() }; } catch (err) { // withTimeout only rejects OUR promise — without stop() the abandoned // start() keeps running and leaks the spawned child on every timeout. await instance.stop().catch(() => { /* best effort */ }); return reply.code(502).send({ status: instance.getState(), error: (err as Error).message }); } }); // ── POST /api/mcps/:id/stop ──────────────────────────────────────────── fastify.post('/api/mcps/:id/stop', async (request, reply) => { const { id } = request.params as { id: string }; const instance = getRuntime()?.getServer(id); if (!instance) return reply.code(404).send({ error: `MCP server "${id}" not registered` }); await instance.stop(); return { status: instance.getState() }; }); // ── POST /api/mcps/:id/revoke — remove from runtime + persisted config ── fastify.post('/api/mcps/:id/revoke', async (request, reply) => { const { id } = request.params as { id: string }; const runtime = getRuntime(); const hadInstance = !!runtime?.getServer(id); const persisted = loadMcpConfig(dataDir()).mcpServers[id]; const removedConfig = removeMcpServerEntry(dataDir(), id); if (!hadInstance && !removedConfig) { return reply.code(404).send({ error: `MCP server "${id}" is not installed` }); } await runtime?.removeServer(id); // stops the process if running const provenance = persisted?.provenance; const verifiedMarketplaceOrigin = provenance?.kind === 'marketplace'; // Keep the marketplace's installed:true annotation honest (A4): if this // server came from a marketplace package, retire that installation row // too — otherwise /api/marketplace/search keeps claiming it's installed. if (verifiedMarketplaceOrigin) { try { const db = fastify.marketplace; const pkgRow = db?.getRawDb().prepare(` SELECT p.id FROM packages p INNER JOIN sources s ON s.id = p.source_id WHERE p.name = ? AND p.version = ? AND p.waggle_install_type = 'mcp' AND s.name = ? AND s.source_type = 'registry' AND s.is_custom = 0 ORDER BY p.id LIMIT 1 `).get( provenance.packageName, provenance.packageVersion, provenance.sourceName, ) as { id: number } | undefined; if (pkgRow && db!.isInstalled(pkgRow.id)) { db!.markUninstalled(pkgRow.id); } } catch (err) { fastify.log.warn({ err, id }, 'marketplace bookkeeping on MCP revoke failed (non-blocking)'); } } recordMcpAudit({ capabilityName: id, source: verifiedMarketplaceOrigin ? 'marketplace' : 'mcp', version: verifiedMarketplaceOrigin ? provenance.packageVersion : null, riskLevel: 'low', trustSource: verifiedMarketplaceOrigin ? 'third_party_verified' : 'local_user', approvalClass: 'standard', action: 'uninstalled', initiator: 'user', detail: `MCP server revoked — removed from runtime and persisted config (${verifiedMarketplaceOrigin ? 'verified marketplace' : 'custom local'} origin)`, }); return { ok: true, id, stoppedInstance: hadInstance, removedConfig }; }); // ── PATCH /api/mcps/:id/permissions — C19 scope: single workspaceId v1 ── fastify.patch('/api/mcps/:id/permissions', async (request, reply) => { const { id } = request.params as { id: string }; const body = request.body as { scope?: string; workspaceId?: string }; let workspaceId: string | undefined; if (typeof body?.workspaceId === 'string' && body.workspaceId.length > 0) { workspaceId = clampStr(body.workspaceId, 200); } else if (body?.scope === 'personal') { workspaceId = undefined; } else { return reply.code(400).send({ error: 'Provide workspaceId (workspace scope) or scope:"personal" (C19: single-workspace scoping v1)', }); } const persisted = loadMcpConfig(dataDir()).mcpServers[id]; const runtime = getRuntime(); const instance = runtime?.getServer(id); if (!persisted && !instance) { return reply.code(404).send({ error: `MCP server "${id}" is not installed` }); } if (persisted) { const next: PersistedMcpEntry = { ...persisted }; if (workspaceId) next.workspaceId = workspaceId; else delete next.workspaceId; saveMcpServerEntry(dataDir(), id, next); } if (instance) { // The runtime reads config.workspaceId live (getToolsForWorkspace) and // exposes no scope-update API; re-registering would needlessly kill the // process. In-place update is the deliberate exception to immutability. instance.config.workspaceId = workspaceId; } return { ok: true, id, scope: workspaceId ? 'workspace' : 'personal', ...(workspaceId ? { workspaceId } : {}) }; }); // NOTE (C2): GET /api/mcps/:id/logs is intentionally NOT implemented — the // runtime retains no stderr/stateChange history (mcp-runtime.ts pipes stderr // but drops it). Needs a ring buffer in McpServerInstance first; scheduled // as a Phase-4 follow-up. PRD §12.8 "view logs" is unmet in v1. }