diff --git a/src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql b/src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql new file mode 100644 index 0000000..f880904 --- /dev/null +++ b/src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Agent" ADD COLUMN "toolset" TEXT NOT NULL DEFAULT 'project'; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 5222643..ac46a7c 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -682,6 +682,12 @@ model Agent { projectId String? defaultPersonalityId String? // applied at chat time when no --personality flag proxyModelName String? // optional informational override + // Which tool surface this agent gets. TEXT, not an enum, so a third surface + // is a non-migration change. + // 'project' — project MCP servers + project prompts (today's behaviour) + // 'mcpctl' — operates mcpctl itself, as the calling user; no project + // context and no MCP tools (see services/mcpctl-tools.ts) + toolset String @default("project") defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... extras Json @default("{}") // future LoRA / tool-allowlist // ── Virtual-agent lifecycle (NULL/default for kind=public, mirrors Llm) ── @@ -1133,21 +1139,21 @@ model TaskEvent { // with repo/repository/repositories all aliased to it. model CodeRepo { - id String @id @default(cuid()) - name String - projectId String + id String @id @default(cuid()) + name String + projectId String // Clone URL. https for token auth, ssh for key auth. - url String - defaultBranch String @default("main") + url String + defaultBranch String @default("main") // Relative path under the session workspace root. Defaults to `name`. - path String @default("") + path String @default("") // Driver key for PR/merge API calls. Inherited from the GitProvider when the // repo was imported from one. - provider String @default("gitea") + provider String @default("gitea") // The GitProvider this repo came from, when it was imported rather than // hand-added. Supplies the credential, so an imported repo needs none of its // own; a hand-added repo leaves this null and carries its own credSecretId. - gitProviderId String? + gitProviderId String? // Denormalized provenance, mirroring McpServer.templateName/templateVersion: // survives the provider being deleted, so "where did this repo come from" // stays answerable after the forge is disconnected. @@ -1156,12 +1162,12 @@ model CodeRepo { sourceFullName String? // Secret holding the provider credential, resolved through SecretService at // session-provision time — never stored in plaintext on the row. - credSecretId String? - credSecretKey String? - ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + credSecretId String? + credSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) cred Secret? @relation(fields: [credSecretId], references: [id]) diff --git a/src/mcpd/src/bootstrap/system-agents.ts b/src/mcpd/src/bootstrap/system-agents.ts new file mode 100644 index 0000000..a3f170e --- /dev/null +++ b/src/mcpd/src/bootstrap/system-agents.ts @@ -0,0 +1,64 @@ +/** + * Bootstrap the built-in `mcpctl` system agent on every startup. + * + * Mirrors bootstrap/system-project.ts and bootstrap/system-skills.ts: idempotent + * upsert so a delete + recreate works, but never overwrites a user's edits to + * the persona or their choice of brain. + * + * The agent operates mcpctl itself, as whoever is talking to it — see + * services/mcpctl-tools.ts for why that is a loopback through the normal API + * rather than a direct service call. + */ +import type { PrismaClient } from '@prisma/client'; + +import { MCPCTL_AGENT_SYSTEM_PROMPT } from '../services/mcpctl-tools.js'; + +export const MCPCTL_AGENT_NAME = 'mcpctl'; + +const DESCRIPTION = 'Operates mcpctl for you, with your permissions. No project context, no MCP tools.'; + +export async function bootstrapSystemAgents(prisma: PrismaClient): Promise { + const existing = await prisma.agent.findUnique({ where: { name: MCPCTL_AGENT_NAME } }); + + if (existing !== null) { + // Keep the toolset correct (that is what makes it a system agent) but leave + // the persona, brain and description alone — an operator may have tuned them. + if (existing.toolset !== 'mcpctl') { + await prisma.agent.update({ + where: { id: existing.id }, + data: { toolset: 'mcpctl', version: { increment: 1 } }, + }); + } + return; + } + + // Agent.llmId is required, so we cannot seed before an Llm exists. On a fresh + // install that is the normal state — try again next startup rather than + // inventing a placeholder row that would fail confusingly at first chat. + const llm = + (await prisma.llm.findFirst({ where: { tier: 'heavy', status: 'active' }, orderBy: { name: 'asc' } })) ?? + (await prisma.llm.findFirst({ where: { status: 'active' }, orderBy: { name: 'asc' } })); + if (llm === null) return; + + // Owner: the first admin, else the first user. Ownership only decides who can + // edit the agent row — it never affects what the agent may DO, because every + // action runs as the caller. + const owner = + (await prisma.user.findFirst({ where: { role: 'ADMIN' }, orderBy: { createdAt: 'asc' } })) ?? + (await prisma.user.findFirst({ orderBy: { createdAt: 'asc' } })); + if (owner === null) return; + + await prisma.agent.create({ + data: { + name: MCPCTL_AGENT_NAME, + description: DESCRIPTION, + systemPrompt: MCPCTL_AGENT_SYSTEM_PROMPT, + llmId: llm.id, + ownerId: owner.id, + toolset: 'mcpctl', + }, + }); + + // eslint-disable-next-line no-console + console.log(`mcpd: seeded system agent '${MCPCTL_AGENT_NAME}' on llm '${llm.name}'`); +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 59ac06e..06f67f6 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -60,6 +60,7 @@ import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission import { registerLogRoutes } from './routes/logs.js'; import { errorLogBuffer } from './services/error-log-buffer.js'; import { bootstrapSystemSkills } from './bootstrap/system-skills.js'; +import { bootstrapSystemAgents } from './bootstrap/system-agents.js'; import { McpServerService, SecretService, @@ -130,6 +131,7 @@ import { TaskService } from './services/task.service.js'; import { CodeRepoService } from './services/code-repo.service.js'; import { GitProviderService } from './services/git-provider.service.js'; import { GitProviderRegistry } from './services/git/registry.js'; +import { McpctlToolProvider } from './services/mcpctl-tools.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -433,6 +435,8 @@ async function main(): Promise { await bootstrapSystemProject(prisma); // PR-4: bootstrap system-owned global skills (e.g. propose-learnings). await bootstrapSystemSkills(prisma); + // Seeds the `mcpctl` system agent once an Llm and a user exist. + await bootstrapSystemAgents(prisma); // Repositories const serverRepo = new McpServerRepository(prisma); @@ -753,6 +757,9 @@ async function main(): Promise { projectRepo, rbacService, secretService, + // Loopback to this same mcpd, so the mcpctl system agent's calls go through + // the normal auth + RBAC path carrying the caller's own token. + new McpctlToolProvider(`http://127.0.0.1:${String(config.port)}`), ); registerAgentChatRoutes(app, chatService); registerProjectChatRoutes(app, chatService, rbacService); diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index e332f7a..7dde512 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -41,9 +41,17 @@ export function registerAgentChatRoutes( ...paramsRest } = parsed; + // Forwarded so the `mcpctl` system agent can act AS this caller. Every + // other agent ignores it — see services/mcpctl-tools.ts. + const authHeader = request.headers['authorization']; + const callerToken = typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; + const args = { agentName: request.params.name, ownerId, + ...(callerToken !== undefined ? { callerToken } : {}), ...(threadId !== undefined ? { threadId } : {}), ...(message !== undefined ? { userMessage: message } : {}), ...(messagesOverride !== undefined diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 440d4d2..ed824f4 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -54,6 +54,8 @@ export interface AgentView { project: { id: string; name: string } | null; defaultPersonality: { id: string; name: string } | null; proxyModelName: string | null; + /** 'project' (MCP tools) or 'mcpctl' (operates mcpctl as the caller). */ + toolset: string; defaultParams: AgentChatParams; extras: Record; // Virtual-agent lifecycle (defaults make public agents look like "active"). @@ -228,6 +230,7 @@ export class AgentService { project: project !== null ? { id: project.id, name: project.name } : null, defaultPersonality, proxyModelName: row.proxyModelName, + toolset: row.toolset, defaultParams: row.defaultParams as AgentChatParams, extras: row.extras as Record, kind: row.kind, diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index c02e7f9..cab46b0 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -38,6 +38,7 @@ import type { RbacService } from './rbac.service.js'; import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js'; import type { AgentChatParams } from '../validation/agent.schema.js'; import { NotFoundError } from './mcp-server.service.js'; +import type { McpctlToolProvider } from './mcpctl-tools.js'; /** Minimal secret-read surface the project-chat `get_secret` tool needs. */ export interface ChatSecretResolver { @@ -136,6 +137,11 @@ export interface PoolCandidate { export interface ChatRequestArgs { agentName: string; + /** + * The end user's bearer token. Only used by the `mcpctl` toolset, which acts + * AS the caller — see services/mcpctl-tools.ts. Absent for every other agent. + */ + callerToken?: string; threadId?: string; userMessage?: string; /** Optional full-history override; if set, threadId history is ignored. */ @@ -196,6 +202,10 @@ interface PreparedChatContext { maxIterations: number; /** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */ allowSecrets: boolean; + /** 'project' (MCP servers) or 'mcpctl' (operate mcpctl as the caller). */ + toolset: string; + /** Caller's bearer token, forwarded only for the 'mcpctl' toolset. */ + callerToken?: string; } export class ChatService { @@ -219,6 +229,11 @@ export class ChatService { private readonly projects?: IProjectRepository, private readonly rbac?: RbacService, private readonly secrets?: ChatSecretResolver, + /** + * Tools for the `mcpctl` system agent. Optional so existing test wirings + * compile; without it a system agent simply gets no tools. + */ + private readonly mcpctlTools?: McpctlToolProvider, ) {} async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> { @@ -299,7 +314,7 @@ export class ChatService { tool_calls: choice.tool_calls, }); for (const call of choice.tool_calls) { - const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets); + const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultMsg = await this.chatRepo.appendMessage({ threadId: ctx.threadId, role: 'tool', @@ -427,7 +442,7 @@ export class ChatService { for (const call of accumulated.toolCalls) { yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record }; try { - const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets); + const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultStr = typeof result === 'string' ? result : JSON.stringify(result); await this.chatRepo.appendMessage({ threadId: ctx.threadId, @@ -747,7 +762,13 @@ export class ChatService { history.push({ role: 'user', content: args.userMessage }); } - const toolList = await this.tools.listTools(projectId); + // A system agent operating mcpctl gets ONLY the mcpctl tools: no project + // MCP servers, and no project prompts in the system block above. Managing + // mcpctl is the whole job, and an unrelated project's context would just + // be noise it might act on. + const toolList = agent.toolset === 'mcpctl' && this.mcpctlTools !== undefined + ? this.mcpctlTools.listTools() + : await this.tools.listTools(projectId); const allowed = mergedParams.tools_allowlist; const filteredTools = allowed === undefined ? toolList @@ -765,6 +786,8 @@ export class ChatService { startingTurnIndex, maxIterations: resolveMaxIterations(agent.extras), allowSecrets: false, + toolset: agent.toolset, + ...(args.callerToken !== undefined ? { callerToken: args.callerToken } : {}), }; } @@ -877,6 +900,9 @@ export class ChatService { startingTurnIndex, maxIterations: MAX_ITERATIONS, allowSecrets, + // Project chat never gets the mcpctl toolset — that surface belongs to + // the system agent alone. + toolset: 'project', }; } @@ -1067,7 +1093,22 @@ export class ChatService { return body; } - private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null, allowSecrets: boolean): Promise { + private async dispatchTool( + toolWireName: string, + argsJson: string, + projectId: string | null, + allowSecrets: boolean, + ctx?: { toolset: string; callerToken?: string }, + ): Promise { + // mcpctl system tools are checked FIRST: they need no project, and they + // execute as the caller rather than through an MCP server. + if (this.mcpctlTools !== undefined && this.mcpctlTools.isMcpctlTool(toolWireName)) { + if (ctx?.toolset !== 'mcpctl') { + throw new Error(`Tool '${toolWireName}' is only available to the mcpctl system agent`); + } + const parsedArgs = safeParseJson(argsJson) as Record; + return this.mcpctlTools.callTool(toolWireName, parsedArgs, ctx.callerToken); + } if (projectId === null) { throw new Error('Tool calls require an agent attached to a Project'); } diff --git a/src/mcpd/src/services/mcpctl-tools.ts b/src/mcpd/src/services/mcpctl-tools.ts new file mode 100644 index 0000000..176882c --- /dev/null +++ b/src/mcpd/src/services/mcpctl-tools.ts @@ -0,0 +1,287 @@ +import type { ChatTool } from './chat.service.js'; + +/** + * Tools that let a system agent operate mcpctl itself. + * + * ## The permission model is the whole design + * + * Every call loops back through mcpd's own HTTP API carrying **the calling + * user's bearer token**. It does not touch services directly and it holds no + * credential of its own. That means the existing auth hook, RBAC hook, zod + * validation, ownership scoping, and audit trail all apply verbatim — the agent + * is exactly as privileged as the person talking to it, never more. Ask it to + * delete a project you cannot delete and mcpd answers 403, same as it would to + * `mcpctl delete`. + * + * Calling the service layer directly would have been faster and would have + * required re-implementing every one of those checks at a second call site. + * That is precisely the RBAC carve-out this project refuses to make. + * + * ## Why five generic tools rather than fifty specific ones + * + * mcpctl is a REST API over named resources, so the honest tool surface is + * list/get/create/update/delete plus a resource name. A large flat catalogue of + * per-resource tools measurably degrades selection; a small well-scoped one + * does not. + */ + +/** Resources the system agent may operate on, and their URL segments. */ +export const MCPCTL_RESOURCES = [ + 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', + 'personalities', 'templates', 'projects', 'users', 'groups', 'rbac', + 'prompts', 'skills', 'proposals', 'mcptokens', + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', +] as const; + +/** Resources that take `?project=` on list. */ +const PROJECT_SCOPED = new Set([ + 'prompts', 'skills', 'proposals', 'mcptokens', 'epics', 'repos', 'servers', +]); + +const RESOURCE_ENUM = { type: 'string', enum: [...MCPCTL_RESOURCES] } as const; + +export const MCPCTL_TOOLS: ChatTool[] = [ + { + name: 'mcpctl_list', + description: + 'List mcpctl resources of one kind. Use this first to discover exact names before acting on them. ' + + 'Some resources are project-scoped — pass `project` to narrow.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource'], + }, + }, + { + name: 'mcpctl_get', + description: + 'Fetch one resource by name (or id). Returns its full definition — read this before an update so you ' + + 'preserve fields you are not changing.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + name: { type: 'string', description: 'Resource name or id' }, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource', 'name'], + }, + }, + { + name: 'mcpctl_create', + description: + 'Create a resource. `body` is the resource definition as JSON, the same shape `mcpctl apply -f` takes. ' + + 'If you are unsure of the shape, list an existing one of the same kind first and mirror it. ' + + 'Validation errors come back with the exact field that was wrong.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + body: { type: 'object', description: 'Resource definition, e.g. { "name": "x", "content": "...", "project": "sre" }' }, + }, + required: ['resource', 'body'], + }, + }, + { + name: 'mcpctl_update', + description: 'Update an existing resource by name. `body` carries only the fields to change.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + name: { type: 'string', description: 'Resource name or id' }, + body: { type: 'object', description: 'Fields to change' }, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource', 'name', 'body'], + }, + }, + { + name: 'mcpctl_delete', + description: + 'Delete a resource by name. Destructive and not undoable — confirm with the user in your reply before ' + + 'calling this unless they clearly asked for the deletion.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + name: { type: 'string', description: 'Resource name or id' }, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource', 'name'], + }, + }, +]; + +export const MCPCTL_TOOL_NAMES = new Set(MCPCTL_TOOLS.map((t) => t.name)); + +/** System prompt for the mcpctl system agent. */ +export const MCPCTL_AGENT_SYSTEM_PROMPT = `You operate mcpctl on behalf of the user. + +You act with EXACTLY the user's own permissions — never more. If mcpctl returns +403, the user genuinely lacks that permission; say so plainly instead of +retrying or working around it. + +How to work: +- Resolve names before acting. List or get first; never guess an id. +- Before creating something, check whether it already exists. +- Mirror the shape of an existing resource of the same kind when you are unsure + what fields a create takes. +- Report exactly what you changed, using the real names, after you change it. +- For destructive actions the user did not explicitly ask for, describe what you + would delete and ask first. + +You have no other tools and no project context. Managing mcpctl is the entire +job.`; + +export interface McpctlToolResult { + status: number; + body: unknown; +} + +/** + * Executes the tools above by calling mcpd's own API as the calling user. + * + * `baseUrl` points back at this same mcpd process. The loopback is deliberate: + * it is what guarantees a single authorization path. + */ +export class McpctlToolProvider { + constructor( + private readonly baseUrl: string, + private readonly timeoutMs = 30_000, + ) {} + + listTools(): ChatTool[] { + return MCPCTL_TOOLS; + } + + isMcpctlTool(name: string): boolean { + return MCPCTL_TOOL_NAMES.has(name); + } + + /** + * @param callerToken the end user's bearer token. Without it the call is + * rejected by mcpd's auth hook — the agent has no identity of its own, + * which is the point. + */ + async callTool( + name: string, + args: Record, + callerToken: string | undefined, + ): Promise { + if (callerToken === undefined || callerToken === '') { + throw new Error( + 'The mcpctl agent acts as you, so it needs your credentials. This chat was started without a ' + + 'user token — run `mcpctl login` and try again.', + ); + } + + const resource = String(args['resource'] ?? ''); + if (!(MCPCTL_RESOURCES as readonly string[]).includes(resource)) { + throw new Error(`Unknown resource '${resource}'. Known: ${MCPCTL_RESOURCES.join(', ')}`); + } + const project = typeof args['project'] === 'string' ? args['project'] : undefined; + const target = typeof args['name'] === 'string' ? args['name'] : undefined; + const body = (args['body'] ?? {}) as Record; + + switch (name) { + case 'mcpctl_list': { + const qs = project !== undefined && PROJECT_SCOPED.has(resource) + ? `?project=${encodeURIComponent(project)}` + : ''; + return this.request('GET', `/api/v1/${resource}${qs}`, undefined, callerToken); + } + case 'mcpctl_get': { + const id = await this.resolveName(resource, requireName(target), project, callerToken); + return this.request('GET', `/api/v1/${resource}/${id}`, undefined, callerToken); + } + case 'mcpctl_create': + return this.request('POST', `/api/v1/${resource}`, body, callerToken); + case 'mcpctl_update': { + const id = await this.resolveName(resource, requireName(target), project, callerToken); + return this.request('PUT', `/api/v1/${resource}/${id}`, body, callerToken); + } + case 'mcpctl_delete': { + const id = await this.resolveName(resource, requireName(target), project, callerToken); + return this.request('DELETE', `/api/v1/${resource}/${id}`, undefined, callerToken); + } + default: + throw new Error(`Unknown mcpctl tool '${name}'`); + } + } + + /** Name → id, by listing and matching. Mirrors the CLI's resolveNameOrId. */ + private async resolveName( + resource: string, + name: string, + project: string | undefined, + token: string, + ): Promise { + if (/^c[a-z0-9]{20,}$/i.test(name)) return name; // already an id + + const qs = project !== undefined && PROJECT_SCOPED.has(resource) + ? `?project=${encodeURIComponent(project)}` + : ''; + const listed = await this.request('GET', `/api/v1/${resource}${qs}`, undefined, token); + const rows = Array.isArray(listed) ? (listed as Array>) : []; + const match = rows.find((r) => r['name'] === name || r['email'] === name); + if (match === undefined) { + const available = rows.map((r) => String(r['name'] ?? r['email'] ?? '')).filter((s) => s !== ''); + throw new Error( + `No ${resource} named '${name}'.` + + (available.length > 0 ? ` Available: ${available.slice(0, 25).join(', ')}` : ''), + ); + } + return String(match['id']); + } + + private async request( + method: string, + path: string, + body: unknown, + token: string, + ): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }; + const init: RequestInit = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) }; + if (body !== undefined && method !== 'GET' && method !== 'DELETE') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + let res: Response; + try { + res = await fetch(`${this.baseUrl}${path}`, init); + } catch (err: unknown) { + throw new Error(`mcpctl API call failed: ${err instanceof Error ? err.message : String(err)}`); + } + + const text = await res.text(); + let parsed: unknown; + try { + parsed = text === '' ? { ok: true } : JSON.parse(text); + } catch { + parsed = text; + } + + if (res.status === 403) { + // Surfaced as-is rather than retried: a denial is the correct answer, and + // the agent must report it instead of looking for another route in. + throw new Error(`Permission denied (403) on ${method} ${path}. You do not have rights for this.`); + } + if (res.status >= 400) { + throw new Error(`mcpctl returned ${String(res.status)} on ${method} ${path}: ${JSON.stringify(parsed)}`); + } + return parsed; + } +} + +function requireName(name: string | undefined): string { + if (name === undefined || name === '') throw new Error('This tool requires a `name` argument'); + return name; +} diff --git a/src/mcpd/tests/services/mcpctl-tools.test.ts b/src/mcpd/tests/services/mcpctl-tools.test.ts new file mode 100644 index 0000000..2e08721 --- /dev/null +++ b/src/mcpd/tests/services/mcpctl-tools.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { McpctlToolProvider, MCPCTL_TOOLS, MCPCTL_RESOURCES } from '../../src/services/mcpctl-tools.js'; + +/** + * The mcpctl system agent's entire safety story is that it acts as the calling + * user and cannot exceed them. These tests pin that: every call carries the + * caller's token, no call carries a service credential, and a 403 is reported + * rather than routed around. + */ +interface Call { url: string; method: string; auth?: string; body?: string } + +function mockFetch(handler: (c: Call) => { status: number; body: unknown }) { + const calls: Call[] = []; + globalThis.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + const call: Call = { + url: String(url), + method: init?.method ?? 'GET', + auth: headers['Authorization'], + body: init?.body as string | undefined, + }; + calls.push(call); + const { status, body } = handler(call); + return { status, text: async () => JSON.stringify(body) } as unknown as Response; + }) as unknown as typeof fetch; + return calls; +} + +const BASE = 'http://127.0.0.1:3000'; +const provider = (): McpctlToolProvider => new McpctlToolProvider(BASE); + +afterEach(() => { vi.restoreAllMocks(); }); + +describe('mcpctl tool surface', () => { + it('is a small generic catalogue, not one tool per resource', () => { + // A large flat catalogue measurably degrades tool selection; mcpctl is a + // REST API over named resources, so list/get/create/update/delete is the + // honest surface. + expect(MCPCTL_TOOLS.map((t) => t.name)).toEqual([ + 'mcpctl_list', 'mcpctl_get', 'mcpctl_create', 'mcpctl_update', 'mcpctl_delete', + ]); + }); + + it('constrains resource to a known enum so the model cannot invent endpoints', () => { + for (const t of MCPCTL_TOOLS) { + const props = (t.parameters as { properties: Record }).properties; + expect(props['resource']?.enum).toEqual([...MCPCTL_RESOURCES]); + } + }); + + it('warns about destructive intent on delete', () => { + const del = MCPCTL_TOOLS.find((t) => t.name === 'mcpctl_delete'); + expect(del?.description).toMatch(/not undoable|confirm/i); + }); +}); + +describe('McpctlToolProvider — acts as the caller', () => { + it('sends the CALLER\'s bearer token on every request', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await provider().callTool('mcpctl_list', { resource: 'projects' }, 'user-token-abc'); + expect(calls[0]?.auth).toBe('Bearer user-token-abc'); + }); + + it('refuses to act at all without a caller token', async () => { + // No ambient credential exists on purpose: with no user there is no + // authority to borrow, so the only safe answer is to stop. + const calls = mockFetch(() => ({ status: 200, body: [] })); + await expect( + provider().callTool('mcpctl_list', { resource: 'projects' }, undefined), + ).rejects.toThrow(/needs your credentials|mcpctl login/); + expect(calls).toHaveLength(0); + }); + + it('reports a 403 instead of retrying or routing around it', async () => { + const calls = mockFetch(() => ({ status: 403, body: { error: 'forbidden' } })); + await expect( + provider().callTool('mcpctl_create', { resource: 'projects', body: { name: 'x' } }, 'tok'), + ).rejects.toThrow(/Permission denied \(403\)/); + expect(calls).toHaveLength(1); // exactly one attempt + }); + + it('rejects a resource outside the allowlist', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await expect( + provider().callTool('mcpctl_list', { resource: 'shell' }, 'tok'), + ).rejects.toThrow(/Unknown resource 'shell'/); + expect(calls).toHaveLength(0); + }); +}); + +describe('McpctlToolProvider — request shaping', () => { + it('lists, scoping project-scoped resources', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await provider().callTool('mcpctl_list', { resource: 'prompts', project: 'sre' }, 'tok'); + expect(calls[0]?.url).toBe(`${BASE}/api/v1/prompts?project=sre`); + }); + + it('ignores project on a resource that is not project-scoped', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await provider().callTool('mcpctl_list', { resource: 'llms', project: 'sre' }, 'tok'); + expect(calls[0]?.url).toBe(`${BASE}/api/v1/llms`); + }); + + it('creates by POSTing the body verbatim', async () => { + const calls = mockFetch(() => ({ status: 201, body: { id: 'p1', name: 'gitthrees' } })); + await provider().callTool( + 'mcpctl_create', + { resource: 'prompts', body: { name: 'gitthrees', content: 'always use worktrees', project: 'sre' } }, + 'tok', + ); + expect(calls[0]?.method).toBe('POST'); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + name: 'gitthrees', content: 'always use worktrees', project: 'sre', + }); + }); + + it('resolves a name to an id before update, so the model never guesses one', async () => { + const calls = mockFetch((c) => + c.method === 'GET' + ? { status: 200, body: [{ id: 'cmabc123456789012345678901', name: 'gitthrees' }] } + : { status: 200, body: { ok: true } }, + ); + await provider().callTool( + 'mcpctl_update', + { resource: 'prompts', name: 'gitthrees', project: 'sre', body: { content: 'updated' } }, + 'tok', + ); + expect(calls[0]?.url).toContain('/api/v1/prompts?project=sre'); + expect(calls[1]?.method).toBe('PUT'); + expect(calls[1]?.url).toBe(`${BASE}/api/v1/prompts/cmabc123456789012345678901`); + }); + + it('passes an id straight through without a lookup', async () => { + const calls = mockFetch(() => ({ status: 200, body: { ok: true } })); + await provider().callTool( + 'mcpctl_get', { resource: 'projects', name: 'cmabc123456789012345678901' }, 'tok', + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${BASE}/api/v1/projects/cmabc123456789012345678901`); + }); + + it('lists what IS available when a name does not resolve', async () => { + mockFetch(() => ({ status: 200, body: [{ id: 'a', name: 'alpha' }, { id: 'b', name: 'beta' }] })); + await expect( + provider().callTool('mcpctl_get', { resource: 'projects', name: 'ghost' }, 'tok'), + ).rejects.toThrow(/No projects named 'ghost'.*alpha, beta/s); + }); + + it('matches users by email, which is their name', async () => { + const calls = mockFetch((c) => + c.method === 'GET' && c.url.endsWith('/users') + ? { status: 200, body: [{ id: 'u1', email: 'michal@example.com' }] } + : { status: 200, body: { ok: true } }, + ); + await provider().callTool('mcpctl_get', { resource: 'users', name: 'michal@example.com' }, 'tok'); + expect(calls[1]?.url).toBe(`${BASE}/api/v1/users/u1`); + }); + + it('requires a name for get/update/delete', async () => { + mockFetch(() => ({ status: 200, body: [] })); + for (const tool of ['mcpctl_get', 'mcpctl_update', 'mcpctl_delete']) { + await expect( + provider().callTool(tool, { resource: 'projects' }, 'tok'), + ).rejects.toThrow(/requires a `name`/); + } + }); + + it('surfaces a validation error with its detail, so the model can correct itself', async () => { + mockFetch(() => ({ status: 400, body: { error: 'Validation error', details: [{ path: ['name'] }] } })); + await expect( + provider().callTool('mcpctl_create', { resource: 'prompts', body: {} }, 'tok'), + ).rejects.toThrow(/mcpctl returned 400.*Validation error/s); + }); + + it('recognises only its own tools', () => { + const p = provider(); + expect(p.isMcpctlTool('mcpctl_list')).toBe(true); + expect(p.isMcpctlTool('docmost__search')).toBe(false); + }); +});