From 5d1072889f22778d01444b2268e4db2d3155b4a0 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 18 Apr 2026 03:06:55 +0100 Subject: [PATCH] fix(mcplocal): thread client bearer into per-upstream McpdClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/mcplocal/src/discovery.ts | 8 +++++++- src/mcplocal/src/http/mcpd-client.ts | 10 ++++++++++ src/mcplocal/tests/project-discovery.test.ts | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index 2eb99b1..89ee424 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -46,7 +46,13 @@ export async function refreshProjectUpstreams( servers = await mcpdClient.get(path); } - return syncUpstreams(router, mcpdClient, servers); + // Downstream upstream-proxy calls go through `mcpdClient` too. In HTTP-mode + // mcplocal the pod has no credentials of its own, so the default token on + // `mcpdClient` is an empty string — every /api/v1/mcp/proxy call would 401. + // Bind a per-request client with the caller's bearer so each McpdUpstream + // forwards the same identity that passed project discovery. + const upstreamClient = authToken ? mcpdClient.withToken(authToken) : mcpdClient; + return syncUpstreams(router, upstreamClient, servers); } /** diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index b4541a3..53511e5 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -60,6 +60,16 @@ export class McpdClient { return new McpdClient(this.baseUrl, this.token, { ...this.extraHeaders }, timeoutMs); } + /** + * Create a new client with a different Bearer token. The HTTP-mode mcplocal + * pod has no credentials of its own — each incoming client request carries + * its McpToken, and this method is how we thread that token through to the + * McpdUpstream instances created during project discovery. + */ + withToken(token: string): McpdClient { + return new McpdClient(this.baseUrl, token, { ...this.extraHeaders }, this.timeoutMs); + } + async get(path: string): Promise { return this.request('GET', path); } diff --git a/src/mcplocal/tests/project-discovery.test.ts b/src/mcplocal/tests/project-discovery.test.ts index a9b506c..0348d4a 100644 --- a/src/mcplocal/tests/project-discovery.test.ts +++ b/src/mcplocal/tests/project-discovery.test.ts @@ -13,6 +13,7 @@ function mockMcpdClient(servers: Array<{ id: string; name: string; transport: st forward: vi.fn(async () => ({ status: 200, body: servers })), withTimeout: vi.fn(() => client), withHeaders: vi.fn(() => client), + withToken: vi.fn(() => client), }; return client; }