Files
lindyson-ai/apps/api/tests/mcpTools.test.ts
T
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

164 lines
7.6 KiB
TypeScript

import { createServer, type Server } from 'node:http';
import { specTypeSchemas } from '@modelcontextprotocol/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
poolQuery: vi.fn(),
getUserProfile: vi.fn(),
getGenerationTask: vi.fn(),
}));
vi.mock('../src/db/pool.js', () => ({ pool: { query: mocks.poolQuery } }));
vi.mock('../src/config/env.js', () => ({ env: {
MCP_PUBLIC_URL: 'https://www.lindyson.com/mcp',
MCP_READ_RATE_PER_MINUTE: 60,
MCP_GENERATE_RATE_PER_MINUTE: 6,
} }));
vi.mock('../src/domain/generationService.js', () => ({
createGenerationTasksBatch: vi.fn(),
getGenerationTask: mocks.getGenerationTask,
sanitizeGenerationConfig: vi.fn(),
}));
vi.mock('../src/domain/consumption.js', () => ({
getGenerationCredits: vi.fn(), listConsumptionSettings: vi.fn(),
}));
vi.mock('../src/domain/users.js', () => ({ getUserProfile: mocks.getUserProfile }));
vi.mock('../src/domain/subscriptionLifecycle.js', () => ({ reconcileSubscription: vi.fn() }));
vi.mock('../src/domain/assetDownloadService.js', () => ({ prepareOwnedAssetDownload: vi.fn() }));
vi.mock('../src/routes/generationRoutes.js', () => ({
isVideoModelCapabilityAvailable: vi.fn(), toUserGenerationTask: vi.fn(),
}));
vi.mock('../src/queues/generationQueue.js', () => ({ enqueueGenerationTask: vi.fn() }));
vi.mock('../src/mcp/fileImports.js', () => ({ getMcpImport: vi.fn(), importChatGptFiles: vi.fn() }));
import { createMcpNodeHandler } from '../src/mcp/tools.js';
import { MCP_SCOPES, type McpIdentity } from '../src/mcp/security.js';
const expectedScopes = {
list_models: ['account:read'],
get_account: ['account:read'],
list_assets: ['assets:read'],
import_files: ['assets:write'],
get_import: ['assets:write'],
estimate_generation: ['account:read'],
generate_image: ['generation:write'],
generate_video: ['generation:write'],
list_tasks: ['generation:read'],
get_task: ['generation:read'],
get_result: ['generation:read', 'assets:read'],
};
const identity: McpIdentity = {
userId: '00000000-0000-4000-8000-000000000001',
connectionId: '00000000-0000-4000-8000-000000000002',
authVersion: 1,
scopes: [...MCP_SCOPES],
};
describe('MCP 工具的 HTTP 权限描述与调用', () => {
let httpServer: Server;
let url: string;
let requestIdentity: McpIdentity;
beforeEach(async () => {
vi.clearAllMocks();
mocks.poolQuery.mockResolvedValue({ rows: [{ calls: 1 }] });
mocks.getUserProfile.mockResolvedValue({
userName: '测试用户', credits: 100, mainCredits: 80, addonCredits: 20, planCode: 'free',
});
requestIdentity = { ...identity, scopes: [...MCP_SCOPES] };
httpServer = createServer((request, response) => {
void createMcpNodeHandler(requestIdentity)(request, response).catch(() => {
response.statusCode = 500;
response.end();
});
});
await new Promise<void>((resolve) => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address();
if (!address || typeof address === 'string') throw new Error('测试 HTTP 服务未启动');
url = `http://127.0.0.1:${address.port}/mcp`;
});
afterEach(async () => {
httpServer.closeAllConnections();
await new Promise<void>((resolve, reject) => httpServer.close((error) => error ? reject(error) : resolve()));
});
async function rpc(method: string, params: Record<string, unknown>, protocol = '2025-06-18') {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': protocol,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const text = await response.text();
expect(response.status, text).toBe(200);
const body = response.headers.get('content-type')?.includes('text/event-stream')
? JSON.parse(text.split('\n').filter((line) => line.startsWith('data: ')).at(-1)!.slice(6))
: JSON.parse(text);
expect(body.error).toBeUndefined();
return body.result;
}
it.each(['2025-03-26', '2025-06-18', '2025-11-25'])(
'%s 的实际工具列表发布全部权限主字段和兼容镜像',
async (protocol) => {
const result = await rpc('tools/list', {}, protocol);
expect(specTypeSchemas.ListToolsResult['~standard'].validate(result).issues).toBeUndefined();
expect(result.tools.map((tool: { name: string }) => tool.name).sort()).toEqual(Object.keys(expectedScopes).sort());
for (const tool of result.tools) {
const scopes = expectedScopes[tool.name as keyof typeof expectedScopes];
expect(tool.securitySchemes).toEqual([{ type: 'oauth2', scopes }]);
expect(tool._meta.securitySchemes).toEqual(tool.securitySchemes);
expect(tool.inputSchema.type).toBe('object');
expect(tool.outputSchema).toEqual({ type: 'object', additionalProperties: true });
const write = ['import_files', 'generate_image', 'generate_video', 'get_result'].includes(tool.name);
expect(tool.annotations).toEqual({
readOnlyHint: !write,
destructiveHint: false,
idempotentHint: !write || tool.name.startsWith('generate_') || tool.name === 'import_files',
openWorldHint: write,
});
}
const estimator = result.tools.find((tool: { name: string }) => tool.name === 'estimate_generation');
expect(estimator.inputSchema.anyOf.map((option: { type: string }) => option.type)).toEqual(['object', 'object']);
const importer = result.tools.find((tool: { name: string }) => tool.name === 'import_files');
expect(importer._meta['openai/fileParams']).toEqual(['files']);
expect(importer.inputSchema.properties.files.items.required).toEqual(['download_url', 'file_id']);
},
);
it('工具调用仍经过 SDK 参数校验并正常返回账号结果', async () => {
const valid = await rpc('tools/call', { name: 'get_account', arguments: {} });
expect(valid.isError).toBeUndefined();
expect(valid.structuredContent).toMatchObject({ credits: 100, mainCredits: 80, addonCredits: 20 });
expect(mocks.getUserProfile).toHaveBeenCalledTimes(1);
const invalid = await rpc('tools/call', { name: 'get_account', arguments: { unknown: true } });
expect(invalid.isError).toBe(true);
expect(mocks.getUserProfile).toHaveBeenCalledTimes(1);
});
it('get_result 缺少素材权限时保留完整的补充授权挑战且不读取任务', async () => {
requestIdentity.scopes = ['generation:read'];
const result = await rpc('tools/call', { name: 'get_result', arguments: { id: identity.userId } });
expect(result.isError).toBe(true);
expect(JSON.parse(result.content[0].text).code).toBe('MCP_SCOPE_REQUIRED');
expect(result._meta['mcp/www_authenticate']).toEqual([
'Bearer resource_metadata="https://www.lindyson.com/.well-known/oauth-protected-resource/mcp", error="insufficient_scope", error_description="Reauthorize Lindyson", scope="generation:read assets:read"',
]);
expect(mocks.getGenerationTask).not.toHaveBeenCalled();
expect(mocks.poolQuery).not.toHaveBeenCalled();
});
it('调用中授权失效时保留重新登录挑战', async () => {
mocks.getUserProfile.mockRejectedValueOnce(Object.assign(new Error('请重新授权'), {
code: 'MCP_AUTH_INVALID', statusCode: 401,
}));
const result = await rpc('tools/call', { name: 'get_account', arguments: {} });
expect(result.isError).toBe(true);
expect(result._meta['mcp/www_authenticate'][0]).toContain('error="invalid_token"');
expect(result._meta['mcp/www_authenticate'][0]).toContain('scope="account:read"');
});
});