1import { assert, describe, it } from "@effect/vitest"
2import { assertTrue } from "@effect/vitest/utils"
3import type * as McpProtocol from "effect/ai/McpProtocol"
4import * as McpSchema from "effect/ai/McpSchema"
5import * as McpServer from "effect/ai/McpServer"
6import * as Context from "effect/Context"
7import * as Effect from "effect/Effect"
8import * as Layer from "effect/Layer"
9import * as Schema from "effect/Schema"
10import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts"
11import { readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts"
12import { makeServerLayer } from "../TestUtils/McpServerLayer.ts"
13import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts"
14import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
15
16const decodeTools = Schema.decodeUnknownEffect(McpSchema.ListToolsResult)
17const decodeCallTool = Schema.decodeUnknownEffect(McpSchema.CallToolResult)
18const decodeJsonSchema2020Tools = Schema.decodeUnknownEffect(Schema.Struct({
19 tools: Schema.Array(Schema.Struct({
20 name: Schema.String,
21 inputSchema: Schema.JsonObject,
22 outputSchema: Schema.optional(Schema.JsonObject)
23 }))
24}))
25
26const callTool = (name: string, arguments_: Record<string, unknown> = {}) =>
27 Effect.gen(function*() {
28 const test = yield* McpConformance
29 const initialized = yield* test.initialize({ server: "features" })
30 yield* test.notifyInitialized(initialized)
31 const response = yield* test.send(initialized, {
32 jsonrpc: "2.0",
33 id: 2,
34 method: "tools/call",
35 params: { name, arguments: arguments_ }
36 })
37 return yield* test.decodeResult(response).pipe(
38 Effect.flatMap((message) => decodeCallTool(message.result))
39 )
40 })
41
42const callToolWire = (name: string) =>
43 Effect.gen(function*() {
44 const test = yield* McpConformance
45 const initialized = yield* test.initialize({ server: "features" })
46 yield* test.notifyInitialized(initialized)
47 const response = yield* test.send(initialized, {
48 jsonrpc: "2.0",
49 id: 2,
50 method: "tools/call",
51 params: { name, arguments: {} }
52 })
53 return yield* test.decodeResult(response)
54 })
55
56// String results are mirrored as serialized JSON only where they are also returned as structured content.
57const stringResultContent = (protocol: McpProtocol.ProtocolAdapter, text: string) => [{
58 type: "text" as const,
59 text: protocol.protocolVersion === "2026-07-28" ? JSON.stringify(text) : text
60}]
61
62export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
63 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
64 describe("Tools", () => {
65 // Shared capability contract; each dated entrypoint owns its normative specification revision.
66 describe("Capabilities", () => {
67 it.effect("MUST advertise the tools capability when tools are registered", () =>
68 Effect.gen(function*() {
69 const test = yield* McpConformance
70 const initialized = yield* test.initialize({ server: "features" })
71
72 assert.property(initialized.message.result.capabilities, "tools")
73 }))
74
75 it.effect("MUST NOT advertise the tools capability when tools are not supported", () =>
76 Effect.gen(function*() {
77 const test = yield* McpConformance
78 const initialized = yield* test.initialize()
79
80 assert.notProperty(initialized.message.result.capabilities, "tools")
81 }))
82 })
83
84 describe("Listing Tools", () => {
85 it.effect(
86 "SCHEMA preserves tool names and descriptions",
87 () =>
88 Effect.gen(function*() {
89 const test = yield* McpConformance
90 const initialized = yield* test.initialize({ server: "features" })
91 yield* test.notifyInitialized(initialized)
92 const response = yield* test.send(initialized, {
93 jsonrpc: "2.0",
94 id: 2,
95 method: "tools/list",
96 params: {}
97 })
98 const result = yield* test.decodeResult(response).pipe(
99 Effect.flatMap((message) => decodeTools(message.result))
100 )
101
102 const tool = result.tools.find((tool) => tool.name === "TestTool")
103 assert.isDefined(tool)
104 assert.strictEqual(tool.description, "A test tool")
105 })
106 )
107
108 it.effect(
109 "MUST return each tool input schema",
110 () =>
111 Effect.gen(function*() {
112 const test = yield* McpConformance
113 const initialized = yield* test.initialize({ server: "features" })
114 yield* test.notifyInitialized(initialized)
115 const response = yield* test.send(initialized, {
116 jsonrpc: "2.0",
117 id: 2,
118 method: "tools/list",
119 params: {}
120 })
121 const result = yield* test.decodeResult(response).pipe(
122 Effect.flatMap((message) => decodeTools(message.result))
123 )
124
125 assert.isTrue(result.tools.every((tool) => tool.inputSchema.type === "object"))
126 })
127 )
128
129 it.effect("should list shared tools and gate modern-only tools by era", () =>
130 Effect.gen(function*() {
131 const test = yield* McpConformance
132 const initialized = yield* test.initialize({ server: "features" })
133 yield* test.notifyInitialized(initialized)
134 const response = yield* test.send(initialized, {
135 jsonrpc: "2.0",
136 id: 2,
137 method: "tools/list",
138 params: {}
139 })
140 const result = yield* test.decodeResult(response).pipe(
141 Effect.flatMap((message) => decodeTools(message.result))
142 )
143 const names = new Set(result.tools.map((tool) => tool.name))
144 for (const name of ["TestTool", "StructuredTool", "LogLevelTool", "RequestMetadataTool"]) {
145 assert.isTrue(names.has(name))
146 }
147 for (const name of ["JsonSchema2020Tool", "MrtrTool"]) {
148 assert.strictEqual(names.has(name), protocol.runtime._tag === "Stateless")
149 }
150 }))
151 })
152
153 describe("Calling Tools", () => {
154 it.effect("MUST call a registered tool with valid arguments", () =>
155 Effect.gen(function*() {
156 const test = yield* McpConformance
157 const initialized = yield* test.initialize({ server: "features" })
158 yield* test.notifyInitialized(initialized)
159 const response = yield* test.send(initialized, {
160 jsonrpc: "2.0",
161 id: 2,
162 method: "tools/call",
163 params: {
164 name: "TestTool",
165 arguments: { value: "called" }
166 }
167 })
168 const result = yield* test.decodeResult(response).pipe(
169 Effect.flatMap((message) => decodeCallTool(message.result))
170 )
171
172 assert.strictEqual(result.isError, false)
173 assert.deepStrictEqual(result.content, stringResultContent(protocol, "called"))
174 }))
175
176 it.effect("MUST reject an unknown tool name with a protocol error", () =>
177 Effect.gen(function*() {
178 const test = yield* McpConformance
179 const before = (yield* test.observations).toolInvocations
180 const initialized = yield* test.initialize({ server: "features" })
181 yield* test.notifyInitialized(initialized)
182 const response = yield* test.send(initialized, {
183 jsonrpc: "2.0",
184 id: 2,
185 method: "tools/call",
186 params: {
187 name: "UnknownTool",
188 arguments: {}
189 }
190 })
191 const error = yield* test.decodeError(response)
192
193 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
194 assert.strictEqual((yield* test.observations).toolInvocations, before)
195 }))
196
197 it.effect("SCHEMA returns text content", () =>
198 Effect.gen(function*() {
199 const test = yield* McpConformance
200 const initialized = yield* test.initialize({ server: "features" })
201 yield* test.notifyInitialized(initialized)
202 const response = yield* test.send(initialized, {
203 jsonrpc: "2.0",
204 id: 2,
205 method: "tools/call",
206 params: {
207 name: "TestTool",
208 arguments: { value: "text" }
209 }
210 })
211 const result = yield* test.decodeResult(response).pipe(
212 Effect.flatMap((message) => decodeCallTool(message.result))
213 )
214
215 assert.deepStrictEqual(result.content, stringResultContent(protocol, "text"))
216 }))
217 it.effect("SCHEMA returns image content", () =>
218 Effect.gen(function*() {
219 const result = yield* callToolWire("ImageTool")
220 assert.deepStrictEqual(result.result.content, [{
221 type: "image",
222 data: "AQID",
223 mimeType: "image/png"
224 }])
225 }))
226 it.effect("SCHEMA returns embedded resources", () =>
227 Effect.gen(function*() {
228 const result = yield* callTool("EmbeddedResourceTool")
229 assert.deepStrictEqual(result.content, [{
230 type: "resource",
231 resource: {
232 uri: "file:///embedded",
233 mimeType: "text/plain",
234 text: "embedded"
235 }
236 }])
237 }))
238 it.effect("MUST return multiple content items in order", () =>
239 Effect.gen(function*() {
240 const result = yield* callTool("MultipleContentTool")
241 assert.deepStrictEqual(result.content, [
242 { type: "text", text: "first" },
243 { type: "text", text: "second" }
244 ])
245 }))
246 it.effect("MUST return tool execution failures with isError", () =>
247 Effect.gen(function*() {
248 const result = yield* callTool("ErrorTool")
249 assert.strictEqual(result.isError, true)
250 assert.deepStrictEqual(result.content, [{ type: "text", text: "expected failure" }])
251 }))
252 it.effect("MUST keep tool execution errors distinct from protocol errors", () =>
253 Effect.gen(function*() {
254 const test = yield* McpConformance
255 const initialized = yield* test.initialize({ server: "features" })
256 yield* test.notifyInitialized(initialized)
257
258 const execution = yield* callTool("ErrorTool")
259 const protocolResponse = yield* test.send(initialized, {
260 jsonrpc: "2.0",
261 id: 3,
262 method: "tools/call",
263 params: { name: "UnknownTool", arguments: {} }
264 })
265 const protocol = yield* test.decodeError(protocolResponse)
266
267 assert.strictEqual(execution.isError, true)
268 assert.strictEqual(protocol.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
269 }))
270
271 it.effect("SHOULD not expose defects or internal error details", () =>
272 Effect.gen(function*() {
273 const test = yield* McpConformance
274 const initialized = yield* test.initialize({ server: "features" })
275 yield* test.notifyInitialized(initialized)
276 const response = yield* test.send(initialized, {
277 jsonrpc: "2.0",
278 id: 2,
279 method: "tools/call",
280 params: { name: "DefectTool", arguments: {} }
281 })
282 const result = yield* test.decodeError(response)
283
284 assert.strictEqual(result.error.code, McpSchema.INTERNAL_ERROR_CODE)
285 assert.strictEqual(result.error.message, "Internal error")
286 assert.notMatch(JSON.stringify(result), /private defect details/)
287 }))
288
289 it.effect.skipIf(protocol.protocolVersion === "2024-11-05")(
290 "should return base64 audio content when an audio tool is called",
291 () =>
292 Effect.gen(function*() {
293 const result = yield* callToolWire("AudioTool")
294 assert.deepStrictEqual(result.result.content, [{
295 type: "audio",
296 data: "BAUG",
297 mimeType: "audio/wav"
298 }])
299 })
300 )
301
302 describe.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))(
303 "Structured content",
304 () => {
305 it.effect("should advertise an object output schema when a tool declares one", () =>
306 Effect.gen(function*() {
307 const test = yield* McpConformance
308 const initialized = yield* test.initialize({ server: "features" })
309 yield* test.notifyInitialized(initialized)
310 const response = yield* test.send(initialized, {
311 jsonrpc: "2.0",
312 id: 2,
313 method: "tools/list",
314 params: {}
315 })
316 const result = yield* test.decodeResult(response).pipe(
317 Effect.flatMap((message) => decodeTools(message.result))
318 )
319
320 assert.strictEqual(
321 result.tools.find((tool) => tool.name === "StructuredTool")?.outputSchema?.type,
322 "object"
323 )
324 }))
325
326 it.effect("should return a resource link when a resource-link tool is called", () =>
327 Effect.gen(function*() {
328 const result = yield* callTool("ResourceLinkTool")
329 assert.deepStrictEqual(result.content, [{
330 type: "resource_link",
331 uri: "file:///test",
332 name: "TestResource",
333 mimeType: "text/plain"
334 }])
335 }))
336
337 it.effect("should return structured content when a structured tool is called", () =>
338 Effect.gen(function*() {
339 const result = yield* callTool("StructuredTool")
340 assert.deepStrictEqual(result.structuredContent, { value: "structured" })
341 }))
342 }
343 )
344
345 it.effect("distinguishes malformed requests from tool validation errors by protocol revision", () =>
346 Effect.gen(function*() {
347 const test = yield* McpConformance
348 const initialized = yield* test.initialize({ server: "features" })
349 yield* test.notifyInitialized(initialized)
350 const before = (yield* test.observations).toolInvocations
351 const request = (arguments_: unknown) =>
352 test.send(initialized, {
353 jsonrpc: "2.0",
354 id: 2,
355 method: "tools/call",
356 params: { name: "TestTool", arguments: arguments_ }
357 })
358 const malformed = yield* request("invalid").pipe(Effect.flatMap(test.decodeError))
359 assert.strictEqual(malformed.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
360
361 const response = yield* request({ value: 123 })
362 let text: string
363 if (["2024-11-05", "2025-03-26", "2025-06-18"].includes(protocol.protocolVersion)) {
364 const error = yield* test.decodeError(response)
365 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
366 text = error.error.message
367 } else {
368 const result = yield* test.decodeResult(response).pipe(
369 Effect.flatMap((message) => decodeCallTool(message.result))
370 )
371 assert.isTrue(result.isError)
372 assert.isUndefined(result.structuredContent)
373 assert.lengthOf(result.content, 1)
374 const content = result.content[0]
375 assertTrue(content.type === "text")
376 text = content.text
377 }
378 assert.include(text, "Invalid parameters for tool 'TestTool'")
379 assert.include(text, "Expected string")
380 assert.notMatch(text, /AiError|ToolParameterValidationError|Toolkit|TestTool\.handle/)
381 assert.strictEqual((yield* test.observations).toolInvocations, before)
382 }))
383 })
384 })
385 })
386
387export const statelessModernSuite = (
388 protocol: McpProtocol.ProtocolAdapter,
389 layer: McpConformanceLayer
390) =>
391 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
392 describe("Tools > JSON Schema 2020-12", () => {
393 // SEP-2106: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12
394 // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool
395 it.effect("should preserve 2020-12 keywords when listing an input schema", () =>
396 Effect.gen(function*() {
397 const test = yield* McpConformance
398 const initialized = yield* test.initialize({ server: "features" })
399 const response = yield* test.send(initialized, {
400 jsonrpc: "2.0",
401 id: 1,
402 method: "tools/list",
403 params: {}
404 })
405 const result = yield* test.decodeResult(response).pipe(
406 Effect.flatMap((message) => decodeJsonSchema2020Tools(message.result))
407 )
408 const tool = result.tools.find((tool) => tool.name === "JsonSchema2020Tool")
409
410 assert.isDefined(tool)
411 assert.deepStrictEqual(tool.inputSchema, {
412 $schema: "https://json-schema.org/draft/2020-12/schema",
413 type: "object",
414 $defs: { identifier: { type: "string" } },
415 properties: { value: { $ref: "#/$defs/identifier" } },
416 allOf: [{ required: ["value"] }],
417 unevaluatedProperties: false
418 })
419 }))
420
421 // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#output-schema
422 it.effect("should list a non-object output schema when the tool declares one", () =>
423 Effect.gen(function*() {
424 const test = yield* McpConformance
425 const initialized = yield* test.initialize({ server: "features" })
426 const response = yield* test.send(initialized, {
427 jsonrpc: "2.0",
428 id: 1,
429 method: "tools/list",
430 params: {}
431 })
432 const result = yield* test.decodeResult(response).pipe(
433 Effect.flatMap((message) => decodeJsonSchema2020Tools(message.result))
434 )
435 const tool = result.tools.find((tool) => tool.name === "TestTool")
436
437 assert.isDefined(tool)
438 assert.deepStrictEqual(tool.outputSchema, { type: "string" })
439 }))
440
441 // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content
442 it.effect("should return primitive structured content when declared by the tool", () =>
443 Effect.gen(function*() {
444 const result = yield* callTool("TestTool", { value: "called" })
445
446 assert.strictEqual(result.structuredContent, "called")
447 }))
448 })
449
450 describe("Tools > Modern request headers", () => {
451 // SEP-2243: https://modelcontextprotocol.io/seps/2243-http-standardization
452 // The final 2026-07-28 specification assigns the HeaderMismatch error code.
453 // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#request-metadata
454 it.effect("should reject a tools/call request when its required routing name is missing", () =>
455 Effect.gen(function*() {
456 const test = yield* McpConformance
457 const initialized = yield* test.initialize({ server: "features" })
458 const before = (yield* test.observations).toolInvocations
459 const response = yield* test.send(initialized, {
460 jsonrpc: "2.0",
461 id: 2,
462 method: "tools/call",
463 params: { arguments: { value: "called" } }
464 })
465 const error = yield* test.decodeError(response)
466
467 assert.strictEqual(error.error.code, McpSchema.HEADER_MISMATCH_ERROR_CODE)
468 assert.strictEqual((yield* test.observations).toolInvocations, before)
469 }))
470
471 it.effect("should reject a tool call when a required parameter header is missing", () =>
472 Effect.gen(function*() {
473 const test = yield* McpConformance
474 const initialized = yield* test.initialize({ server: "features" })
475 const before = (yield* test.observations).toolInvocations
476 const response = yield* test.send(initialized, {
477 jsonrpc: "2.0",
478 id: "missing-parameter-header",
479 method: "tools/call",
480 params: {
481 name: "HeaderTool",
482 arguments: { region: "eu-central-1" }
483 }
484 })
485
486 assert.strictEqual(response.status, 400)
487 const error = yield* test.decodeError(response)
488 assert.strictEqual(error.id, "missing-parameter-header")
489 assert.strictEqual(error.error.code, McpSchema.HEADER_MISMATCH_ERROR_CODE)
490 assert.strictEqual((yield* test.observations).toolInvocations, before)
491 }))
492
493 // Header extraction uses the exact JSON property path; absent parameters do not require headers.
494 // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-behavior-for-custom-headers
495 it.effect("should accept omitted routing headers when optional parameters have prototype property names", () =>
496 Effect.gen(function*() {
497 const registration = Layer.effectDiscard(McpServer.McpServer.use((server) =>
498 server.addTool({
499 tool: new McpSchema.Tool({
500 name: "OptionalRouting",
501 inputSchema: {
502 type: "object",
503 properties: {
504 toString: { type: "string", "x-mcp-header": "Route" },
505 nested: {
506 type: "object",
507 properties: { constructor: { type: "string", "x-mcp-header": "Nested-Route" } }
508 }
509 }
510 }
511 }),
512 annotations: Context.empty(),
513 handle: () =>
514 Effect.succeed(
515 new McpSchema.CallToolResult({
516 content: [{ type: "text", text: "called" }]
517 })
518 )
519 })
520 ))
521 const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge(
522 makeServerLayer({ name: "OptionalRouting", protocols: [protocol] })
523 )))
524 for (const args of [{}, { nested: {} }]) {
525 const response = yield* harness.post({
526 jsonrpc: "2.0",
527 id: "optional-routing",
528 method: "tools/call",
529 params: {
530 name: "OptionalRouting",
531 arguments: args,
532 _meta: {
533 "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
534 "io.modelcontextprotocol/clientCapabilities": {}
535 }
536 }
537 }, {
538 "Mcp-Protocol-Version": protocol.protocolVersion,
539 "Mcp-Method": "tools/call",
540 "Mcp-Name": "OptionalRouting"
541 })
542 assert.strictEqual(response.status, 200)
543 const message = Schema.decodeUnknownSync(Schema.Struct({ result: McpSchema.CallToolResult }))(
544 yield* readMcpHttpResponse(response)
545 )
546 assert.deepStrictEqual(message.result.content, [{ type: "text", text: "called" }])
547 }
548 }))
549
550 // Plain values permit interior HTAB; non-ASCII and other control characters require Base64 encoding.
551 // Decoding must preserve the complete string, including a leading U+FEFF, before comparing it with the body.
552 // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#value-encoding
553 // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-validation
554 it.effect("should validate parameter headers when values use plain or Base64 encoding", () =>
555 Effect.gen(function*() {
556 const test = yield* McpConformance
557 const initialized = yield* test.initialize({ server: "features" })
558 const cases = [
559 {
560 id: "integer-missing",
561 arguments: { region: "eu-central-1", shard: 42 },
562 headers: { "Mcp-Param-Region": "eu-central-1" },
563 status: 400
564 },
565 {
566 id: "integer-mismatch",
567 arguments: { region: "eu-central-1", shard: 42 },
568 headers: { "Mcp-Param-Region": "eu-central-1", "Mcp-Param-Shard": "7" },
569 status: 400
570 },
571 {
572 id: "boolean-mismatch",
573 arguments: { region: "eu-central-1", dryRun: true },
574 headers: { "Mcp-Param-Region": "eu-central-1", "Mcp-Param-Dry-Run": "false" },
575 status: 400
576 },
577 {
578 id: "encoded-string",
579 arguments: { region: "Hello, 世界" },
580 headers: { "Mcp-Param-Region": "=?base64?SGVsbG8sIOS4lueVjA==?=" },
581 status: 200
582 },
583 {
584 id: "encoded-invalid-utf8",
585 arguments: { region: "\uFFFD" },
586 headers: { "Mcp-Param-Region": "=?base64?/w==?=" },
587 status: 400
588 },
589 {
590 id: "encoded-replacement-character",
591 arguments: { region: "\uFFFD" },
592 headers: { "Mcp-Param-Region": "=?base64?77+9?=" },
593 status: 200
594 },
595 {
596 id: "encoded-leading-bom",
597 arguments: { region: "\uFEFFeu-west" },
598 headers: { "Mcp-Param-Region": "=?base64?77u/ZXUtd2VzdA==?=" },
599 status: 200
600 },
601 {
602 id: "encoded-leading-bom-mismatch",
603 arguments: { region: "eu-west" },
604 headers: { "Mcp-Param-Region": "=?base64?77u/ZXUtd2VzdA==?=" },
605 status: 400
606 },
607 {
608 id: "encoded-tab",
609 arguments: { region: "eu\twest" },
610 headers: { "Mcp-Param-Region": "=?base64?ZXUJd2VzdA==?=" },
611 status: 200
612 },
613 {
614 id: "plain-tab",
615 arguments: { region: "eu\twest" },
616 headers: { "Mcp-Param-Region": "eu\twest" },
617 status: 200
618 },
619 {
620 id: "plain-control-character",
621 arguments: { region: "eu\u000bwest" },
622 headers: { "Mcp-Param-Region": "eu\u000bwest" },
623 status: 400
624 }
625 ] as const
626
627 for (const testCase of cases) {
628 yield* test.resetObservations
629 const response = yield* test.send(initialized, {
630 jsonrpc: "2.0",
631 id: testCase.id,
632 method: "tools/call",
633 params: {
634 name: "HeaderTool",
635 arguments: testCase.arguments
636 }
637 }, { headers: testCase.headers })
638
639 assert.strictEqual(response.status, testCase.status, testCase.id)
640 assert.strictEqual((yield* test.observations).toolInvocations, testCase.status === 400 ? 0 : 1, testCase.id)
641 if (testCase.status === 400) {
642 const error = yield* test.decodeError(response)
643 assert.strictEqual(error.error.code, McpSchema.HEADER_MISMATCH_ERROR_CODE)
644 } else {
645 yield* test.decodeResult(response)
646 }
647 }
648 }))
649 })
650
651 describe("Tools > Modern listing", () => {
652 // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#listing-tools
653 it.effect("should return tools in the same order when the registered inventory has not changed", () =>
654 Effect.gen(function*() {
655 const test = yield* McpConformance
656 const initialized = yield* test.initialize({ server: "features" })
657 const list = Effect.fnUntraced(function*() {
658 const response = yield* test.send(initialized, {
659 jsonrpc: "2.0",
660 id: 20,
661 method: "tools/list",
662 params: {}
663 })
664 const result = yield* test.decodeResult(response).pipe(
665 Effect.flatMap((message) => decodeTools(message.result))
666 )
667 return result.tools.map((tool) => tool.name)
668 })
669
670 assert.deepStrictEqual(yield* list(), yield* list())
671 }))
672 })
673 })
674
675export const statefulLegacySuite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
676 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
677 describe("Tools > Legacy validation", () => {
678 it.effect("should reject malformed tool parameters without invoking a handler", () =>
679 Effect.gen(function*() {
680 const test = yield* McpConformance
681 const initialized = yield* test.initialize({ server: "features" })
682 yield* test.notifyInitialized(initialized)
683 const before = (yield* test.observations).toolInvocations
684 const response = yield* test.send(initialized, {
685 jsonrpc: "2.0",
686 id: 2,
687 method: "tools/call",
688 params: { arguments: { value: "called" } }
689 })
690 const error = yield* test.decodeError(response)
691
692 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
693 assert.strictEqual((yield* test.observations).toolInvocations, before)
694 }))
695 })
696
697 // https://modelcontextprotocol.io/specification/2025-11-25/server/tools
698 describe("Tools > Legacy notifications", () => {
699 it.effect("should advertise tool list-change notifications when supported", () =>
700 Effect.gen(function*() {
701 const test = yield* McpConformance
702 const discovered = yield* test.initialize({ server: "features" })
703 assert.strictEqual(discovered.message.result.capabilities.tools?.listChanged, true)
704 }))
705
706 it.effect("should send a tool list-change notification when the advertised list changes", () =>
707 Effect.gen(function*() {
708 const fixture = yield* makeMcpStdioHarness(protocol)
709 const makeTool = (name: string) => ({
710 tool: new McpSchema.Tool({ name, inputSchema: { type: "object", properties: {} } }),
711 annotations: Context.empty(),
712 handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] }))
713 })
714 yield* fixture.server.addTool(makeTool("baseline-list-changed-tool"))
715 yield* fixture.initialize()
716 yield* fixture.server.addTool(makeTool("dynamic-list-changed-tool"))
717 yield* fixture.flushListChanged
718
719 const notification = yield* fixture.awaitOutboundMethod("notifications/tools/list_changed")
720 assert.strictEqual(notification.jsonrpc, "2.0")
721 assert.strictEqual(notification.method, "notifications/tools/list_changed")
722 assert.notProperty(notification, "id")
723
724 const response = yield* fixture.sendRequest("tools/list", {})
725 const result = yield* decodeTools(response.result)
726 assert.isTrue(result.tools.some((tool) => tool.name === "dynamic-list-changed-tool"))
727 }))
728 })
729 })