History

Let Codex plugin approval prompts reach the client

Rhys Sullivan committed 2 months ago

Commit 8fa7ed81 parent 1631dad3 files · +58 −1Browse files

Unified · Split

3 files
  1. packages/plugins/mcp/src/sdk/appserver-connector.test.ts +24 −0
  2. packages/plugins/mcp/src/sdk/appserver-connector.ts +14 −1
  3. packages/plugins/mcp/src/sdk/appserver-test-server.ts +20 −0
packages/plugins/mcp/src/sdk/appserver-connector.test.ts+24−0
@@ -78,6 +78,30 @@ describe("codex app-server bridge", () => { · 77 unmodified lines
78 ),78 ),
79 );79 );
80 80
81 it.effect("starts the thread with an approval policy that lets prompts through", () =>
82 Effect.scoped(
83 Effect.gen(function* () {
84 // Codex declines MCP elicitations ITSELF on a thread whose approval
85 // policy does not allow them — the prompt never reaches the client and
86 // the tool just reports "access was not approved". The fixture only
87 // elicits when the bridge asked for a permitting policy, so reaching
88 // the handler at all is the assertion.
89 const connection = yield* withConnection(appServerInput("messages"));
90 let prompted = false;
91 connection.client.setRequestHandler("elicitation/create", () => {
92 prompted = true;
93 return Promise.resolve({ action: "accept" as const, content: {} });
94 });
95
96 const result = yield* Effect.promise(() =>
97 connection.client.callTool({ name: "needs_approval", arguments: {} }),
98 );
99 expect(prompted, "the plugin's own approval prompt reached the client").toBe(true);
100 expect(result.isError).toBeFalsy();
101 }),
102 ),
103 );
104
81 it.effect("a declined elicitation reaches the app-server as a decline, not an approval", () =>105 it.effect("a declined elicitation reaches the app-server as a decline, not an approval", () =>
82 Effect.scoped(106 Effect.scoped(
83 Effect.gen(function* () {107 Effect.gen(function* () {
packages/plugins/mcp/src/sdk/appserver-connector.ts+14−1
@@ -17,6 +17,8 @@ · 16 unmodified lines
17//17//
18// MCP upstream app-server downstream18// MCP upstream app-server downstream
19// initialize → initialize → initialized → thread/start19// initialize → initialize → initialized → thread/start
20// (with an approval policy that lets the
21// plugin's own prompts reach the client)
20// tools/list → mcpServerStatus/list (one server's tools)22// tools/list → mcpServerStatus/list (one server's tools)
21// tools/call → mcpServer/tool/call23// tools/call → mcpServer/tool/call
22// elicitation/create (to client) ← mcpServer/elicitation/request24// elicitation/create (to client) ← mcpServer/elicitation/request
@@ -277,7 +279,18 @@ class AppServerClientTransport implements Transport { · 254 unmodified lines
277 return;279 return;
278 }280 }
279 this.#sendDownstream({ jsonrpc: "2.0", method: "initialized" });281 this.#sendDownstream({ jsonrpc: "2.0", method: "initialized" });
280 const started = await this.#request("thread/start", { sessionStartSource: "startup" });282 // `approvalPolicy` is load-bearing, not a default worth inheriting: on a
283 // thread whose policy is `never` (or a granular one without
284 // `mcpElicitations`) Codex DECLINES every MCP elicitation itself and never
285 // forwards it, which surfaces as an unexplained "access was not approved"
286 // on any tool that asks — `read_messages` above all. `on-request` is the
287 // policy that routes the plugin's own approval prompt to the client, where
288 // executor's elicitation bridge answers it. Nothing else can escalate here:
289 // this thread runs no turns, so there is no shell or exec approval to ask.
290 const started = await this.#request("thread/start", {
291 sessionStartSource: "startup",
292 approvalPolicy: "on-request",
293 });
281 if (!started.ok) {294 if (!started.ok) {
282 this.#fail(message.id, started.error);295 this.#fail(message.id, started.error);
283 return;296 return;
packages/plugins/mcp/src/sdk/appserver-test-server.ts+20−0
@@ -29,6 +29,10 @@ const decodeInitializeParams = Schema.decodeUnknownOption( · 28 unmodified lines
29 Schema.Struct({ clientInfo: Schema.Struct({ name: Schema.String }) }),29 Schema.Struct({ clientInfo: Schema.Struct({ name: Schema.String }) }),
30);30);
31 31
32const decodeThreadStartParams = Schema.decodeUnknownOption(
33 Schema.Struct({ approvalPolicy: Schema.optional(Schema.String) }),
34);
35
32const decodeThreadParams = Schema.decodeUnknownOption(36const decodeThreadParams = Schema.decodeUnknownOption(
33 Schema.Struct({37 Schema.Struct({
34 threadId: Schema.optional(Schema.String),38 threadId: Schema.optional(Schema.String),
@@ -77,6 +81,7 @@ const replyError = (id: number | string, code: number, message: string): void => · 42 unmodified lines
77};81};
78 82
79let initializedSeen = false;83let initializedSeen = false;
84let elicitationsAllowed = false;
80let nextServerRequestId = 1000;85let nextServerRequestId = 1000;
81/** Elicitation request id → the pending tool call's request id. */86/** Elicitation request id → the pending tool call's request id. */
82const pendingApprovals = new Map<number | string, number | string>();87const pendingApprovals = new Map<number | string, number | string>();
@@ -134,6 +139,15 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 51 unmodified lines
134 return;139 return;
135 }140 }
136 if (call.tool === "needs_approval") {141 if (call.tool === "needs_approval") {
142 if (!elicitationsAllowed) {
143 // Exactly what Codex returns when it declines the elicitation for the
144 // client: an error result, no prompt, no explanation.
145 reply(id, {
146 content: [{ type: "text", text: "access was not approved" }],
147 isError: true,
148 });
149 return;
150 }
137 const elicitationId = nextServerRequestId++;151 const elicitationId = nextServerRequestId++;
138 pendingApprovals.set(elicitationId, id);152 pendingApprovals.set(elicitationId, id);
139 write({153 write({
@@ -200,6 +214,12 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { · 60 unmodified lines
200 replyError(message.id, -32600, "thread/start before the initialized notification");214 replyError(message.id, -32600, "thread/start before the initialized notification");
201 return;215 return;
202 }216 }
217 // Codex DECLINES every MCP elicitation itself on a thread whose approval
218 // policy does not allow them, so a thread started without one can never
219 // receive an approval prompt. The fixture models that: it only elicits
220 // when the bridge asked for a policy that permits elicitations.
221 const params = Option.getOrUndefined(decodeThreadStartParams(message.params));
222 elicitationsAllowed = params?.approvalPolicy === "on-request";
203 reply(message.id, { thread: { id: THREAD_ID } });223 reply(message.id, { thread: { id: THREAD_ID } });
204 return;224 return;
205 }225 }