Files
mcpctl/src/mcplocal/tests/project-discovery.test.ts
Michal 5d1072889f fix(mcplocal): thread client bearer into per-upstream McpdClient
Symptom: HTTP-mode mcplocal accepted the incoming mcpctl_pat_ bearer,
but every /api/v1/mcp/proxy call to mcpd for upstream discovery came
back with "Authentication failed: invalid or expired token" — because
those proxy calls were using the pod's DEFAULT McpdClient token,
which in a container with no ~/.mcpctl/credentials is the empty
string. The discovery GET was correct (explicit authOverride in
forward()), but syncUpstreams() then created McpdUpstream instances
bound to the original mcpdClient — so every tools/list to each
upstream went out with `Authorization: Bearer ` (empty) and mcpd's
auth hook rejected it.

Fix: add McpdClient.withToken(token) and have refreshProjectUpstreams
swap to `mcpdClient.withToken(authToken)` before handing the client to
syncUpstreams. This keeps the "pod has no identity" design: the token
used for downstream /api/v1/mcp/proxy calls is the caller's McpToken,
same as the one used for the initial discovery GET and for introspect.

Tested: project-discovery.test.ts + mcpd-upstream.test.ts pass. Next:
rebuild + roll the mcplocal image and retry LiteLLM probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 03:06:55 +01:00

92 lines
3.4 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { refreshProjectUpstreams } from '../src/discovery.js';
import { McpRouter } from '../src/router.js';
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
const client = {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => servers),
post: vi.fn(async () => ({ result: {} })),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: servers })),
withTimeout: vi.fn(() => client),
withHeaders: vi.fn(() => client),
withToken: vi.fn(() => client),
};
return client;
}
describe('refreshProjectUpstreams', () => {
it('registers project-scoped servers as upstreams', async () => {
const router = new McpRouter();
const client = mockMcpdClient([
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
{ id: 'srv-2', name: 'ha', transport: 'stdio' },
]);
const registered = await refreshProjectUpstreams(router, client as any, 'smart-home');
expect(registered).toEqual(['grafana', 'ha']);
expect(router.getUpstreamNames()).toContain('grafana');
expect(router.getUpstreamNames()).toContain('ha');
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers');
});
it('removes stale upstreams on refresh', async () => {
const router = new McpRouter();
// First refresh: 2 servers
const client1 = mockMcpdClient([
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
{ id: 'srv-2', name: 'ha', transport: 'stdio' },
]);
await refreshProjectUpstreams(router, client1 as any, 'smart-home');
expect(router.getUpstreamNames()).toHaveLength(2);
// Second refresh: only 1 server
const client2 = mockMcpdClient([
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
]);
await refreshProjectUpstreams(router, client2 as any, 'smart-home');
expect(router.getUpstreamNames()).toEqual(['grafana']);
});
it('forwards auth token via forward() method', async () => {
const router = new McpRouter();
const servers = [{ id: 'srv-1', name: 'grafana', transport: 'stdio' }];
const client = mockMcpdClient(servers);
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined, 'user-token-123');
expect(router.getUpstreamNames()).toContain('grafana');
});
it('throws on failed project fetch', async () => {
const router = new McpRouter();
const client = mockMcpdClient([]);
client.forward.mockResolvedValue({ status: 403, body: { error: 'Forbidden' } });
await expect(
refreshProjectUpstreams(router, client as any, 'secret-project', 'bad-token'),
).rejects.toThrow('Failed to fetch project servers: 403');
});
it('URL-encodes project name', async () => {
const router = new McpRouter();
const client = mockMcpdClient([]);
await refreshProjectUpstreams(router, client as any, 'my project');
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my%20project/servers');
});
it('handles empty project server list', async () => {
const router = new McpRouter();
const client = mockMcpdClient([]);
const registered = await refreshProjectUpstreams(router, client as any, 'empty-project');
expect(registered).toEqual([]);
expect(router.getUpstreamNames()).toHaveLength(0);
});
});