Skip to content

Commit 134f753

Browse files
committed
Add e2e tests for mcp approval/rejection
1 parent bb74eb2 commit 134f753

2 files changed

Lines changed: 87 additions & 4 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type * as acp from "@agentclientprotocol/sdk";
2+
import path from "node:path";
3+
import {afterEach, beforeEach, expect, it} from "vitest";
4+
import {ApprovalOptionId} from "../../../ApprovalOptionId";
5+
import {
6+
createAuthenticatedFixture,
7+
createPermissionResponse,
8+
describeE2E,
9+
expectEndTurn,
10+
type PermissionResponder,
11+
type SpawnedAgentFixture,
12+
} from "./acp-e2e-test-utils";
13+
14+
const MCP_SERVER_NAME = "integration-mcp";
15+
const MCP_ECHO_MESSAGE = "mcp approval e2e";
16+
17+
function createMcpServer(): acp.McpServerStdio {
18+
return {
19+
name: MCP_SERVER_NAME,
20+
command: process.execPath,
21+
args: [path.join(process.cwd(), "node_modules/mcp-hello-world/build/stdio.js")],
22+
env: [],
23+
};
24+
}
25+
26+
function isMcpPermissionRequest(request: acp.RequestPermissionRequest): boolean {
27+
return request.toolCall.kind === "execute" && request._meta?.["is_mcp_tool_approval"] === true;
28+
}
29+
30+
function createMcpPermissionResponder(optionId: ApprovalOptionId): PermissionResponder {
31+
return (request) => createPermissionResponse(isMcpPermissionRequest(request) ? optionId : null);
32+
}
33+
34+
describeE2E("E2E MCP approval tests", () => {
35+
let fixture: SpawnedAgentFixture;
36+
let sessionId: string;
37+
38+
beforeEach(async () => {
39+
fixture = await createAuthenticatedFixture();
40+
sessionId = (await fixture.createSession([createMcpServer()])).sessionId;
41+
});
42+
43+
afterEach(async () => {
44+
await fixture.dispose();
45+
});
46+
47+
function expectMcpToolPermissionRequest(): void {
48+
const requests = fixture.readPermissionRequests(sessionId, "execute");
49+
expect(requests.length).toBe(1);
50+
expect(isMcpPermissionRequest(requests[0]!)).toBe(true);
51+
}
52+
53+
it("executes an approved MCP tool call", async () => {
54+
fixture.setPermissionResponder(createMcpPermissionResponder(ApprovalOptionId.AllowOnce));
55+
56+
await fixture.expectPromptText(
57+
sessionId,
58+
`Use the ${MCP_SERVER_NAME} MCP echo tool with message "${MCP_ECHO_MESSAGE}". Reply with exactly the tool result and no extra text.`,
59+
(text) => expect(text).toContain(`You said: ${MCP_ECHO_MESSAGE}`),
60+
);
61+
expectMcpToolPermissionRequest();
62+
});
63+
64+
it("ends turn when MCP tool call is rejected", async () => {
65+
fixture.setPermissionResponder(createMcpPermissionResponder(ApprovalOptionId.RejectOnce));
66+
67+
expectEndTurn(await fixture.connection.prompt({
68+
sessionId,
69+
prompt: [{
70+
type: "text",
71+
text: `Use the ${MCP_SERVER_NAME} MCP echo tool with message "${MCP_ECHO_MESSAGE}". Stop if the tool call is rejected.`,
72+
}],
73+
}));
74+
expectMcpToolPermissionRequest();
75+
});
76+
});

src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface TestSkill {
2121
export interface SpawnedAgentFixture {
2222
readonly connection: acp.ClientSideConnection;
2323
readonly workspaceDir: string;
24-
createSession(): Promise<acp.NewSessionResponse>;
24+
createSession(mcpServers?: acp.McpServer[]): Promise<acp.NewSessionResponse>;
2525
restart(): Promise<SpawnedAgentFixture>;
2626
writeSkill(skill: TestSkill): void;
2727
setPermissionResponder(responder: PermissionResponder): void;
@@ -159,10 +159,10 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
159159
return this.paths.workspaceDir;
160160
}
161161

162-
async createSession(): Promise<acp.NewSessionResponse> {
162+
async createSession(mcpServers: acp.McpServer[] = []): Promise<acp.NewSessionResponse> {
163163
return await this.connection.newSession({
164164
cwd: this.workspaceDir,
165-
mcpServers: [],
165+
mcpServers,
166166
});
167167
}
168168

@@ -263,13 +263,20 @@ function printLogDirectory(logDirectory: string): void {
263263
.filter((entry) => entry.isFile())
264264
.forEach((entry) => {
265265
const logFilePath = path.join(logDirectory, entry.name);
266-
const content = fs.readFileSync(logFilePath, "utf8").trim();
266+
const content = redactLogSecrets(fs.readFileSync(logFilePath, "utf8").trim());
267267
console.log(`[APP_SERVER_LOGS] Logs from ${logFilePath}:`);
268268
console.log(content.length > 0 ? content : "[APP_SERVER_LOGS] Log file is empty");
269269
console.log("------");
270270
});
271271
}
272272

273+
function redactLogSecrets(content: string): string {
274+
return content
275+
.replace(/("apiKey"\s*:\s*")[^"]+(")/g, "$1[REDACTED]$2")
276+
.replace(/("Authorization"\s*:\s*")Bearer [^"]+(")/gi, "$1Bearer [REDACTED]$2")
277+
.replace(/(Incorrect API key provided: )[^.,\s]+/g, "$1[REDACTED]");
278+
}
279+
273280
async function waitForProcessExit(proc: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<boolean> {
274281
if (proc.exitCode !== null || proc.signalCode !== null) {
275282
return true;

0 commit comments

Comments
 (0)