test(mcplocal): end-to-end wire-name coverage on the project endpoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / test (pull_request) Successful in 3m42s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / test (pull_request) Successful in 3m42s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
Drives /projects/:name/mcp over the real Streamable HTTP transport with a fake websearch upstream: tools/list must serve `websearch_fetch_content` (every name matching the OpenAI function-name charset), calling that wire name must reach the upstream as bare `fetch_content`, and a legacy client echoing the canonical `websearch/fetch_content` must still route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
This commit is contained in:
145
src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts
Normal file
145
src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
|
||||
import type { McpRouter } from '../src/router.js';
|
||||
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
|
||||
|
||||
/**
|
||||
* End-to-end wire-name test on the endpoint the librechat incident actually
|
||||
* hit: /projects/:name/mcp. A fake `websearch` upstream serves a tool named
|
||||
* `fetch_content`; the client must see `websearch_fetch_content` in
|
||||
* tools/list, and calling that wire name must reach the upstream as plain
|
||||
* `fetch_content` (router canonical `websearch/fetch_content`, prefix
|
||||
* stripped on dispatch).
|
||||
*/
|
||||
|
||||
const upstreamRequests: JsonRpcRequest[] = [];
|
||||
|
||||
vi.mock('../src/discovery.js', () => ({
|
||||
refreshProjectUpstreams: vi.fn(async (router: McpRouter) => {
|
||||
router.addUpstream({
|
||||
name: 'websearch',
|
||||
send: async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
||||
upstreamRequests.push(req);
|
||||
if (req.method === 'tools/list') {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: { tools: [{ name: 'fetch_content', description: 'fetch a page', inputSchema: { type: 'object' } }] },
|
||||
};
|
||||
}
|
||||
if (req.method === 'tools/call') {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: { content: [{ type: 'text', text: 'page body' }] },
|
||||
};
|
||||
}
|
||||
return { jsonrpc: '2.0', id: req.id, result: {} };
|
||||
},
|
||||
close: async () => {},
|
||||
isAlive: () => true,
|
||||
});
|
||||
return ['websearch'];
|
||||
}),
|
||||
// gated: false → no gate plugin, the full catalog is served at initialize
|
||||
// (the chat-web configuration).
|
||||
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
|
||||
}));
|
||||
|
||||
vi.mock('../src/http/config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
|
||||
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
|
||||
});
|
||||
|
||||
function mockMcpdClient() {
|
||||
const client: Record<string, unknown> = {
|
||||
baseUrl: 'http://test:3100',
|
||||
token: 'test-token',
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(async () => ({ status: 200, body: [] })),
|
||||
withHeaders: vi.fn(),
|
||||
withToken: vi.fn(),
|
||||
withTimeout: vi.fn(),
|
||||
};
|
||||
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
|
||||
(client.withToken as ReturnType<typeof vi.fn>).mockReturnValue(client);
|
||||
(client.withTimeout as ReturnType<typeof vi.fn>).mockReturnValue(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
function parseSse(body: string): JsonRpcResponse {
|
||||
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
|
||||
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
|
||||
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
|
||||
}
|
||||
|
||||
describe('registerProjectMcpEndpoint wire names', () => {
|
||||
it('serves wire-safe names on the project endpoint and dispatches calls upstream', async () => {
|
||||
upstreamRequests.length = 0;
|
||||
const app = Fastify();
|
||||
registerProjectMcpEndpoint(app, mockMcpdClient() as never);
|
||||
await app.ready();
|
||||
try {
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
};
|
||||
|
||||
const init = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/chat-web/mcp',
|
||||
headers,
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
|
||||
});
|
||||
expect(init.statusCode).toBe(200);
|
||||
const sessionId = init.headers['mcp-session-id'] as string;
|
||||
expect(sessionId).toBeTruthy();
|
||||
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/chat-web/mcp',
|
||||
headers: sessionHeaders,
|
||||
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
});
|
||||
const listResponse = parseSse(list.body);
|
||||
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('websearch_fetch_content');
|
||||
// Every served name must be a valid OpenAI-style function name — the
|
||||
// invariant the librechat incident violated.
|
||||
for (const name of names) {
|
||||
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
|
||||
}
|
||||
|
||||
const call = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/chat-web/mcp',
|
||||
headers: sessionHeaders,
|
||||
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
|
||||
});
|
||||
const callResponse = parseSse(call.body);
|
||||
expect(callResponse.error).toBeUndefined();
|
||||
expect((callResponse.result as { content: Array<{ text: string }> }).content[0]?.text).toBe('page body');
|
||||
|
||||
// The upstream saw the bare tool name — namespace stripped, not mangled.
|
||||
const upstreamCall = upstreamRequests.find((r) => r.method === 'tools/call');
|
||||
expect(upstreamCall?.params?.['name']).toBe('fetch_content');
|
||||
|
||||
// Legacy clients echoing the canonical slash name keep working.
|
||||
const legacy = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/chat-web/mcp',
|
||||
headers: sessionHeaders,
|
||||
payload: { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'websearch/fetch_content', arguments: { url: 'https://x' } } },
|
||||
});
|
||||
expect(parseSse(legacy.body).error).toBeUndefined();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user