agentic-teams: epics, tasks, git forges, agent authority #139
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Agent" ADD COLUMN "toolset" TEXT NOT NULL DEFAULT 'project';
|
||||
|
||||
@@ -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])
|
||||
|
||||
64
src/mcpd/src/bootstrap/system-agents.ts
Normal file
64
src/mcpd/src/bootstrap/system-agents.ts
Normal file
@@ -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<void> {
|
||||
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}'`);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
// 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<string, unknown>,
|
||||
kind: row.kind,
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
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<unknown> {
|
||||
private async dispatchTool(
|
||||
toolWireName: string,
|
||||
argsJson: string,
|
||||
projectId: string | null,
|
||||
allowSecrets: boolean,
|
||||
ctx?: { toolset: string; callerToken?: string },
|
||||
): Promise<unknown> {
|
||||
// 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<string, unknown>;
|
||||
return this.mcpctlTools.callTool(toolWireName, parsedArgs, ctx.callerToken);
|
||||
}
|
||||
if (projectId === null) {
|
||||
throw new Error('Tool calls require an agent attached to a Project');
|
||||
}
|
||||
|
||||
287
src/mcpd/src/services/mcpctl-tools.ts
Normal file
287
src/mcpd/src/services/mcpctl-tools.ts
Normal file
@@ -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=<name>` 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<string, unknown>,
|
||||
callerToken: string | undefined,
|
||||
): Promise<unknown> {
|
||||
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<string, unknown>;
|
||||
|
||||
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<string> {
|
||||
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<Record<string, unknown>>) : [];
|
||||
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<unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
181
src/mcpd/tests/services/mcpctl-tools.test.ts
Normal file
181
src/mcpd/tests/services/mcpctl-tools.test.ts
Normal file
@@ -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<string, string>;
|
||||
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<string, { enum?: string[] }> }).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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user