diff --git a/docs/architecture/mcp-gateway.md b/docs/architecture/mcp-gateway.md index 1a1cd1ba2..491143c91 100644 --- a/docs/architecture/mcp-gateway.md +++ b/docs/architecture/mcp-gateway.md @@ -40,6 +40,11 @@ Dev Proxy captures tool calls from the **Copilot API response body**. It never s Step 1 is crash recovery — under normal operation step 7 cleans up. Both are needed. +The Scope API includes masked `env` or `headers` only for servers with stored +secrets. At step 3, the queue processor skips Token Manager resolution when both +fields are absent; a secret-less server has no Token Manager record. Resolution +failures for servers with secret metadata still produce a warning. + **Project-scoped resolution.** Steps 2–3 both carry the **run's `projectId`** (`requestDoc.projectId`, threaded through `queue-processor.ts` to `McpServerClient.resolveServers(projectId, slugs)` and `McpSecretClient.resolveSecrets(projectId, diff --git a/packages/shared/src/queue/queue-processor.test.ts b/packages/shared/src/queue/queue-processor.test.ts index 6a7ed97da..58ce52c08 100644 --- a/packages/shared/src/queue/queue-processor.test.ts +++ b/packages/shared/src/queue/queue-processor.test.ts @@ -469,6 +469,84 @@ describe("CodingAgentQueueProcessor.handleRequest redelivery handling", () => { }); }); +describe("CodingAgentQueueProcessor MCP secret hydration", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function dispatchMcpServer( + server: Record, + resolveResponse?: { ok: boolean; status: number; statusText: string; body?: unknown }, + ) { + const qp = new CodingAgentQueueProcessor( + { ...testConfig, apiBaseUrl: "http://api:80", tokenManagerUrl: "http://tokens:3003" }, + stubProcessor, + ); + (qp as any).collection = { updateOne: vi.fn().mockResolvedValue({ matchedCount: 1 }) }; + (qp as any).heartbeatStore = new InMemoryHeartbeatStore(); + (qp as any).processMultiTurn = vi.fn().mockResolvedValue(undefined); + + const fetchMock = vi.fn().mockImplementation(async (url: string) => { + if (url.startsWith("http://api:80")) { + return { ok: true, json: async () => server }; + } + if (!resolveResponse) throw new Error("Unexpected Token Manager request"); + return { ...resolveResponse, json: async () => resolveResponse.body }; + }); + vi.stubGlobal("fetch", fetchMock); + + const requestDoc = { + _id: "request-1", + projectId: "project-1", + workerType: "test-worker", + agentVersion: "test-1.0.0", + mcpServers: ["time"], + run: { _id: "run-1", status: "queued", attemptNumber: 1 }, + } as any; + const log = vi.fn().mockResolvedValue(undefined); + const heartbeat = { popReceipt: "receipt-1" } as VisibilityHeartbeat; + const message = { messageId: "message-1", popReceipt: "receipt-1" } as any; + + await (qp as any).handleRequest(requestDoc, message, heartbeat, log, { runId: "run-1" }); + return { fetchMock, log, processMultiTurn: (qp as any).processMultiTurn }; + } + + it("skips Token Manager and does not warn for a server without secrets", async () => { + const result = await dispatchMcpServer({ + _id: "time", slug: "time", name: "Time", type: "stdio", command: "uvx", + }); + + expect(result.fetchMock).toHaveBeenCalledTimes(1); + expect(result.log).not.toHaveBeenCalledWith("warn", expect.stringContaining("Failed to hydrate"), expect.anything()); + expect(result.processMultiTurn.mock.calls[0][5]).toEqual([ + { slug: "time", name: "Time", type: "stdio", command: "uvx" }, + ]); + }); + + it("hydrates a server with masked secret metadata", async () => { + const result = await dispatchMcpServer( + { _id: "time", slug: "time", name: "Time", type: "stdio", command: "uvx", env: { API_KEY: "" } }, + { ok: true, status: 200, statusText: "OK", body: { env: { API_KEY: "real-secret" } } }, + ); + + expect(result.fetchMock).toHaveBeenCalledTimes(2); + expect(result.processMultiTurn.mock.calls[0][5][0].env).toEqual({ API_KEY: "real-secret" }); + }); + + it("still warns when resolution fails for a server with secrets", async () => { + const result = await dispatchMcpServer( + { _id: "time", slug: "time", name: "Time", type: "stdio", command: "uvx", env: { API_KEY: "" } }, + { ok: false, status: 503, statusText: "Service Unavailable" }, + ); + + expect(result.log).toHaveBeenCalledWith( + "warn", + "Failed to hydrate secrets for MCP server 'Time' (time)", + expect.objectContaining({ error: expect.stringContaining("503") }), + ); + }); +}); + describe("CodingAgentQueueProcessor pre-processing failures", () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts index 2be51bf18..1f20712af 100644 --- a/packages/shared/src/queue/queue-processor.ts +++ b/packages/shared/src/queue/queue-processor.ts @@ -415,6 +415,10 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { + // The API includes masked env/headers only when the server has stored secrets. + // Secret-less servers have no Token Manager record to resolve. + if (!config.env && !config.headers) return config; + try { const resolved = await secretClient.resolveSecrets(requestDoc.projectId, config.slug); if ('env' in resolved && resolved.env && Object.keys(resolved.env).length > 0) {