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
182 lines
7.6 KiB
TypeScript
182 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|