128 lines · 5.7 KB · TypeScript
RawDownload
// 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);      }),    );  }),);