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