// 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); }), ); }),);
// 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);
}),
);
}),
);