1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as Effect from "effect/Effect"
5import * as Schema from "effect/Schema"
6import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
7
8const SamplingRequest = Schema.Struct({
9 messages: Schema.Array(Schema.Struct({
10 role: Schema.String,
11 content: Schema.Record(Schema.String, Schema.Unknown)
12 })),
13 modelPreferences: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
14 systemPrompt: Schema.optionalKey(Schema.String),
15 includeContext: Schema.optionalKey(Schema.String),
16 maxTokens: Schema.Number,
17 stopSequences: Schema.optionalKey(Schema.Array(Schema.String)),
18 metadata: Schema.Unknown
19})
20
21const decodeSamplingRequest = Schema.decodeUnknownEffect(SamplingRequest)
22const decodeSamplingResult = Schema.decodeUnknownEffect(Schema.Struct({
23 role: Schema.String,
24 content: Schema.Record(Schema.String, Schema.Unknown),
25 model: Schema.String,
26 stopReason: Schema.optionalKey(Schema.String)
27}))
28
29const textResponse = {
30 role: "assistant",
31 content: { type: "text", text: "sampled" },
32 model: "test-model",
33 stopReason: "endTurn"
34} as const
35
36const samplingRequest = McpSchema.CreateMessage.payloadSchema.make({
37 messages: [
38 McpSchema.SamplingMessage.make({
39 role: "user",
40 content: McpSchema.TextContent.make({ text: "sample" })
41 })
42 ],
43 maxTokens: 64,
44 metadata: {}
45})
46
47const samplingRequestWithOptions = McpSchema.CreateMessage.payloadSchema.make({
48 messages: [
49 McpSchema.SamplingMessage.make({
50 role: "user",
51 content: McpSchema.TextContent.make({ text: "first" })
52 }),
53 McpSchema.SamplingMessage.make({
54 role: "assistant",
55 content: McpSchema.TextContent.make({ text: "second" })
56 })
57 ],
58 modelPreferences: new McpSchema.ModelPreferences({
59 hints: [McpSchema.ModelHint.make({ name: "test-model" })],
60 costPriority: 0.2,
61 speedPriority: 0.4,
62 intelligencePriority: 0.8
63 }),
64 systemPrompt: "System",
65 includeContext: "thisServer",
66 maxTokens: 64,
67 stopSequences: ["STOP"],
68 metadata: { request: "metadata" }
69})
70
71export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
72 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
73 describe("Sampling", () => {
74 // Text and image sampling are shared by all three dated specifications.
75 // Audio sampling was added in 2025-03-26.
76 describe("Capabilities", () => {
77 it.effect("MUST send sampling requests when the client advertises sampling", () =>
78 Effect.gen(function*() {
79 const test = yield* McpConformance
80 const peer = yield* test.makePeer({
81 capabilities: { sampling: {} },
82 handlers: {
83 "sampling/createMessage": () => Effect.succeed(textResponse)
84 }
85 })
86
87 yield* peer.wireClient["sampling/createMessage"](samplingRequest)
88
89 assert.strictEqual((yield* peer.takeRequest).method, "sampling/createMessage")
90 }))
91
92 it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")(
93 "SHOULD reject context-enabled requests before sending without sampling.context",
94 () =>
95 Effect.gen(function*() {
96 const test = yield* McpConformance
97 const peer = yield* test.makePeer({ capabilities: { sampling: {} } })
98 const request = McpSchema.CreateMessage.payloadSchema.make({
99 ...samplingRequest,
100 includeContext: "thisServer"
101 })
102
103 const exit = yield* Effect.exit(peer.reverseClient.createMessage(request))
104
105 assert.strictEqual(exit._tag, "Failure")
106 assert.deepStrictEqual(yield* peer.requests, [])
107 })
108 )
109
110 it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")(
111 "MUST reject every tool-enabled request shape before sending without sampling.tools",
112 () =>
113 Effect.gen(function*() {
114 const test = yield* McpConformance
115 const requests = [
116 {
117 name: "tools",
118 request: {
119 messages: [{ role: "user", content: { type: "text", text: "Weather?" } }],
120 tools: [{ name: "weather", inputSchema: { type: "object" } }],
121 maxTokens: 64
122 }
123 },
124 {
125 name: "toolChoice",
126 request: {
127 messages: [{ role: "user", content: { type: "text", text: "Weather?" } }],
128 toolChoice: { mode: "required" },
129 maxTokens: 64
130 }
131 },
132 {
133 name: "tool_use content",
134 request: {
135 messages: [{
136 role: "assistant",
137 content: { type: "tool_use", id: "call-1", name: "weather", input: { city: "Zurich" } }
138 }],
139 maxTokens: 64
140 }
141 },
142 {
143 name: "tool_result content",
144 request: {
145 messages: [{
146 role: "user",
147 content: {
148 type: "tool_result",
149 toolUseId: "call-1",
150 content: [{ type: "text", text: "Sunny" }]
151 }
152 }],
153 maxTokens: 64
154 }
155 }
156 ] as const
157
158 for (const testCase of requests) {
159 const peer = yield* test.makePeer({ capabilities: { sampling: {} } })
160 const request = Schema.decodeUnknownSync(McpSchema.CreateMessage.payloadSchema)(testCase.request)
161 const exit = yield* Effect.exit(peer.reverseClient.createMessage(request))
162
163 assert.strictEqual(exit._tag, "Failure", testCase.name)
164 assert.deepStrictEqual(yield* peer.requests, [], testCase.name)
165 }
166 }).pipe(Effect.scoped)
167 )
168
169 it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")(
170 "MUST allow text-only content arrays without sampling.tools",
171 () =>
172 Effect.gen(function*() {
173 const test = yield* McpConformance
174 const peer = yield* test.makePeer({
175 capabilities: { sampling: {} },
176 handlers: {
177 "sampling/createMessage": () =>
178 Effect.succeed({
179 role: "assistant",
180 content: [{ type: "text", text: "First" }, { type: "text", text: "Second" }],
181 model: "text-model"
182 })
183 }
184 })
185
186 const result = yield* peer.reverseClient.createMessage(
187 Schema.decodeUnknownSync(McpSchema.CreateMessage.payloadSchema)({
188 messages: [{
189 role: "user",
190 content: [{ type: "text", text: "First" }, { type: "text", text: "Second" }]
191 }],
192 maxTokens: 64
193 })
194 )
195
196 assert.strictEqual((yield* peer.requests).length, 1)
197 assert.deepStrictEqual(JSON.parse(JSON.stringify(result.content)), [
198 { type: "text", text: "First" },
199 { type: "text", text: "Second" }
200 ])
201 })
202 )
203
204 it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")(
205 "SCENARIO rejects unexpected tool-enabled responses when sampling.tools was not advertised",
206 () =>
207 Effect.gen(function*() {
208 const test = yield* McpConformance
209 const peer = yield* test.makePeer({
210 capabilities: { sampling: {} },
211 handlers: {
212 "sampling/createMessage": () =>
213 Effect.succeed({
214 role: "assistant",
215 content: { type: "tool_use", id: "call-1", name: "weather", input: {} },
216 model: "tool-model",
217 stopReason: "toolUse"
218 })
219 }
220 })
221
222 const exit = yield* Effect.exit(peer.reverseClient.createMessage(samplingRequest))
223
224 assert.strictEqual(exit._tag, "Failure")
225 assert.strictEqual((yield* peer.requests).length, 1)
226 })
227 )
228 })
229
230 describe("Creating Messages", () => {
231 it.effect("MUST preserve message order and sampling request options", () =>
232 Effect.gen(function*() {
233 const test = yield* McpConformance
234 const peer = yield* test.makePeer({
235 capabilities: {
236 sampling: protocol.protocolVersion === "2025-11-25" ? { context: {} } : {}
237 },
238 handlers: {
239 "sampling/createMessage": () => Effect.succeed(textResponse)
240 }
241 })
242
243 yield* peer.wireClient["sampling/createMessage"](samplingRequestWithOptions)
244 const recorded = yield* peer.takeRequest
245 const payload = yield* decodeSamplingRequest(recorded.payload)
246
247 assert.deepStrictEqual(
248 payload.messages.map((message) => ({
249 role: message.role,
250 text: message.content.text
251 })),
252 [
253 { role: "user", text: "first" },
254 { role: "assistant", text: "second" }
255 ]
256 )
257 assert.strictEqual(payload.systemPrompt, "System")
258 assert.deepStrictEqual(payload.modelPreferences, {
259 hints: [{ name: "test-model" }],
260 costPriority: 0.2,
261 speedPriority: 0.4,
262 intelligencePriority: 0.8
263 })
264 assert.strictEqual(payload.maxTokens, 64)
265 assert.deepStrictEqual(payload.stopSequences, ["STOP"])
266 assert.deepStrictEqual(payload.metadata, { request: "metadata" })
267 assert.strictEqual(payload.includeContext, "thisServer")
268 }))
269
270 it.effect("MUST accept and decode text sampling content", () =>
271 Effect.gen(function*() {
272 const test = yield* McpConformance
273 const peer = yield* test.makePeer({
274 capabilities: { sampling: {} },
275 handlers: {
276 "sampling/createMessage": () => Effect.succeed(textResponse)
277 }
278 })
279
280 const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe(
281 Effect.flatMap(decodeSamplingResult)
282 )
283
284 assert.strictEqual(result.role, "assistant")
285 assert.strictEqual(result.model, "test-model")
286 assert.strictEqual(result.stopReason, "endTurn")
287 assert.deepStrictEqual(result.content, {
288 type: "text",
289 text: "sampled"
290 })
291 }))
292
293 it.effect("MUST accept image sampling content", () =>
294 Effect.gen(function*() {
295 const test = yield* McpConformance
296 const peer = yield* test.makePeer({
297 capabilities: { sampling: {} },
298 handlers: {
299 "sampling/createMessage": () =>
300 Effect.succeed({
301 role: "assistant",
302 content: {
303 type: "image",
304 data: "AQID",
305 mimeType: "image/png"
306 },
307 model: "vision-model"
308 })
309 }
310 })
311
312 const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe(
313 Effect.flatMap(decodeSamplingResult)
314 )
315
316 assert.deepStrictEqual(result.content, {
317 type: "image",
318 data: "AQID",
319 mimeType: "image/png"
320 })
321 }))
322
323 it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))(
324 "MUST accept audio sampling content",
325 () =>
326 Effect.gen(function*() {
327 const test = yield* McpConformance
328 const peer = yield* test.makePeer({
329 capabilities: { sampling: {} },
330 handlers: {
331 "sampling/createMessage": () =>
332 Effect.succeed({
333 role: "assistant",
334 content: {
335 type: "audio",
336 data: "BAUG",
337 mimeType: "audio/wav"
338 },
339 model: "audio-model"
340 })
341 }
342 })
343
344 const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe(
345 Effect.flatMap(decodeSamplingResult)
346 )
347
348 assert.deepStrictEqual(result.content, {
349 type: "audio",
350 data: "BAUG",
351 mimeType: "audio/wav"
352 })
353 })
354 )
355
356 it.effect("MUST surface sampling errors returned by the client", () =>
357 Effect.gen(function*() {
358 const test = yield* McpConformance
359 const peer = yield* test.makePeer({
360 capabilities: { sampling: {} },
361 handlers: {
362 "sampling/createMessage": () =>
363 Effect.fail(
364 new McpSchema.InternalError({
365 message: "Sampling failed"
366 })
367 )
368 }
369 })
370
371 const error = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe(Effect.flip)
372
373 assert.isTrue("code" in error)
374 if ("code" in error) assert.strictEqual(error.code, McpSchema.INTERNAL_ERROR_CODE)
375 assert.strictEqual(error.message, "Sampling failed")
376 }))
377
378 it.effect.skipIf(protocol.protocolVersion !== "2025-11-25")(
379 "MUST round-trip tool-enabled sampling requests and results",
380 () =>
381 Effect.gen(function*() {
382 const test = yield* McpConformance
383 const response = {
384 role: "assistant",
385 content: [{
386 type: "tool_use",
387 id: "call-2",
388 name: "weather",
389 input: { city: "Geneva" }
390 }],
391 model: "tool-model",
392 stopReason: "toolUse"
393 } as const
394 const peer = yield* test.makePeer({
395 capabilities: { sampling: { tools: {} } },
396 handlers: { "sampling/createMessage": () => Effect.succeed(response) }
397 })
398 const request = McpSchema.CreateMessage.payloadSchema.make({
399 messages: [
400 McpSchema.SamplingMessage.make({
401 role: "assistant",
402 content: McpSchema.ToolUseContent.make({
403 id: "call-1",
404 name: "weather",
405 input: { city: "Zurich" }
406 })
407 }),
408 McpSchema.SamplingMessage.make({
409 role: "user",
410 content: McpSchema.ToolResultContent.make({
411 toolUseId: "call-1",
412 content: [McpSchema.TextContent.make({ text: "Sunny" })],
413 structuredContent: { temperature: 24 }
414 })
415 })
416 ],
417 tools: [{
418 name: "weather",
419 description: "Get weather",
420 inputSchema: {
421 type: "object",
422 properties: { city: { type: "string" } },
423 required: ["city"]
424 }
425 }],
426 toolChoice: new McpSchema.ToolChoice({ mode: "required" }),
427 maxTokens: 64
428 })
429
430 const result = yield* peer.reverseClient.createMessage(request)
431 const recorded = yield* peer.takeRequest
432
433 assert.strictEqual(recorded.method, "sampling/createMessage")
434 assert.deepStrictEqual(recorded.payload, {
435 messages: [
436 {
437 role: "assistant",
438 content: { type: "tool_use", id: "call-1", name: "weather", input: { city: "Zurich" } }
439 },
440 {
441 role: "user",
442 content: {
443 type: "tool_result",
444 toolUseId: "call-1",
445 content: [{ type: "text", text: "Sunny" }],
446 structuredContent: { temperature: 24 }
447 }
448 }
449 ],
450 tools: [{
451 name: "weather",
452 description: "Get weather",
453 inputSchema: {
454 type: "object",
455 properties: { city: { type: "string" } },
456 required: ["city"]
457 }
458 }],
459 toolChoice: { mode: "required" },
460 maxTokens: 64
461 })
462 assert.deepStrictEqual(JSON.parse(JSON.stringify(result)), response)
463 })
464 )
465 })
466 })
467 })