feat: McpToken — HTTP-mode mcplocal, CLI verbs, audit plumbing #50

Merged
michal merged 12 commits from feat/mcptoken into main 2026-04-18 16:37:53 +00:00
2 changed files with 18 additions and 4 deletions
Showing only changes of commit 75fe0533c1 - Show all commits

View File

@@ -62,21 +62,31 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
return existing.router; 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 // Create new router or refresh existing one
const router = existing?.router ?? new McpRouter(); const router = existing?.router ?? new McpRouter();
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken); await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
// Resolve project LLM model: local override → mcpd recommendation → global default // Resolve project LLM model: local override → mcpd recommendation → global default
const localOverride = loadProjectLlmOverride(projectName); const localOverride = loadProjectLlmOverride(projectName);
const mcpdConfig = await fetchProjectLlmConfig(mcpdClient, projectName); const mcpdConfig = await fetchProjectLlmConfig(requestClient, projectName);
const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined; const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined;
// If project llmProvider is "none", disable LLM for this project // If project llmProvider is "none", disable LLM for this project
const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none'; const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none';
const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null); const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null);
// Configure prompt resources with SA-scoped client for RBAC // Configure prompt resources with SA-scoped client for RBAC.
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` }); // 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); router.setPromptConfig(saClient, projectName);
// System prompt fetcher for LLM consumers (uses router's cached fetcher) // System prompt fetcher for LLM consumers (uses router's cached fetcher)

View File

@@ -30,9 +30,13 @@ function mockMcpdClient() {
delete: vi.fn(), delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })), forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(), 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.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; return client;
} }