| @@ -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 | ); |