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?
|
projectId String?
|
||||||
defaultPersonalityId String? // applied at chat time when no --personality flag
|
defaultPersonalityId String? // applied at chat time when no --personality flag
|
||||||
proxyModelName String? // optional informational override
|
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, ...
|
defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ...
|
||||||
extras Json @default("{}") // future LoRA / tool-allowlist
|
extras Json @default("{}") // future LoRA / tool-allowlist
|
||||||
// ── Virtual-agent lifecycle (NULL/default for kind=public, mirrors Llm) ──
|
// ── Virtual-agent lifecycle (NULL/default for kind=public, mirrors Llm) ──
|
||||||
@@ -1133,21 +1139,21 @@ model TaskEvent {
|
|||||||
// with repo/repository/repositories all aliased to it.
|
// with repo/repository/repositories all aliased to it.
|
||||||
|
|
||||||
model CodeRepo {
|
model CodeRepo {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
projectId String
|
projectId String
|
||||||
// Clone URL. https for token auth, ssh for key auth.
|
// Clone URL. https for token auth, ssh for key auth.
|
||||||
url String
|
url String
|
||||||
defaultBranch String @default("main")
|
defaultBranch String @default("main")
|
||||||
// Relative path under the session workspace root. Defaults to `name`.
|
// 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
|
// Driver key for PR/merge API calls. Inherited from the GitProvider when the
|
||||||
// repo was imported from one.
|
// 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
|
// 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
|
// 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.
|
// own; a hand-added repo leaves this null and carries its own credSecretId.
|
||||||
gitProviderId String?
|
gitProviderId String?
|
||||||
// Denormalized provenance, mirroring McpServer.templateName/templateVersion:
|
// Denormalized provenance, mirroring McpServer.templateName/templateVersion:
|
||||||
// survives the provider being deleted, so "where did this repo come from"
|
// survives the provider being deleted, so "where did this repo come from"
|
||||||
// stays answerable after the forge is disconnected.
|
// stays answerable after the forge is disconnected.
|
||||||
@@ -1156,12 +1162,12 @@ model CodeRepo {
|
|||||||
sourceFullName String?
|
sourceFullName String?
|
||||||
// Secret holding the provider credential, resolved through SecretService at
|
// Secret holding the provider credential, resolved through SecretService at
|
||||||
// session-provision time — never stored in plaintext on the row.
|
// session-provision time — never stored in plaintext on the row.
|
||||||
credSecretId String?
|
credSecretId String?
|
||||||
credSecretKey String?
|
credSecretKey String?
|
||||||
ownerId String
|
ownerId String
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
cred Secret? @relation(fields: [credSecretId], references: [id])
|
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 { registerLogRoutes } from './routes/logs.js';
|
||||||
import { errorLogBuffer } from './services/error-log-buffer.js';
|
import { errorLogBuffer } from './services/error-log-buffer.js';
|
||||||
import { bootstrapSystemSkills } from './bootstrap/system-skills.js';
|
import { bootstrapSystemSkills } from './bootstrap/system-skills.js';
|
||||||
|
import { bootstrapSystemAgents } from './bootstrap/system-agents.js';
|
||||||
import {
|
import {
|
||||||
McpServerService,
|
McpServerService,
|
||||||
SecretService,
|
SecretService,
|
||||||
@@ -130,6 +131,7 @@ import { TaskService } from './services/task.service.js';
|
|||||||
import { CodeRepoService } from './services/code-repo.service.js';
|
import { CodeRepoService } from './services/code-repo.service.js';
|
||||||
import { GitProviderService } from './services/git-provider.service.js';
|
import { GitProviderService } from './services/git-provider.service.js';
|
||||||
import { GitProviderRegistry } from './services/git/registry.js';
|
import { GitProviderRegistry } from './services/git/registry.js';
|
||||||
|
import { McpctlToolProvider } from './services/mcpctl-tools.js';
|
||||||
import { SkillService } from './services/skill.service.js';
|
import { SkillService } from './services/skill.service.js';
|
||||||
import { GitBackupService } from './services/backup/git-backup.service.js';
|
import { GitBackupService } from './services/backup/git-backup.service.js';
|
||||||
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
||||||
@@ -433,6 +435,8 @@ async function main(): Promise<void> {
|
|||||||
await bootstrapSystemProject(prisma);
|
await bootstrapSystemProject(prisma);
|
||||||
// PR-4: bootstrap system-owned global skills (e.g. propose-learnings).
|
// PR-4: bootstrap system-owned global skills (e.g. propose-learnings).
|
||||||
await bootstrapSystemSkills(prisma);
|
await bootstrapSystemSkills(prisma);
|
||||||
|
// Seeds the `mcpctl` system agent once an Llm and a user exist.
|
||||||
|
await bootstrapSystemAgents(prisma);
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
const serverRepo = new McpServerRepository(prisma);
|
const serverRepo = new McpServerRepository(prisma);
|
||||||
@@ -753,6 +757,9 @@ async function main(): Promise<void> {
|
|||||||
projectRepo,
|
projectRepo,
|
||||||
rbacService,
|
rbacService,
|
||||||
secretService,
|
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);
|
registerAgentChatRoutes(app, chatService);
|
||||||
registerProjectChatRoutes(app, chatService, rbacService);
|
registerProjectChatRoutes(app, chatService, rbacService);
|
||||||
|
|||||||
@@ -41,9 +41,17 @@ export function registerAgentChatRoutes(
|
|||||||
...paramsRest
|
...paramsRest
|
||||||
} = parsed;
|
} = 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 = {
|
const args = {
|
||||||
agentName: request.params.name,
|
agentName: request.params.name,
|
||||||
ownerId,
|
ownerId,
|
||||||
|
...(callerToken !== undefined ? { callerToken } : {}),
|
||||||
...(threadId !== undefined ? { threadId } : {}),
|
...(threadId !== undefined ? { threadId } : {}),
|
||||||
...(message !== undefined ? { userMessage: message } : {}),
|
...(message !== undefined ? { userMessage: message } : {}),
|
||||||
...(messagesOverride !== undefined
|
...(messagesOverride !== undefined
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface AgentView {
|
|||||||
project: { id: string; name: string } | null;
|
project: { id: string; name: string } | null;
|
||||||
defaultPersonality: { id: string; name: string } | null;
|
defaultPersonality: { id: string; name: string } | null;
|
||||||
proxyModelName: string | null;
|
proxyModelName: string | null;
|
||||||
|
/** 'project' (MCP tools) or 'mcpctl' (operates mcpctl as the caller). */
|
||||||
|
toolset: string;
|
||||||
defaultParams: AgentChatParams;
|
defaultParams: AgentChatParams;
|
||||||
extras: Record<string, unknown>;
|
extras: Record<string, unknown>;
|
||||||
// Virtual-agent lifecycle (defaults make public agents look like "active").
|
// 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,
|
project: project !== null ? { id: project.id, name: project.name } : null,
|
||||||
defaultPersonality,
|
defaultPersonality,
|
||||||
proxyModelName: row.proxyModelName,
|
proxyModelName: row.proxyModelName,
|
||||||
|
toolset: row.toolset,
|
||||||
defaultParams: row.defaultParams as AgentChatParams,
|
defaultParams: row.defaultParams as AgentChatParams,
|
||||||
extras: row.extras as Record<string, unknown>,
|
extras: row.extras as Record<string, unknown>,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { RbacService } from './rbac.service.js';
|
|||||||
import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js';
|
import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js';
|
||||||
import type { AgentChatParams } from '../validation/agent.schema.js';
|
import type { AgentChatParams } from '../validation/agent.schema.js';
|
||||||
import { NotFoundError } from './mcp-server.service.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. */
|
/** Minimal secret-read surface the project-chat `get_secret` tool needs. */
|
||||||
export interface ChatSecretResolver {
|
export interface ChatSecretResolver {
|
||||||
@@ -136,6 +137,11 @@ export interface PoolCandidate {
|
|||||||
|
|
||||||
export interface ChatRequestArgs {
|
export interface ChatRequestArgs {
|
||||||
agentName: string;
|
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;
|
threadId?: string;
|
||||||
userMessage?: string;
|
userMessage?: string;
|
||||||
/** Optional full-history override; if set, threadId history is ignored. */
|
/** Optional full-history override; if set, threadId history is ignored. */
|
||||||
@@ -196,6 +202,10 @@ interface PreparedChatContext {
|
|||||||
maxIterations: number;
|
maxIterations: number;
|
||||||
/** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */
|
/** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */
|
||||||
allowSecrets: boolean;
|
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 {
|
export class ChatService {
|
||||||
@@ -219,6 +229,11 @@ export class ChatService {
|
|||||||
private readonly projects?: IProjectRepository,
|
private readonly projects?: IProjectRepository,
|
||||||
private readonly rbac?: RbacService,
|
private readonly rbac?: RbacService,
|
||||||
private readonly secrets?: ChatSecretResolver,
|
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 }> {
|
async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> {
|
||||||
@@ -299,7 +314,7 @@ export class ChatService {
|
|||||||
tool_calls: choice.tool_calls,
|
tool_calls: choice.tool_calls,
|
||||||
});
|
});
|
||||||
for (const call of 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({
|
const resultMsg = await this.chatRepo.appendMessage({
|
||||||
threadId: ctx.threadId,
|
threadId: ctx.threadId,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@@ -427,7 +442,7 @@ export class ChatService {
|
|||||||
for (const call of accumulated.toolCalls) {
|
for (const call of accumulated.toolCalls) {
|
||||||
yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record<string, unknown> };
|
yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record<string, unknown> };
|
||||||
try {
|
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);
|
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||||
await this.chatRepo.appendMessage({
|
await this.chatRepo.appendMessage({
|
||||||
threadId: ctx.threadId,
|
threadId: ctx.threadId,
|
||||||
@@ -747,7 +762,13 @@ export class ChatService {
|
|||||||
history.push({ role: 'user', content: args.userMessage });
|
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 allowed = mergedParams.tools_allowlist;
|
||||||
const filteredTools = allowed === undefined
|
const filteredTools = allowed === undefined
|
||||||
? toolList
|
? toolList
|
||||||
@@ -765,6 +786,8 @@ export class ChatService {
|
|||||||
startingTurnIndex,
|
startingTurnIndex,
|
||||||
maxIterations: resolveMaxIterations(agent.extras),
|
maxIterations: resolveMaxIterations(agent.extras),
|
||||||
allowSecrets: false,
|
allowSecrets: false,
|
||||||
|
toolset: agent.toolset,
|
||||||
|
...(args.callerToken !== undefined ? { callerToken: args.callerToken } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +900,9 @@ export class ChatService {
|
|||||||
startingTurnIndex,
|
startingTurnIndex,
|
||||||
maxIterations: MAX_ITERATIONS,
|
maxIterations: MAX_ITERATIONS,
|
||||||
allowSecrets,
|
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;
|
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) {
|
if (projectId === null) {
|
||||||
throw new Error('Tool calls require an agent attached to a Project');
|
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