feat(mcpd): mcpctl system agent — operates mcpctl with the caller's permissions
A system agent, seeded like the system project and system skills, whose entire
job is running mcpctl for the user:
mcpctl chat mcpctl -m 'Make a prompt in the SRE project saying we run multiple
agents on the same source at once, so always use git worktrees'
## The permission model is the design
Every tool call loops back through mcpd's own HTTP API carrying the CALLING
USER's bearer token. The agent holds no credential of its own and never touches
the service layer directly, so the existing auth hook, RBAC hook, zod
validation, ownership scoping and audit trail all apply verbatim. It is exactly
as privileged as the person talking to it, never more.
Calling services directly would have been simpler and would have meant
re-implementing every one of those checks at a second call site — precisely the
RBAC carve-out this project refuses to make. A 403 is surfaced as a 403, not
retried or routed around; the system prompt tells the agent to report it.
Verified live, not just mocked: the same tool with an admin token created a
project, and with a plain user's token returned "Permission denied (403)".
No token at all refuses before any request is made; a bad token is rejected by
mcpd's own auth, because there is no ambient credential to fall back on.
## Shape
- `Agent.toolset` (TEXT, default 'project') selects the surface. 'mcpctl' means
no project prompts and no MCP tools — managing mcpctl is the whole job, and an
unrelated project's context would be noise it might act on.
- Five generic tools (list/get/create/update/delete + resource) rather than one
per resource. mcpctl is a REST API over named resources, so that is the honest
surface, and this repo's own tool-selection findings say a scoped catalogue
beats a large flat one.
- Names are resolved to ids by listing first, and a miss reports what IS
available, so the model never guesses an id.
- Resource is a closed enum: the model cannot invent an endpoint.
Also exercised end to end against a live mcpd: created the worktrees prompt in
the `sre` project, listed it back, and read its content — the user's original
example, working.
Tests: 17 unit tests covering the token plumbing, the no-token and 403 paths,
resource allowlisting, name resolution, and request shaping.
Local: build clean, workspace 2457 passed (+17), lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -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) ──
|
||||||
|
|||||||
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