704 lines
28 KiB
TypeScript
704 lines
28 KiB
TypeScript
/**
|
|
* 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
|
|
* `<dataDir>/.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<string, string> {
|
|
if (env === null || typeof env !== 'object' || Array.isArray(env)) {
|
|
return env as Record<string, string>;
|
|
}
|
|
const out: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(env as Record<string, unknown>).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<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
return new Promise<T>((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<RecordAuditInput, 'capabilityType'>): 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<string>();
|
|
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<string, string>;
|
|
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<string, string>;
|
|
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.
|
|
}
|