History

Stream local MCP responses so native elicitation reaches the client (#1556)

* e2e: local native MCP elicitation repro for #1555 * Stream local MCP responses so native elicitation reaches the client Fixes #1555.

Rhys Sullivan committed 3 months ago

Commit f674fb81 parent b0296433 files · +144 −1Browse files

Unified · Split

3 files
  1. .changeset/local-native-elicitation-streaming.md +7 −0
  2. apps/local/src/mcp.ts +9 −1
  3. e2e/local/mcp-native-elicitation.test.ts +128 −0
.changeset/local-native-elicitation-streaming.md+7−0
@@ -0,0 +1,7 @@
1---
2"executor": patch
3---
4
5**Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out**
6
7The local daemon's Streamable HTTP transport ran with `enableJsonResponse: true`, which buffers a `tools/call` into a single JSON body and leaves no open stream for the server to write on. A server-to-client `elicitation/create` raised during that call was therefore never delivered, and approval-gated tools failed with a `-32001` request timeout even though the session had negotiated `elicitation_mode=native` and the client's `elicitation.form` capability. The transport now uses the spec-default SSE streaming, so the reverse request rides the originating tool call's stream — matching the Cloudflare host's behaviour.
apps/local/src/mcp.ts+9−1
@@ -184,8 +184,16 @@ export const createMcpRequestHandler = ( · 183 unmodified lines
184 let createdSessionId: string | null = null;184 let createdSessionId: string | null = null;
185 let resourceConfig: LocalMcpServerConfig | null = null;185 let resourceConfig: LocalMcpServerConfig | null = null;
186 const transport = new WebStandardStreamableHTTPServerTransport({186 const transport = new WebStandardStreamableHTTPServerTransport({
187 // SSE streaming (the spec default), NOT `enableJsonResponse: true`.
188 // JSON mode holds the POST open as a bare Promise and resolves it with
189 // one buffered body once every response is ready, so the transport has
190 // no open stream to write on: a server-to-client `elicitation/create`
191 // issued DURING a `tools/call` is dropped, and native elicitation dies
192 // on the client's request timeout (#1555). Streaming keeps the tool
193 // call's own stream writable in both directions, which is what native
194 // elicitation rides. Clients must already accept `text/event-stream`
195 // (the transport rejects a POST otherwise), so this costs nothing.
187 sessionIdGenerator: () => crypto.randomUUID(),196 sessionIdGenerator: () => crypto.randomUUID(),
188 enableJsonResponse: true,
189 onsessioninitialized: (sid) => {197 onsessioninitialized: (sid) => {
190 createdSessionId = sid;198 createdSessionId = sid;
191 transports.set(sid, transport);199 transports.set(sid, transport);
e2e/local/mcp-native-elicitation.test.ts+128−0
@@ -0,0 +1,128 @@
1// Repro for #1555: "Local HTTP MCP native elicitation times out before reaching
2// client". The Cloudflare counterpart (cloudflare/mcp-native-elicitation.test.ts,
3// green since #1522) proves the reverse request routes on the originating
4// tools/call stream there. This is the same scenario against the LOCAL daemon's
5// HTTP MCP endpoint, which the report says never delivers `elicitation/create`
6// and fails with McpNativeElicitationTransportError / -32001 request timed out.
7//
8// Same shape as the local browser-approval scenario (a require_approval policy
9// on a built-in read tool), but with `elicitation_mode=native` and a client that
10// advertises `capabilities.elicitation.form` and answers the request in-band —
11// so no browser is needed, only the raw SDK transport with the bearer.
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { HttpApiClient } from "effect/unstable/httpapi";
15import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
16import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19import { composePluginApi } from "@executor-js/api/server";
20
21import { scenario } from "../src/scenario";
22import { Cli, RunDir } from "../src/services";
23import { withLocalServer } from "./local-server";
24
25const coreApi = composePluginApi([] as const);
26
27const GATED_TOOL = "executor.coreTools.policies.list";
28const GATED_CODE = `
29const result = await tools.executor.coreTools.policies.list({});
30return JSON.stringify(result);
31`;
32
33scenario(
34 "Local · native MCP elicitation carries approval decisions on the tool call stream",
35 { timeout: 300_000 },
36 Effect.gen(function* () {
37 const cli = yield* Cli;
38 const runDir = yield* RunDir;
39
40 yield* withLocalServer(cli, runDir, (server) =>
41 Effect.gen(function* () {
42 // Bearer-authed typed API client (local's credential is the printed
43 // token) — used only to plant the gating policy.
44 const api = yield* HttpApiClient.make(coreApi, {
45 baseUrl: new URL("/api", server.origin).toString(),
46 transformClient: HttpClient.mapRequest((request) =>
47 HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
48 ),
49 }).pipe(Effect.provide(FetchHttpClient.layer));
50
51 const policy = yield* api.policies.create({
52 payload: { owner: "org", pattern: GATED_TOOL, action: "require_approval" },
53 });
54
55 yield* Effect.gen(function* () {
56 let decision: "accept" | "decline" | "cancel" = "accept";
57 let elicitationCount = 0;
58 const client = yield* Effect.acquireRelease(
59 Effect.promise(async () => {
60 const connectedClient = new Client(
61 { name: "e2e-local-native-elicitation", version: "1.0.0" },
62 { capabilities: { elicitation: { form: {}, url: {} } } },
63 );
64 connectedClient.setRequestHandler(ElicitRequestSchema, async () => {
65 elicitationCount += 1;
66 return decision === "accept"
67 ? { action: "accept" as const, content: {} }
68 : { action: decision };
69 });
70 const url = new URL(`${server.origin}/mcp`);
71 url.searchParams.set("elicitation_mode", "native");
72 url.searchParams.set("artifacts", "false");
73 await connectedClient.connect(
74 new StreamableHTTPClientTransport(url, {
75 requestInit: { headers: { authorization: `Bearer ${server.token}` } },
76 }),
77 );
78 return connectedClient;
79 }),
80 (connectedClient) => Effect.promise(() => connectedClient.close()),
81 );
82
83 const accepted = yield* Effect.promise(() =>
84 client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
85 timeout: 30_000,
86 }),
87 );
88 expect(elicitationCount, "the native elicitation reached the client").toBe(1);
89 expect(accepted.isError, "accepting lets the gated tool complete").toBeFalsy();
90 expect(
91 JSON.stringify(accepted.content),
92 "the gated tool returned its policy listing",
93 ).toContain(policy.id);
94
95 decision = "decline";
96 const declined = yield* Effect.promise(() =>
97 client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
98 timeout: 30_000,
99 }),
100 );
101 expect(elicitationCount, "the second native elicitation also reached the client").toBe(2);
102 expect(declined.isError, "declining blocks the gated tool").toBe(true);
103 expect(
104 JSON.stringify(declined.content),
105 "the engine reports the client's decline decision",
106 ).toContain("declined by the user");
107 expect(
108 JSON.stringify(declined.content),
109 "the declined tool did not return its output",
110 ).not.toContain(policy.id);
111
112 decision = "cancel";
113 const cancelled = yield* Effect.promise(() =>
114 client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
115 timeout: 30_000,
116 }),
117 );
118 expect(elicitationCount, "the cancel elicitation also reached the client").toBe(3);
119 expect(cancelled.isError, "cancelling blocks the gated tool").toBe(true);
120 expect(
121 JSON.stringify(cancelled.content),
122 "the engine reports the client's cancel decision",
123 ).toContain("cancelled by the user");
124 }).pipe(Effect.scoped);
125 }),
126 );
127 }),
128);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t