Compare commits

...

3 Commits

Author SHA1 Message Date
Michal
75fe0533c1 fix(mcplocal): propagate caller's bearer to prompt-index and LLM-config calls
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>
2026-04-18 04:44:27 +01:00
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
Michal
dfc53cd15e fix(mcpd): per-route /api/v1/mcp/proxy auth missed McpToken dispatch
Symptom: LiteLLM → mcplocal → mcpd proxy calls for project-scoped MCP
tool discovery all 401'd with "Authentication failed: invalid or
expired token", even though the same mcpctl_pat_ bearer works against
/api/v1/mcptokens/introspect and /api/v1/projects/:name/servers. Result:
the new k8s mcplocal pod could accept the bearer and respond to
/projects/:name/mcp (initialize was 200), but every downstream upstream
discovery call through /api/v1/mcp/proxy failed.

Root cause: registerMcpProxyRoutes installs its own route-scoped
createAuthMiddleware with the `authDeps` parameter it receives. In
main.ts that was being constructed with only `findSession` — missing
the `findMcpToken` that the GLOBAL auth hook already had. So a
mcpctl_pat_ bearer got all the way to the proxy route and then was
handed to an old-shape middleware that knew nothing about the prefix.

Fix: extract authDeps (findSession + findMcpToken) to a named const
and reuse it for both the global hook and the proxy route. Comment at
the declaration site warns future additions to keep the two paths in
sync — they have to agree or McpToken bearers silently break on
whichever one drifts.

Verified against the live cluster: LiteLLM's discoverTools path no
longer 401s; mcplocal logs now show successful upstream proxy calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:23:44 +01:00
6 changed files with 46 additions and 11 deletions

View File

@@ -315,10 +315,13 @@ async function main(): Promise<void> {
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
// Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({
findSession: (token) => authService.findSession(token),
findMcpToken: async (tokenHash) => {
// Shared auth dependencies. Both the global auth hook and the per-route
// preHandler on /api/v1/mcp/proxy must know how to resolve both session
// bearers AND mcpctl_pat_ bearers, or mcplocal→mcpd proxy calls with a
// McpToken will 401 at the route layer even though the global hook accepts them.
const authDeps = {
findSession: (token: string) => authService.findSession(token),
findMcpToken: async (tokenHash: string) => {
const row = await mcpTokenRepo.findByHash(tokenHash);
if (row === null) return null;
return {
@@ -332,7 +335,8 @@ async function main(): Promise<void> {
revokedAt: row.revokedAt,
};
},
});
};
const authMiddleware = createAuthMiddleware(authDeps);
// Server
const app = await createServer(config, {
@@ -436,7 +440,7 @@ async function main(): Promise<void> {
registerMcpProxyRoutes(app, {
mcpProxyService,
auditLogService,
authDeps: { findSession: (token) => authService.findSession(token) },
authDeps,
});
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);

View File

@@ -46,7 +46,13 @@ export async function refreshProjectUpstreams(
servers = await mcpdClient.get<McpdServer[]>(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);
}
/**

View File

@@ -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<T>(path: string): Promise<T> {
return this.request<T>('GET', path);
}

View File

@@ -62,21 +62,31 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
return existing.router;
}
// HTTP-mode mcplocal has no pod-level credentials — the default
// `mcpdClient.token` is an empty string. Every downstream call from this
// request (upstream discovery, LLM config fetch, prompt index for
// begin_session) has to use the CALLER's McpToken as the bearer, or mcpd
// rejects with 401. Build one per-request client here and thread it
// everywhere instead of sprinkling `.withToken(authToken)` at each call site.
const requestClient = authToken ? mcpdClient.withToken(authToken) : mcpdClient;
// Create new router or refresh existing one
const router = existing?.router ?? new McpRouter();
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
// Resolve project LLM model: local override → mcpd recommendation → global default
const localOverride = loadProjectLlmOverride(projectName);
const mcpdConfig = await fetchProjectLlmConfig(mcpdClient, projectName);
const mcpdConfig = await fetchProjectLlmConfig(requestClient, projectName);
const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined;
// If project llmProvider is "none", disable LLM for this project
const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none';
const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null);
// Configure prompt resources with SA-scoped client for RBAC
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
// Configure prompt resources with SA-scoped client for RBAC.
// Keep the X-Service-Account header for mcpd-side audit tagging, but carry
// the caller's bearer so auth passes (the principal resolves as McpToken:<sha>).
const saClient = requestClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
router.setPromptConfig(saClient, projectName);
// System prompt fetcher for LLM consumers (uses router's cached fetcher)

View File

@@ -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;
}

View File

@@ -30,9 +30,13 @@ function mockMcpdClient() {
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
withToken: vi.fn(),
withTimeout: vi.fn(),
};
// withHeaders returns a new client-like object (returns self for simplicity)
// 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;
}