| 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 Effect from "effect/Effect" |
| 5 | import * as Schema from "effect/Schema" |
| 6 | import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" |
| 7 | |
| 8 | const 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 | |
| 21 | const decodeSamplingRequest = Schema.decodeUnknownEffect(SamplingRequest) |
| 22 | const 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 | |
| 29 | const textResponse = { |
| 30 | role: "assistant", |
| 31 | content: { type: "text", text: "sampled" }, |
| 32 | model: "test-model", |
| 33 | stopReason: "endTurn" |
| 34 | } as const |
| 35 | |
| 36 | const 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 | |
| 47 | const 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 | |
| 71 | export 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 | }) |