All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / lint (pull_request) Successful in 2m27s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Successful in 4m56s
CI/CD / publish (pull_request) Has been skipped
The proxy-path fix (5d10728) covered upstream tools/call routing via
McpdUpstream, but getOrCreateRouter in project-mcp-endpoint.ts had TWO
more mcpd-bound call sites that silently fell back to the pod's empty
default token:
1. fetchProjectLlmConfig(mcpdClient, projectName)
2. router.setPromptConfig(mcpdClient.withHeaders({...}))
→ which is what gate.ts begin_session uses via ctx.fetchPromptIndex()
to hit /api/v1/projects/:name/prompts/visible
Symptom: in the k8s mcplocal pod, LiteLLM would initialize + tools/list
fine (showing begin_session), but tools/call begin_session returned
`{isError: true, content: "McpError: Authentication failed: invalid or
expired token"}`. Reproduced against the live cluster by driving
LiteLLM's /mcp/ endpoint with qwen3-thinking's exact payload.
Fix: build `requestClient = mcpdClient.withToken(authToken)` once at the
top of getOrCreateRouter and thread it through fetchProjectLlmConfig
and setPromptConfig. withHeaders still adds X-Service-Account for
mcpd-side audit tagging, but the bearer now carries the caller's
McpToken identity (resolves as McpToken:<sha> on mcpd).
Verified: unit tests pass (mock needed withToken/withTimeout stubs).
Next step: rebuild image + roll pod + retest LiteLLM→mcp flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
6.2 KiB
TypeScript
191 lines
6.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import Fastify from 'fastify';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
|
|
|
|
// Mock discovery module — we don't want real HTTP calls
|
|
vi.mock('../src/discovery.js', () => ({
|
|
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
|
|
fetchProjectLlmConfig: vi.fn(async () => ({})),
|
|
}));
|
|
|
|
// Mock config module — don't read real config files
|
|
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),
|
|
};
|
|
});
|
|
|
|
import { refreshProjectUpstreams } from '../src/discovery.js';
|
|
|
|
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(),
|
|
};
|
|
// Chainable withX returns the same client for simplicity
|
|
(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;
|
|
}
|
|
|
|
describe('registerProjectMcpEndpoint', () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
app = Fastify();
|
|
registerProjectMcpEndpoint(app, mockMcpdClient() as any);
|
|
await app.ready();
|
|
});
|
|
|
|
it('registers POST /projects/:projectName/mcp route', async () => {
|
|
// The endpoint should exist and attempt to handle MCP protocol
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/smart-home/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
// The StreamableHTTPServerTransport hijacks the response,
|
|
// so we may get a 200 or the transport handles it directly
|
|
expect(res.statusCode).not.toBe(404);
|
|
});
|
|
|
|
it('calls refreshProjectUpstreams with project name', async () => {
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/smart-home/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
|
|
expect.any(Object), // McpRouter instance
|
|
expect.any(Object), // McpdClient
|
|
'smart-home',
|
|
undefined, // no auth token
|
|
);
|
|
});
|
|
|
|
it('forwards auth token from Authorization header', async () => {
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/secure-project/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'authorization': 'Bearer my-token-123',
|
|
},
|
|
});
|
|
|
|
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.any(Object),
|
|
'secure-project',
|
|
'my-token-123',
|
|
);
|
|
});
|
|
|
|
it('returns 502 when project discovery fails', async () => {
|
|
vi.mocked(refreshProjectUpstreams).mockRejectedValueOnce(new Error('Forbidden'));
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/bad-project/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(502);
|
|
expect(res.json().error).toContain('Failed to load project');
|
|
});
|
|
|
|
it('returns 404 for unknown session ID', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/smart-home/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} },
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'mcp-session-id': 'nonexistent-session',
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 400 for GET without session', async () => {
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: '/projects/smart-home/mcp',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().error).toContain('session');
|
|
});
|
|
|
|
it('returns 400 for DELETE without session', async () => {
|
|
const res = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/projects/smart-home/mcp',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().error).toContain('session');
|
|
});
|
|
|
|
it('caches project router across requests', async () => {
|
|
// Two requests to the same project should reuse the router
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/smart-home/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/smart-home/mcp',
|
|
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
// refreshProjectUpstreams should only be called once (cached)
|
|
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('creates separate routers for different projects', async () => {
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/project-a/mcp',
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/projects/project-b/mcp',
|
|
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
// Two different projects should trigger two refreshes
|
|
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(2);
|
|
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-a', undefined);
|
|
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-b', undefined);
|
|
});
|
|
});
|