Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/architecture/mcp-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions packages/shared/src/queue/queue-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,84 @@ describe("CodingAgentQueueProcessor.handleRequest redelivery handling", () => {
});
});

describe("CodingAgentQueueProcessor MCP secret hydration", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

async function dispatchMcpServer(
server: Record<string, unknown>,
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: "<secret>" } },
{ 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: "<secret>" } },
{ 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();
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/queue/queue-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,10 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor<RequestDocumen
const hydratedNames: string[] = [];
mcpServerConfigs = await Promise.all(
mcpServerConfigs.map(async (config) => {
// 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) {
Expand Down