1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as McpServer from "effect/ai/McpServer"
5import * as Tool from "effect/ai/Tool"
6import * as Toolkit from "effect/ai/Toolkit"
7import * as Cause from "effect/Cause"
8import * as Effect from "effect/Effect"
9import * as Exit from "effect/Exit"
10import * as Layer from "effect/Layer"
11import * as Schema from "effect/Schema"
12import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts"
13import { makeMcpSseReader, readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts"
14import { makeServerLayer } from "../TestUtils/McpServerLayer.ts"
15import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
16import type { McpTestPeer } from "./McpTestPeer.ts"
17
18const ElicitationRequest = Schema.Struct({
19 message: Schema.String,
20 requestedSchema: Schema.Record(Schema.String, Schema.Unknown)
21})
22
23const decodeElicitationRequest = Schema.decodeUnknownEffect(ElicitationRequest)
24
25const 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
47const 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
79export 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 })