230 lines
8.2 KiB
TypeScript
230 lines
8.2 KiB
TypeScript
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');
|
|
});
|
|
});
|