Repository Public
1/**2 * Skills and MCP servers: authenticated, audited proxies to the runtime (which reads and edits3 * Claude Code's own configuration). The DB tables are a cache of the last listing only.4 * Secrets (MCP env / header values) are write-only: forwarded once, never stored, logged or audited.5 */6import type { FastifyInstance } from 'fastify';7import { z } from 'zod';8import { AddMcpServer, Id, McpServerInfo, SkillInfo } from '@cr/shared';9import { HttpError, authOf, parse, type Ctx } from '../../http.js';10import { runtimeToHttp, validateRel } from '../../paths.js';11import { projectDir } from '../sessions/index.js';12import { RegistrySearch } from './registry.js';1314const ProjectQuery = z.object({ projectId: Id.optional() });15/**16 * `live=1` asks the runtime for the live status of every server (`claude mcp list` health-checks17 * them all: up to 45 s cold, cached 60 s by the runtime); `refresh=1` bypasses that cache.18 * Default: configuration only (status `unknown`), which answers immediately.19 */20const McpListQuery = ProjectQuery.extend({ live: z.enum(['0', '1']).optional(), refresh: z.enum(['0', '1']).optional() });21/** A leading "-" would be read as an option by the claude CLI (same rule as the runtime). */22const MCP_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,39}$/;23const McpName = z.object({ name: z.string().regex(MCP_NAME) });24const McpTestResult = z.object({ status: McpServerInfo.shape.status, lastError: z.string().nullable(), checkedAt: z.string().nullable() });25const McpScopeQuery = z.object({ scope: z.enum(['user', 'project', 'local']).default('user'), projectId: Id.optional() });26const SearchQuery = z.object({ q: z.string().min(1).max(200), limit: z.coerce.number().int().min(1).max(50).default(20) });27/**28 * Body forwarded to the runtime `/skills/op`. `install` copies from a directory INSIDE a project29 * (`projectId` + relative `path`); nothing is fetched from the network and no skill code runs.30 */31const SkillOp = z.discriminatedUnion('op', [32 z.object({ op: z.literal('install'), projectId: Id, path: z.string().min(1).max(400), replace: z.boolean().optional() }),33 z.object({ op: z.literal('remove'), name: z.string().min(1).max(120), kind: z.enum(['skill', 'command', 'agent']), scope: z.enum(['user', 'project']).default('user'), projectId: Id.optional() }),34 z.object({ op: z.enum(['enable', 'disable']), name: z.string().min(1).max(120), kind: z.enum(['skill', 'command', 'agent']), scope: z.enum(['user', 'project']).default('user'), projectId: Id.optional() }),35 z.object({ op: z.literal('install-registry'), name: z.string().max(200) }),36]);3738export default async function skillsMcpPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {39 const { db, rt, audit } = ctx;40 const rootFor = async (userId: string, projectId?: string): Promise<string | undefined> =>41 projectId ? (await projectDir(ctx, userId, projectId)).abs : undefined;4243 app.get('/skills', { config: { doc: { summary: 'Installed skills / commands / agents', query: ProjectQuery, response: z.array(SkillInfo), responseName: 'SkillInfoList' } } }, async (req) => {44 const a = authOf(req);45 const q = parse(ProjectQuery, req.query);46 const list = await rt.json<SkillInfo[]>('GET', '/skills', undefined, { query: { root: await rootFor(a.userId, q.projectId) } }).catch(runtimeToHttp);47 const skills = z.array(SkillInfo).safeParse(list);48 if (!skills.success) throw new HttpError(502, 'runtime_bad_response', 'unexpected skills listing');49 await db.withTx(async (tx) => {50 await tx.q('DELETE FROM installed_skills');51 for (const s of skills.data) {52 await tx.q(53 `INSERT INTO installed_skills (name, kind, source, version, location, enabled) VALUES ($1, $2, $3, $4, $5, $6)54 ON CONFLICT (name, kind, source) DO UPDATE SET version = excluded.version, location = excluded.location, enabled = excluded.enabled, seen_at = now()`,55 [s.name, s.kind, s.source, s.version, s.location, s.enabled],56 );57 }58 }).catch((err: unknown) => ctx.log.warn({ err }, 'skills cache update failed'));59 return skills.data;60 });6162 app.post('/skills/op', { config: { doc: { summary: 'Install from a project directory / remove / enable / disable', body: SkillOp, bodyName: 'SkillOp' } } }, async (req) => {63 const a = authOf(req);64 const body = parse(SkillOp, req.body);65 if (body.op === 'install-registry') {66 throw new HttpError(501, 'not_implemented', 'installing from the skills registry is not implemented in this milestone; nothing was downloaded');67 }68 if (body.op !== 'install' && body.scope === 'project' && !body.projectId) throw new HttpError(400, 'invalid_request', 'projectId is required for project scope');69 const root = await rootFor(a.userId, body.projectId);70 // Runtime shapes (apps/runtime/src/skills.ts): install = {sourceDir (relative to root), root, replace?}71 // (always into the user's skills); enable/disable = {kind, name, scope, root?}; remove adds confirm:true.72 const rtBody =73 body.op === 'install'74 ? { op: body.op, root, sourceDir: validateRel(body.path, { allowEmpty: false }), replace: body.replace }75 : { op: body.op, kind: body.kind, name: body.name, scope: body.scope, root, ...(body.op === 'remove' ? { confirm: true } : {}) };76 const detail = body.op === 'install' ? { op: body.op, path: rtBody.sourceDir } : { op: body.op, name: body.name, kind: body.kind, scope: body.scope };77 try {78 const out = await rt.json<unknown>('POST', '/skills/op', rtBody);79 await audit.req(req, 'skills.op', { detail });80 return out ?? { ok: true };81 } catch (err) {82 await audit.req(req, 'skills.op', { ok: false, detail });83 return runtimeToHttp(err);84 }85 });8687 app.get('/skills/registry/search', { config: { doc: { summary: 'Search the skills registry (read-only adapter)', query: SearchQuery, response: RegistrySearch, responseName: 'RegistrySearch' } } }, async (req) => {88 const q = parse(SearchQuery, req.query);89 if (!ctx.skillsRegistry) return { connected: false, results: [] };90 try {91 return { connected: true, results: await ctx.skillsRegistry.search(q.q, q.limit) };92 } catch {93 return { connected: false, results: [] };94 }95 });9697 app.get('/mcp', { config: { doc: { summary: 'Configured MCP servers (no secret values)', query: McpListQuery, response: z.array(McpServerInfo), responseName: 'McpServerInfoList' } } }, async (req) => {98 const a = authOf(req);99 const q = parse(McpListQuery, req.query);100 const live = q.live === '1';101 const query = { root: await rootFor(a.userId, q.projectId), live: live ? '1' : '0', refresh: live && q.refresh === '1' ? '1' : undefined };102 const list = await rt.json<unknown>('GET', '/mcp', undefined, { query, timeoutMs: live ? 60_000 : 10_000 }).catch(runtimeToHttp);103 const servers = z.array(McpServerInfo).safeParse(list);104 if (!servers.success) throw new HttpError(502, 'runtime_bad_response', 'unexpected MCP listing');105 await db.withTx(async (tx) => {106 await tx.q('DELETE FROM mcp_servers');107 for (const s of servers.data) {108 await tx.q(109 `INSERT INTO mcp_servers (name, scope, transport, target, env_keys, status, last_error, checked_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)110 ON CONFLICT (name, scope) DO NOTHING`,111 [s.name, s.scope, s.transport, s.target, s.envKeys, s.status, s.lastError, s.checkedAt ? new Date(s.checkedAt) : null],112 );113 }114 }).catch((err: unknown) => ctx.log.warn({ err }, 'mcp cache update failed'));115 return servers.data;116 });117118 app.post('/mcp', { config: { doc: { summary: 'Add an MCP server (env/header values are write-only)', body: AddMcpServer, bodyName: 'AddMcpServer' } } }, async (req, reply) => {119 const a = authOf(req);120 const body = parse(AddMcpServer, req.body);121 if (!MCP_NAME.test(body.name)) throw new HttpError(400, 'invalid_request', 'invalid MCP server name');122 if (body.scope === 'project' && !body.projectId) throw new HttpError(400, 'invalid_request', 'projectId is required for project scope');123 const root = await rootFor(a.userId, body.projectId);124 const { projectId: _p, ...rest } = body;125 const detail = { transport: body.transport, scope: body.scope, envKeys: Object.keys(body.env), headerKeys: Object.keys(body.headers) };126 try {127 const out = await rt.json<unknown>('POST', '/mcp/add', { ...rest, root }, { timeoutMs: 60_000 });128 await audit.req(req, 'mcp.add', { target: body.name, detail });129 void reply.code(201);130 return out ?? { ok: true };131 } catch (err) {132 await audit.req(req, 'mcp.add', { target: body.name, ok: false, detail });133 return runtimeToHttp(err);134 }135 });136137 app.delete('/mcp/:name', { config: { doc: { summary: 'Remove an MCP server', query: McpScopeQuery } } }, async (req) => {138 const a = authOf(req);139 const { name } = parse(McpName, req.params);140 const q = parse(McpScopeQuery, req.query);141 try {142 const out = await rt.json<unknown>('POST', '/mcp/remove', { name, scope: q.scope, root: await rootFor(a.userId, q.projectId) }, { timeoutMs: 60_000 });143 await audit.req(req, 'mcp.remove', { target: name, detail: { scope: q.scope } });144 return out ?? { ok: true };145 } catch (err) {146 await audit.req(req, 'mcp.remove', { target: name, ok: false });147 return runtimeToHttp(err);148 }149 });150151 app.post('/mcp/:name/test', { config: { doc: { summary: 'Check an MCP server (claude mcp get)', query: McpScopeQuery, response: McpServerInfo, responseName: 'McpServerInfo' } } }, async (req) => {152 const a = authOf(req);153 const { name } = parse(McpName, req.params);154 const q = parse(McpScopeQuery, req.query);155 const root = await rootFor(a.userId, q.projectId);156 // The runtime answers `{status, lastError, checkedAt}`; the browser DTO is the full entry.157 const out = await rt.json<unknown>('POST', '/mcp/test', { name, root }, { timeoutMs: 60_000 }).catch(runtimeToHttp);158 const result = McpTestResult.safeParse(out);159 if (!result.success) throw new HttpError(502, 'runtime_bad_response', 'unexpected MCP test result');160 const list = z.array(McpServerInfo).safeParse(await rt.json<unknown>('GET', '/mcp', undefined, { query: { root, live: '0' }, timeoutMs: 10_000 }).catch(() => null));161 const entries = list.success ? list.data.filter((s) => s.name === name) : [];162 const entry: McpServerInfo = entries.find((s) => s.scope === q.scope) ?? entries[0] ?? { name, scope: q.scope, transport: 'stdio', target: '', envKeys: [], status: 'unknown', lastError: null, checkedAt: null };163 await audit.req(req, 'mcp.test', { target: name });164 return { ...entry, ...result.data };165 });166}167