SPB Git

Repository Public

index.ts 10.5 KB 167 lines typescript

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
Files main
Menu

Theme