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);
|
||
|
|
});
|
||
|
|
});
|