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+
7+The 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
184184 let createdSessionId: string | null = null;
185185 let resourceConfig: LocalMcpServerConfig | null = null;
186186 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.
187196 sessionIdGenerator: () => crypto.randomUUID(),
188− enableJsonResponse: true,
189197 onsessioninitialized: (sid) => {
190198 createdSessionId = sid;
191199 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.
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { HttpApiClient } from "effect/unstable/httpapi";
15+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
16+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19+import { composePluginApi } from "@executor-js/api/server";
20+
21+import { scenario } from "../src/scenario";
22+import { Cli, RunDir } from "../src/services";
23+import { withLocalServer } from "./local-server";
24+
25+const coreApi = composePluginApi([] as const);
26+
27+const GATED_TOOL = "executor.coreTools.policies.list";
28+const GATED_CODE = `
29+const result = await tools.executor.coreTools.policies.list({});
30+return JSON.stringify(result);
31+`;
32+
33+scenario(
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