| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import type * as McpProtocol from "effect/ai/McpProtocol" |
| 3 | import * as McpSchema from "effect/ai/McpSchema" |
| 4 | import * as McpServer from "effect/ai/McpServer" |
| 5 | import * as Tool from "effect/ai/Tool" |
| 6 | import * as Toolkit from "effect/ai/Toolkit" |
| 7 | import * as Cause from "effect/Cause" |
| 8 | import * as Effect from "effect/Effect" |
| 9 | import * as Exit from "effect/Exit" |
| 10 | import * as Layer from "effect/Layer" |
| 11 | import * as Schema from "effect/Schema" |
| 12 | import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts" |
| 13 | import { makeMcpSseReader, readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts" |
| 14 | import { makeServerLayer } from "../TestUtils/McpServerLayer.ts" |
| 15 | import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" |
| 16 | import type { McpTestPeer } from "./McpTestPeer.ts" |
| 17 | |
| 18 | const ElicitationRequest = Schema.Struct({ |
| 19 | message: Schema.String, |
| 20 | requestedSchema: Schema.Record(Schema.String, Schema.Unknown) |
| 21 | }) |
| 22 | |
| 23 | const decodeElicitationRequest = Schema.decodeUnknownEffect(ElicitationRequest) |
| 24 | |
| 25 | const request = { |
| 26 | message: "Please provide your profile", |
| 27 | requestedSchema: { |
| 28 | type: "object", |
| 29 | properties: { |
| 30 | name: { |
| 31 | type: "string", |
| 32 | title: "Name" |
| 33 | }, |
| 34 | age: { |
| 35 | type: "integer", |
| 36 | minimum: 0 |
| 37 | }, |
| 38 | subscribed: { |
| 39 | type: "boolean", |
| 40 | default: false |
| 41 | } |
| 42 | }, |
| 43 | required: ["name"] |
| 44 | } |
| 45 | } as const |
| 46 | |
| 47 | const runElicitation = <S extends Schema.ConstraintEncoder<Record<string, unknown>, unknown>>( |
| 48 | client: McpTestPeer["reverseClient"], |
| 49 | protocolVersion: McpProtocol.StatefulProtocolVersion, |
| 50 | schema: S |
| 51 | ) => |
| 52 | McpServer.elicit({ |
| 53 | message: request.message, |
| 54 | schema |
| 55 | }).pipe( |
| 56 | Effect.provideService( |
| 57 | McpSchema.McpServerClient, |
| 58 | McpSchema.McpServerClient.of({ |
| 59 | clientId: 1, |
| 60 | protocolVersion, |
| 61 | clientCapabilities: { elicitation: {} }, |
| 62 | clientInfo: { |
| 63 | name: "McpConformancePeer", |
| 64 | version: "1.0.0" |
| 65 | }, |
| 66 | initializePayload: { |
| 67 | protocolVersion, |
| 68 | capabilities: { elicitation: {} }, |
| 69 | clientInfo: { |
| 70 | name: "McpConformancePeer", |
| 71 | version: "1.0.0" |
| 72 | } |
| 73 | }, |
| 74 | getClient: Effect.succeed(client) |
| 75 | }) |
| 76 | ) |
| 77 | ) |
| 78 | |
| 79 | export const suite = ( |
| 80 | protocol: McpProtocol.ProtocolAdapter<McpProtocol.StatefulProtocolVersion>, |
| 81 | layer: McpConformanceLayer |
| 82 | ) => |
| 83 | it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { |
| 84 | describe("Elicitation", () => { |
| 85 | for (const responseSession of ["missing", "unknown", "unrelated", "owner"] as const) { |
| 86 | // https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management |
| 87 | // A reverse response must belong to the session that requested the input. |
| 88 | it.effect(`MUST preserve elicitation response ownership with a ${responseSession} HTTP session`, () => |
| 89 | Effect.gen(function*() { |
| 90 | const toolkit = Toolkit.make(Tool.make("Approve", { |
| 91 | parameters: Tool.EmptyParams, |
| 92 | success: Schema.Struct({ approved: Schema.Boolean }), |
| 93 | dependencies: [McpSchema.McpServerClient] |
| 94 | })) |
| 95 | const registration = McpServer.toolkit(toolkit).pipe(Layer.provide( |
| 96 | toolkit.toLayer({ |
| 97 | Approve: () => |
| 98 | McpServer.elicit({ |
| 99 | message: "Approve this operation", |
| 100 | schema: Schema.Struct({ approved: Schema.Boolean }) |
| 101 | }).pipe(Effect.orDie) |
| 102 | }) |
| 103 | )) |
| 104 | const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge( |
| 105 | makeServerLayer({ name: "ElicitationSessionConformance", protocols: [protocol] }) |
| 106 | ))) |
| 107 | const initialize = Effect.fnUntraced(function*() { |
| 108 | const response = yield* harness.post({ |
| 109 | jsonrpc: "2.0", |
| 110 | id: "initialize", |
| 111 | method: "initialize", |
| 112 | params: { |
| 113 | protocolVersion: protocol.protocolVersion, |
| 114 | capabilities: { elicitation: {} }, |
| 115 | clientInfo: { name: "approval-client", version: "1.0.0" } |
| 116 | } |
| 117 | }) |
| 118 | yield* readMcpHttpResponse(response) |
| 119 | const sessionId = response.headers.get("Mcp-Session-Id") |
| 120 | assert.isNotNull(sessionId) |
| 121 | yield* harness.post({ jsonrpc: "2.0", method: "notifications/initialized" }, { |
| 122 | "Mcp-Session-Id": sessionId, |
| 123 | "Mcp-Protocol-Version": protocol.protocolVersion |
| 124 | }) |
| 125 | return sessionId |
| 126 | }) |
| 127 | const owner = yield* initialize() |
| 128 | const other = responseSession === "unrelated" ? yield* initialize() : undefined |
| 129 | const response = yield* harness.post({ |
| 130 | jsonrpc: "2.0", |
| 131 | id: "approval-tool", |
| 132 | method: "tools/call", |
| 133 | params: { name: "Approve", arguments: {} } |
| 134 | }, { "Mcp-Session-Id": owner, "Mcp-Protocol-Version": protocol.protocolVersion }) |
| 135 | const stream = makeMcpSseReader(response) |
| 136 | yield* Effect.addFinalizer(() => stream.cancel) |
| 137 | const reverse = yield* stream.take() |
| 138 | assert.strictEqual(reverse.method, "elicitation/create") |
| 139 | assert.isDefined(reverse.id) |
| 140 | const sessionId = responseSession === "owner" ? owner : responseSession === "unknown" ? "unknown" : other |
| 141 | const injected = yield* harness.post( |
| 142 | { |
| 143 | jsonrpc: "2.0", |
| 144 | id: reverse.id, |
| 145 | result: { action: "accept", content: { approved: true } } |
| 146 | }, |
| 147 | sessionId === undefined ? {} : { |
| 148 | "Mcp-Session-Id": sessionId, |
| 149 | "Mcp-Protocol-Version": protocol.protocolVersion |
| 150 | } |
| 151 | ) |
| 152 | if (responseSession !== "owner") { |
| 153 | const legitimate = yield* harness.post({ |
| 154 | jsonrpc: "2.0", |
| 155 | id: reverse.id, |
| 156 | result: { action: "accept", content: { approved: false } } |
| 157 | }, { "Mcp-Session-Id": owner, "Mcp-Protocol-Version": protocol.protocolVersion }) |
| 158 | assert.strictEqual(legitimate.status, 202) |
| 159 | } |
| 160 | const completed = yield* stream.take() |
| 161 | assert.strictEqual(completed.id, "approval-tool") |
| 162 | const result = yield* Schema.decodeUnknownEffect(McpSchema.CallToolResult)(completed.result) |
| 163 | assert.deepStrictEqual(result.content, [{ |
| 164 | type: "text", |
| 165 | text: JSON.stringify({ approved: responseSession === "owner" }) |
| 166 | }]) |
| 167 | if (responseSession === "missing") assert.strictEqual(injected.status, 400) |
| 168 | if (responseSession === "unknown") assert.strictEqual(injected.status, 404) |
| 169 | if (responseSession === "owner") assert.strictEqual(injected.status, 202) |
| 170 | })) |
| 171 | } |
| 172 | |
| 173 | // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation |
| 174 | describe("Capabilities", () => { |
| 175 | it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( |
| 176 | "MUST treat an empty elicitation capability as form support", |
| 177 | () => |
| 178 | Effect.gen(function*() { |
| 179 | const test = yield* McpConformance |
| 180 | const peer = yield* test.makePeer({ |
| 181 | capabilities: { elicitation: {} }, |
| 182 | handlers: { |
| 183 | "elicitation/create": () => Effect.succeed({ action: "accept", content: { name: "Ada" } }) |
| 184 | } |
| 185 | }) |
| 186 | |
| 187 | yield* peer.reverseClient.elicit(Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)(request)) |
| 188 | |
| 189 | assert.deepStrictEqual((yield* peer.takeRequest).payload, request) |
| 190 | }).pipe(Effect.scoped) |
| 191 | ) |
| 192 | |
| 193 | it.effect("MUST send elicitation requests when the client advertises elicitation", () => |
| 194 | Effect.gen(function*() { |
| 195 | const test = yield* McpConformance |
| 196 | const peer = yield* test.makePeer({ |
| 197 | capabilities: { elicitation: {} }, |
| 198 | handlers: { |
| 199 | "elicitation/create": () => |
| 200 | Effect.succeed({ |
| 201 | action: "accept", |
| 202 | content: { name: "Ada" } |
| 203 | }) |
| 204 | } |
| 205 | }) |
| 206 | |
| 207 | yield* peer.wireClient["elicitation/create"](request) |
| 208 | |
| 209 | assert.strictEqual((yield* peer.takeRequest).method, "elicitation/create") |
| 210 | })) |
| 211 | |
| 212 | it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( |
| 213 | "MUST gate form and URL modes on their independent nested capabilities", |
| 214 | () => |
| 215 | Effect.gen(function*() { |
| 216 | const test = yield* McpConformance |
| 217 | const formOnly = yield* test.makePeer({ capabilities: { elicitation: { form: {} } } }) |
| 218 | const urlOnly = yield* test.makePeer({ capabilities: { elicitation: { url: {} } } }) |
| 219 | |
| 220 | const formOnUrlOnly = yield* Effect.exit(urlOnly.reverseClient.elicit(request)) |
| 221 | const urlOnFormOnly = yield* Effect.exit(formOnly.reverseClient.elicit({ |
| 222 | mode: "url", |
| 223 | message: "Authorize access", |
| 224 | url: "https://example.com/authorize", |
| 225 | elicitationId: "authorization-1" |
| 226 | })) |
| 227 | |
| 228 | assert.strictEqual(formOnUrlOnly._tag, "Failure") |
| 229 | assert.strictEqual(urlOnFormOnly._tag, "Failure") |
| 230 | assert.deepStrictEqual(yield* formOnly.requests, []) |
| 231 | assert.deepStrictEqual(yield* urlOnly.requests, []) |
| 232 | }) |
| 233 | ) |
| 234 | }) |
| 235 | |
| 236 | describe("Form Mode", () => { |
| 237 | it.effect("MUST send the message and requested primitive form schema", () => |
| 238 | Effect.gen(function*() { |
| 239 | const test = yield* McpConformance |
| 240 | const peer = yield* test.makePeer({ |
| 241 | capabilities: { elicitation: {} }, |
| 242 | handlers: { |
| 243 | "elicitation/create": () => |
| 244 | Effect.succeed({ |
| 245 | action: "accept", |
| 246 | content: { name: "Ada", age: 37, subscribed: true } |
| 247 | }) |
| 248 | } |
| 249 | }) |
| 250 | |
| 251 | const result = yield* peer.wireClient["elicitation/create"](request) |
| 252 | const recorded = yield* peer.takeRequest |
| 253 | const payload = yield* decodeElicitationRequest(recorded.payload) |
| 254 | |
| 255 | assert.strictEqual(payload.message, request.message) |
| 256 | assert.deepStrictEqual(payload.requestedSchema, request.requestedSchema) |
| 257 | assert.strictEqual(result.action, "accept") |
| 258 | if (result.action === "accept") { |
| 259 | assert.deepStrictEqual(result.content, { |
| 260 | name: "Ada", |
| 261 | age: 37, |
| 262 | subscribed: true |
| 263 | }) |
| 264 | } |
| 265 | })) |
| 266 | |
| 267 | it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( |
| 268 | "MUST preserve omitted and explicit form modes", |
| 269 | () => |
| 270 | Effect.gen(function*() { |
| 271 | const test = yield* McpConformance |
| 272 | const peer = yield* test.makePeer({ |
| 273 | capabilities: { elicitation: { form: {} } }, |
| 274 | handlers: { |
| 275 | "elicitation/create": () => Effect.succeed({ action: "accept", content: { name: "Ada" } }) |
| 276 | } |
| 277 | }) |
| 278 | |
| 279 | yield* peer.reverseClient.elicit(Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)(request)) |
| 280 | yield* peer.reverseClient.elicit( |
| 281 | Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)({ ...request, mode: "form" }) |
| 282 | ) |
| 283 | |
| 284 | const requests = yield* peer.requests |
| 285 | assert.deepStrictEqual(requests.map((_) => _.payload), [request, { ...request, mode: "form" }]) |
| 286 | }).pipe(Effect.scoped) |
| 287 | ) |
| 288 | |
| 289 | it.effect("MUST decode accepted content against the requested schema", () => |
| 290 | Effect.gen(function*() { |
| 291 | const test = yield* McpConformance |
| 292 | const peer = yield* test.makePeer({ |
| 293 | capabilities: { elicitation: {} }, |
| 294 | handlers: { |
| 295 | "elicitation/create": () => |
| 296 | Effect.succeed({ |
| 297 | action: "accept", |
| 298 | content: { name: "Ada", age: "37" } |
| 299 | }) |
| 300 | } |
| 301 | }) |
| 302 | |
| 303 | const result = yield* runElicitation( |
| 304 | peer.reverseClient, |
| 305 | protocol.protocolVersion, |
| 306 | Schema.Struct({ |
| 307 | name: Schema.String, |
| 308 | age: Schema.NumberFromString |
| 309 | }) |
| 310 | ) |
| 311 | |
| 312 | assert.deepStrictEqual(result, { name: "Ada", age: 37 }) |
| 313 | })) |
| 314 | |
| 315 | it.effect("MUST reject schemas outside the elicitation subset before sending", () => |
| 316 | Effect.gen(function*() { |
| 317 | const test = yield* McpConformance |
| 318 | const peer = yield* test.makePeer({ capabilities: { elicitation: {} } }) |
| 319 | |
| 320 | const exit = yield* Effect.exit(runElicitation( |
| 321 | peer.reverseClient, |
| 322 | protocol.protocolVersion, |
| 323 | Schema.Struct({ nested: Schema.Struct({ value: Schema.String }) }) |
| 324 | )) |
| 325 | |
| 326 | assert.isTrue(Exit.isFailure(exit)) |
| 327 | if (Exit.isFailure(exit)) { |
| 328 | assert.isTrue(Cause.hasDies(exit.cause)) |
| 329 | } |
| 330 | assert.deepStrictEqual(yield* peer.requests, []) |
| 331 | }).pipe(Effect.scoped)) |
| 332 | |
| 333 | it.effect("SCENARIO returns a typed failure when the user declines", () => |
| 334 | Effect.gen(function*() { |
| 335 | const test = yield* McpConformance |
| 336 | const peer = yield* test.makePeer({ |
| 337 | capabilities: { elicitation: {} }, |
| 338 | handlers: { |
| 339 | "elicitation/create": () => Effect.succeed({ action: "decline" }) |
| 340 | } |
| 341 | }) |
| 342 | |
| 343 | const error = yield* runElicitation( |
| 344 | peer.reverseClient, |
| 345 | protocol.protocolVersion, |
| 346 | Schema.Struct({ name: Schema.String }) |
| 347 | ).pipe(Effect.flip) |
| 348 | |
| 349 | assert.instanceOf(error, McpSchema.ElicitationDeclined) |
| 350 | })) |
| 351 | |
| 352 | it.effect("SCENARIO interrupts the operation when the user cancels", () => |
| 353 | Effect.gen(function*() { |
| 354 | const test = yield* McpConformance |
| 355 | const peer = yield* test.makePeer({ |
| 356 | capabilities: { elicitation: {} }, |
| 357 | handlers: { |
| 358 | "elicitation/create": () => Effect.succeed({ action: "cancel" }) |
| 359 | } |
| 360 | }) |
| 361 | |
| 362 | const exit = yield* Effect.exit(runElicitation( |
| 363 | peer.reverseClient, |
| 364 | protocol.protocolVersion, |
| 365 | Schema.Struct({ name: Schema.String }) |
| 366 | )) |
| 367 | |
| 368 | assert.isTrue(Exit.isFailure(exit)) |
| 369 | if (Exit.isFailure(exit)) { |
| 370 | assert.isTrue(Cause.hasInterrupts(exit.cause)) |
| 371 | } |
| 372 | })) |
| 373 | |
| 374 | it.effect("MUST reject accepted content that does not match the requested schema", () => |
| 375 | Effect.gen(function*() { |
| 376 | const test = yield* McpConformance |
| 377 | const peer = yield* test.makePeer({ |
| 378 | capabilities: { elicitation: {} }, |
| 379 | handlers: { |
| 380 | "elicitation/create": () => |
| 381 | Effect.succeed({ |
| 382 | action: "accept", |
| 383 | content: { name: 123 } |
| 384 | }) |
| 385 | } |
| 386 | }) |
| 387 | |
| 388 | const exit = yield* Effect.exit(runElicitation( |
| 389 | peer.reverseClient, |
| 390 | protocol.protocolVersion, |
| 391 | Schema.Struct({ name: Schema.String }) |
| 392 | )) |
| 393 | |
| 394 | assert.isTrue(Exit.isFailure(exit)) |
| 395 | if (Exit.isFailure(exit)) { |
| 396 | assert.isTrue(Cause.hasDies(exit.cause)) |
| 397 | } |
| 398 | })) |
| 399 | }) |
| 400 | |
| 401 | describe("URL Mode", () => { |
| 402 | it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")( |
| 403 | "MUST round-trip URL elicitation", |
| 404 | () => |
| 405 | Effect.gen(function*() { |
| 406 | const test = yield* McpConformance |
| 407 | const request = { |
| 408 | mode: "url" as const, |
| 409 | message: "Authorize access", |
| 410 | url: "https://example.com/authorize", |
| 411 | elicitationId: "authorization-1" |
| 412 | } |
| 413 | const peer = yield* test.makePeer({ |
| 414 | capabilities: { elicitation: { url: {} } }, |
| 415 | handlers: { "elicitation/create": () => Effect.succeed({ action: "accept" }) } |
| 416 | }) |
| 417 | |
| 418 | const result = yield* peer.reverseClient.elicit( |
| 419 | Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)(request) |
| 420 | ) |
| 421 | |
| 422 | assert.deepStrictEqual((yield* peer.takeRequest).payload, request) |
| 423 | assert.strictEqual(result.action, "accept") |
| 424 | }).pipe(Effect.scoped) |
| 425 | ) |
| 426 | }) |
| 427 | }) |
| 428 | }) |