import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import express, { type RequestHandler } from 'express'; import request from 'supertest'; import { mountToolsApi, _resetToolCatalogCacheForTests, type McpCatalogDeps, type ToolCatalogEntry, } from './tools-api.js'; import { setSshSubsystem, type SshSubsystem } from '../engine/tools/ssh.js'; /** * Build an express app exposing /api/tools. `user` controls what * (req as any).user is set to before the catalog handler runs. */ function makeApp(opts?: { user?: { id?: string; role?: string } | null; authActive?: boolean; mcp?: McpCatalogDeps | null; }): express.Application { const app = express(); app.use(express.json()); if (opts?.user !== null) { const u = opts?.user ?? { id: 'u1', role: 'user' }; app.use((req, _res, next) => { (req as unknown as { user: typeof u }).user = u; // supertest doesn't have passport; fake isAuthenticated. (req as unknown as { isAuthenticated: () => boolean }).isAuthenticated = () => true; next(); }); } const requireAuth: RequestHandler = (req, res, next) => { if ((req as unknown as { user?: { id?: string } }).user?.id) { next(); } else { res.status(401).json({ error: 'Unauthorized' }); } }; mountToolsApi(app, { authActive: opts?.authActive ?? false, requireAuth, mcp: opts?.mcp ?? null, }); return app; } describe('GET /api/tools (runtime catalog)', () => { beforeEach(() => { _resetToolCatalogCacheForTests(); setSshSubsystem(null); }); afterEach(() => { setSshSubsystem(null); _resetToolCatalogCacheForTests(); }); it('returns ToolCatalogResponse with builtin tools', async () => { const res = await request(makeApp()).get('/api/tools'); expect(res.status).toBe(200); expect(Array.isArray(res.body.tools)).toBe(true); const names = (res.body.tools as ToolCatalogEntry[]).map((t) => t.name); // Core builtin tools must be present. expect(names).toContain('Read'); expect(names).toContain('Write'); expect(names).toContain('Bash'); }); it('tags core tools with source=builtin and category=core', async () => { const res = await request(makeApp()).get('/api/tools'); const read = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'Read'); expect(read).toBeDefined(); expect(read!.source).toBe('builtin'); expect(read!.category).toBe('core'); expect(read!.scope).toBe('piece'); expect(read!.available).toBe(true); }); it('includes meta tools tagged source=meta scope=global', async () => { const res = await request(makeApp()).get('/api/tools'); const tools = res.body.tools as ToolCatalogEntry[]; const readDoc = tools.find((t) => t.name === 'ReadToolDoc'); expect(readDoc).toBeDefined(); expect(readDoc!.source).toBe('meta'); expect(readDoc!.scope).toBe('global'); expect(readDoc!.available).toBe(true); const brainstorm = tools.find((t) => t.name === 'Brainstorm'); expect(brainstorm?.source).toBe('meta'); expect(brainstorm?.scope).toBe('global'); }); it('marks ssh tools available=false with reason when SSH subsystem is not initialised', async () => { setSshSubsystem(null); const res = await request(makeApp()).get('/api/tools'); const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec'); // SSH module may not exist in some lean builds; only assert when present. if (ssh) { expect(ssh.category).toBe('ssh'); expect(ssh.available).toBe(false); expect(ssh.reason).toMatch(/SSH subsystem not initialised/); expect(ssh.scope).toBe('piece'); } }); it('marks ssh tools available=true when subsystem is initialised', async () => { setSshSubsystem({} as SshSubsystem); const res = await request(makeApp()).get('/api/tools'); const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec'); if (ssh) { expect(ssh.available).toBe(true); expect(ssh.reason).toBeUndefined(); } }); it('includes MCP tools for authenticated user', async () => { const mcp: McpCatalogDeps = { registry: { listEnabledForUser: () => [ { id: 'canva', name: 'Canva', enabled: true }, ], }, tokenManager: { hasToken: () => true, }, toolCache: { getAllForServers: () => [ { serverId: 'canva', toolName: 'createDesign' }, { serverId: 'canva', toolName: 'listDesigns' }, ], }, }; const res = await request(makeApp({ mcp })).get('/api/tools'); const tools = res.body.tools as ToolCatalogEntry[]; const create = tools.find((t) => t.name === 'mcp__canva__createDesign'); expect(create).toBeDefined(); expect(create!.source).toBe('mcp'); expect(create!.category).toBe('mcp:canva'); expect(create!.serverId).toBe('canva'); expect(create!.scope).toBe('user'); expect(create!.available).toBe(true); }); it('marks MCP tools unavailable with reason when user is not connected (offline)', async () => { const mcp: McpCatalogDeps = { registry: { listEnabledForUser: () => [{ id: 'gh', name: 'GitHub', enabled: true }], }, tokenManager: { hasToken: () => false, // user not connected }, toolCache: { getAllForServers: () => [{ serverId: 'gh', toolName: 'listIssues' }], }, }; const res = await request(makeApp({ mcp })).get('/api/tools'); const tool = (res.body.tools as ToolCatalogEntry[]).find( (t) => t.name === 'mcp__gh__listIssues', ); expect(tool).toBeDefined(); expect(tool!.available).toBe(false); expect(tool!.reason).toMatch(/offline/); }); it('omits MCP tools when caller has no user id (unauthenticated)', async () => { const mcp: McpCatalogDeps = { registry: { listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }], }, tokenManager: { hasToken: () => true }, toolCache: { getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }], }, }; const res = await request(makeApp({ user: null, mcp })).get('/api/tools'); expect(res.status).toBe(200); const tools = res.body.tools as ToolCatalogEntry[]; expect(tools.some((t) => t.source === 'mcp')).toBe(false); }); it('returns 401 when authActive=true and caller is not authenticated', async () => { const res = await request(makeApp({ user: null, authActive: true })).get('/api/tools'); expect(res.status).toBe(401); }); it('?legacy=1 returns flat array of tool names', async () => { const res = await request(makeApp()).get('/api/tools?legacy=1'); expect(res.status).toBe(200); expect(Array.isArray(res.body.tools)).toBe(true); // Must be string[], NOT objects. for (const t of res.body.tools as unknown[]) { expect(typeof t).toBe('string'); } expect(res.body.tools).toContain('Read'); expect(res.body.tools).toContain('ReadToolDoc'); }); it('?legacy=1 includes per-user MCP names when authenticated', async () => { const mcp: McpCatalogDeps = { registry: { listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }], }, tokenManager: { hasToken: () => true }, toolCache: { getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }], }, }; const res = await request(makeApp({ mcp })).get('/api/tools?legacy=1'); expect(res.body.tools).toContain('mcp__canva__createDesign'); }); it('surfaces a placeholder entry when MCP server has no cached tools', async () => { const mcp: McpCatalogDeps = { registry: { listEnabledForUser: () => [{ id: 'fresh', name: 'Fresh', enabled: true }], }, tokenManager: { hasToken: () => false }, toolCache: { getAllForServers: () => [] }, }; const res = await request(makeApp({ mcp })).get('/api/tools'); const placeholder = (res.body.tools as ToolCatalogEntry[]).find( (t) => t.serverId === 'fresh', ); expect(placeholder).toBeDefined(); expect(placeholder!.available).toBe(false); expect(placeholder!.reason).toMatch(/offline|no cached tools/); expect(placeholder!.scope).toBe('user'); }); });