Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/local-native-elicitation-streaming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"executor": patch
---

**Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out**

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.
10 changes: 9 additions & 1 deletion apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,16 @@ export const createMcpRequestHandler = (
let createdSessionId: string | null = null;
let resourceConfig: LocalMcpServerConfig | null = null;
const transport = new WebStandardStreamableHTTPServerTransport({
// SSE streaming (the spec default), NOT `enableJsonResponse: true`.
// JSON mode holds the POST open as a bare Promise and resolves it with
// one buffered body once every response is ready, so the transport has
// no open stream to write on: a server-to-client `elicitation/create`
// issued DURING a `tools/call` is dropped, and native elicitation dies
// on the client's request timeout (#1555). Streaming keeps the tool
// call's own stream writable in both directions, which is what native
// elicitation rides. Clients must already accept `text/event-stream`
// (the transport rejects a POST otherwise), so this costs nothing.
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
onsessioninitialized: (sid) => {
createdSessionId = sid;
transports.set(sid, transport);
Expand Down
128 changes: 128 additions & 0 deletions e2e/local/mcp-native-elicitation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Repro for #1555: "Local HTTP MCP native elicitation times out before reaching
// client". The Cloudflare counterpart (cloudflare/mcp-native-elicitation.test.ts,
// green since #1522) proves the reverse request routes on the originating
// tools/call stream there. This is the same scenario against the LOCAL daemon's
// HTTP MCP endpoint, which the report says never delivers `elicitation/create`
// and fails with McpNativeElicitationTransportError / -32001 request timed out.
//
// Same shape as the local browser-approval scenario (a require_approval policy
// on a built-in read tool), but with `elicitation_mode=native` and a client that
// advertises `capabilities.elicitation.form` and answers the request in-band —
// so no browser is needed, only the raw SDK transport with the bearer.
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { HttpApiClient } from "effect/unstable/httpapi";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { composePluginApi } from "@executor-js/api/server";

import { scenario } from "../src/scenario";
import { Cli, RunDir } from "../src/services";
import { withLocalServer } from "./local-server";

const coreApi = composePluginApi([] as const);

const GATED_TOOL = "executor.coreTools.policies.list";
const GATED_CODE = `
const result = await tools.executor.coreTools.policies.list({});
return JSON.stringify(result);
`;

scenario(
"Local · native MCP elicitation carries approval decisions on the tool call stream",
{ timeout: 300_000 },
Effect.gen(function* () {
const cli = yield* Cli;
const runDir = yield* RunDir;

yield* withLocalServer(cli, runDir, (server) =>
Effect.gen(function* () {
// Bearer-authed typed API client (local's credential is the printed
// token) — used only to plant the gating policy.
const api = yield* HttpApiClient.make(coreApi, {
baseUrl: new URL("/api", server.origin).toString(),
transformClient: HttpClient.mapRequest((request) =>
HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
),
}).pipe(Effect.provide(FetchHttpClient.layer));

const policy = yield* api.policies.create({
payload: { owner: "org", pattern: GATED_TOOL, action: "require_approval" },
});

yield* Effect.gen(function* () {
let decision: "accept" | "decline" | "cancel" = "accept";
let elicitationCount = 0;
const client = yield* Effect.acquireRelease(
Effect.promise(async () => {
const connectedClient = new Client(
{ name: "e2e-local-native-elicitation", version: "1.0.0" },
{ capabilities: { elicitation: { form: {}, url: {} } } },
);
connectedClient.setRequestHandler(ElicitRequestSchema, async () => {
elicitationCount += 1;
return decision === "accept"
? { action: "accept" as const, content: {} }
: { action: decision };
});
const url = new URL(`${server.origin}/mcp`);
url.searchParams.set("elicitation_mode", "native");
url.searchParams.set("artifacts", "false");
await connectedClient.connect(
new StreamableHTTPClientTransport(url, {
requestInit: { headers: { authorization: `Bearer ${server.token}` } },
}),
);
return connectedClient;
}),
(connectedClient) => Effect.promise(() => connectedClient.close()),
);

const accepted = yield* Effect.promise(() =>
client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
timeout: 30_000,
}),
);
expect(elicitationCount, "the native elicitation reached the client").toBe(1);
expect(accepted.isError, "accepting lets the gated tool complete").toBeFalsy();
expect(
JSON.stringify(accepted.content),
"the gated tool returned its policy listing",
).toContain(policy.id);

decision = "decline";
const declined = yield* Effect.promise(() =>
client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
timeout: 30_000,
}),
);
expect(elicitationCount, "the second native elicitation also reached the client").toBe(2);
expect(declined.isError, "declining blocks the gated tool").toBe(true);
expect(
JSON.stringify(declined.content),
"the engine reports the client's decline decision",
).toContain("declined by the user");
expect(
JSON.stringify(declined.content),
"the declined tool did not return its output",
).not.toContain(policy.id);

decision = "cancel";
const cancelled = yield* Effect.promise(() =>
client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
timeout: 30_000,
}),
);
expect(elicitationCount, "the cancel elicitation also reached the client").toBe(3);
expect(cancelled.isError, "cancelling blocks the gated tool").toBe(true);
expect(
JSON.stringify(cancelled.content),
"the engine reports the client's cancel decision",
).toContain("cancelled by the user");
}).pipe(Effect.scoped);
}),
);
}),
);
Loading