1import { assert, describe, it } from "@effect/vitest"
2import * as McpCore from "effect/ai/internal/mcpCore"
3import * as McpProtocol from "effect/ai/McpProtocol"
4import * as McpSchema from "effect/ai/McpSchema"
5import * as McpServer from "effect/ai/McpServer"
6import * as Tool from "effect/ai/Tool"
7import * as Toolkit from "effect/ai/Toolkit"
8import * as Context from "effect/Context"
9import * as Effect from "effect/Effect"
10import * as HttpServerRequest from "effect/http/HttpServerRequest"
11import * as Layer from "effect/Layer"
12import * as Option from "effect/Option"
13import { CurrentLogLevel } from "effect/References"
14import * as RpcClient from "effect/rpc/RpcClient"
15import type * as RpcSerialization from "effect/rpc/RpcSerialization"
16import * as Schema from "effect/Schema"
17import { makeHttpHarness } from "./TestUtils/McpHttpHarness.ts"
18import { readMcpHttpResponse } from "./TestUtils/McpHttpResponse.ts"
19import { makeServerLayer } from "./TestUtils/McpServerLayer.ts"
20
21const ServerIcon = McpSchema.Icon.make({
22 src: "https://example.com/server.svg",
23 mimeType: "image/svg+xml",
24 sizes: ["48x48", "any"],
25 theme: "dark"
26})
27const ResourceIcon = McpSchema.Icon.make({ src: "https://example.com/resource.svg" })
28const PromptIcon = McpSchema.Icon.make({ src: "https://example.com/prompt.svg" })
29const ToolIcon = McpSchema.Icon.make({ src: "https://example.com/tool.svg" })
30
31const SharedTool = Tool.make("shared", {
32 parameters: Tool.EmptyParams,
33 success: Schema.String
34}).annotate(Tool.Title, "Shared tool title")
35
36const JsonArrayTool = Tool.make("json-array", {
37 parameters: Tool.EmptyParams,
38 success: Schema.Tuple([Schema.String, Schema.Null])
39})
40
41const StructuredOnlyTool = Tool.make("structured-only", {
42 parameters: Tool.EmptyParams,
43 success: Schema.Struct({ value: Schema.String })
44}).annotate(
45 McpSchema.EnabledWhen,
46 (client) => client.protocolVersion === "2025-06-18"
47)
48
49const UnionResultTool = Tool.make("union-result", {
50 parameters: Tool.EmptyParams,
51 success: Schema.Union([
52 Schema.Struct({ _tag: Schema.Literal("a"), a: Schema.Number }),
53 Schema.Struct({ _tag: Schema.Literal("b"), b: Schema.String })
54 ])
55})
56
57const IdentifiedResultTool = Tool.make("identified-result", {
58 parameters: Tool.EmptyParams,
59 success: Schema.Struct({ value: Schema.String }).annotate({ identifier: "IdentifiedResult" })
60})
61
62const ValidatedTool = Tool.make("validated", {
63 parameters: Schema.Struct({
64 value: Schema.String
65 }),
66 success: Schema.String
67})
68
69const CapabilityTool = Tool.make("capability", {
70 parameters: Tool.EmptyParams,
71 success: Schema.String,
72 dependencies: [McpSchema.McpRequestContext]
73})
74
75const InitializeMetadataTool = Tool.make("initialize-metadata", {
76 parameters: Tool.EmptyParams,
77 success: Schema.String,
78 dependencies: [McpSchema.McpRequestContext]
79})
80
81const LogLevelTool = Tool.make("log-level", {
82 parameters: Tool.EmptyParams,
83 success: Schema.String,
84 dependencies: [CurrentLogLevel]
85})
86
87const CapabilityGatedTool = Tool.make("capability-gated", {
88 parameters: Tool.EmptyParams,
89 success: Schema.String
90}).annotate(
91 McpSchema.EnabledWhen,
92 (client) =>
93 client.clientInfo?.name === "allowed-client" &&
94 client.capabilities.roots !== undefined
95)
96
97const TestToolkit = Toolkit.make(
98 SharedTool,
99 JsonArrayTool,
100 StructuredOnlyTool,
101 UnionResultTool,
102 IdentifiedResultTool,
103 ValidatedTool,
104 CapabilityTool,
105 InitializeMetadataTool,
106 LogLevelTool,
107 CapabilityGatedTool
108)
109
110const FamilyResource = McpServer.resource({
111 uri: "file:///canonical.txt",
112 name: "canonical",
113 description: "Canonical resource",
114 mimeType: "text/plain",
115 content: Effect.succeed("canonical resource")
116})
117
118const FamilyPrompt = McpServer.prompt({
119 name: "canonical-prompt",
120 description: "Canonical prompt",
121 parameters: {
122 style: Schema.String
123 },
124 completion: {
125 style: () => Effect.succeed(["short", "long"])
126 },
127 content: ({ style }) => Effect.succeed(`Use the ${style} style`)
128})
129
130const InputResource = McpServer.resource({
131 uri: "file:///input-context.txt",
132 name: "input-context",
133 content: McpSchema.McpRequestContext.useSync((context) =>
134 JSON.stringify({ inputResponses: context.inputResponses, requestState: context.requestState })
135 )
136})
137
138const InputPrompt = McpServer.prompt({
139 name: "input-context",
140 content: () =>
141 McpSchema.McpRequestContext.useSync((context) =>
142 JSON.stringify({ inputResponses: context.inputResponses, requestState: context.requestState })
143 )
144})
145
146const AudioPrompt = McpServer.prompt({
147 name: "audio-prompt",
148 content: () =>
149 Effect.succeed([{
150 role: "user",
151 content: McpSchema.AudioContent.make({
152 data: new Uint8Array([1, 2, 3]),
153 mimeType: "audio/wav"
154 })
155 }])
156})
157
158const ResourceLinkPrompt = McpServer.prompt({
159 name: "resource-link-prompt",
160 content: () =>
161 Effect.succeed([{
162 role: "user",
163 content: McpSchema.ResourceLink.make({
164 uri: "file:///canonical.txt",
165 name: "canonical"
166 })
167 }])
168})
169
170const MrtrTool = new McpSchema.Tool({
171 name: "mrtr-elicitation",
172 description: "Requires client input before completing",
173 inputSchema: { type: "object" }
174})
175
176interface TestState {
177 sharedInvocations: number
178 structuredInvocations: number
179 capabilityInvocations: number
180}
181
182const makeFixture = Effect.fnUntraced(function*(
183 extensions?: NonNullable<McpSchema.ServerCapabilities["extensions"]>
184) {
185 const state: TestState = {
186 sharedInvocations: 0,
187 structuredInvocations: 0,
188 capabilityInvocations: 0
189 }
190 const toolkitLayer = McpServer.toolkit(TestToolkit).pipe(
191 Layer.provideMerge(TestToolkit.toLayer(TestToolkit.of({
192 shared: () =>
193 Effect.sync(() => {
194 state.sharedInvocations++
195 return "shared-result"
196 }),
197 "json-array": () => Effect.succeed(["array", null] as const),
198 "structured-only": () =>
199 Effect.sync(() => {
200 state.structuredInvocations++
201 return { value: "structured-result" }
202 }),
203 "union-result": () => Effect.succeed({ _tag: "a" as const, a: 1 }),
204 "identified-result": () => Effect.succeed({ value: "identified" }),
205 validated: ({ value }) => Effect.succeed(value),
206 capability: () =>
207 McpServer.clientCapabilities.pipe(
208 Effect.map((capabilities) => {
209 state.capabilityInvocations++
210 return JSON.stringify(capabilities)
211 })
212 ),
213 "initialize-metadata": () =>
214 McpSchema.McpRequestContext.useSync((context) => JSON.stringify(context.requestMetadata)),
215 "log-level": () => CurrentLogLevel,
216 "capability-gated": () => Effect.succeed("visible")
217 })))
218 )
219 const mrtrToolLayer = Layer.effectDiscard(
220 Effect.gen(function*() {
221 const server = yield* McpServer.McpServer
222 yield* server.addTool({
223 tool: MrtrTool,
224 annotations: Context.empty(),
225 handle: () =>
226 Effect.succeed(
227 new McpSchema.InputRequired({
228 inputRequests: { roots: { method: "roots/list" } }
229 })
230 )
231 })
232 })
233 )
234 const serverLayer = Layer.mergeAll(
235 toolkitLayer,
236 mrtrToolLayer,
237 FamilyResource,
238 FamilyPrompt,
239 InputResource,
240 InputPrompt,
241 AudioPrompt,
242 ResourceLinkPrompt
243 ).pipe(
244 Layer.provide(McpServer.layerHttp({
245 name: "ProtocolAdapterServer",
246 version: "1.0.0",
247 description: "Protocol adapter fixture",
248 websiteUrl: "https://example.com/mcp",
249 icons: [McpSchema.Icon.make({
250 src: "https://example.com/server.svg",
251 mimeType: "image/svg+xml",
252 sizes: ["any"]
253 })],
254 path: "/mcp",
255 extensions,
256 protocols: [
257 McpProtocol.v2026_07_28,
258 McpProtocol.v2025_11_25,
259 McpProtocol.v2025_06_18,
260 McpProtocol.v2025_03_26,
261 McpProtocol.v2024_11_05
262 ]
263 }))
264 )
265 const harness = yield* makeHttpHarness(serverLayer)
266 return { ...harness, state }
267})
268
269interface LowLevelTestState {
270 imageInvocations: number
271 audioInvocations: number
272 resourceLinkInvocations: number
273 projectionMismatchInvocations: number
274 argumentObservations: Array<unknown>
275 duplicateInvocations: Array<"first" | "second">
276}
277
278const makeLowLevelFixture = Effect.fnUntraced(function*() {
279 const state: LowLevelTestState = {
280 imageInvocations: 0,
281 audioInvocations: 0,
282 resourceLinkInvocations: 0,
283 projectionMismatchInvocations: 0,
284 argumentObservations: [],
285 duplicateInvocations: []
286 }
287 const registrations = Layer.effectDiscard(
288 Effect.gen(function*() {
289 const server = yield* McpServer.McpServer
290 const makeTool = (name: string, description: string) =>
291 new McpSchema.Tool({
292 name,
293 description,
294 inputSchema: {
295 type: "object",
296 properties: {}
297 }
298 })
299
300 yield* server.addResource({
301 resource: new McpSchema.Resource({
302 uri: "file:///metadata.txt",
303 name: "metadata-resource",
304 icons: [ResourceIcon],
305 _meta: { descriptor: "fixture" }
306 }),
307 annotations: Context.empty(),
308 handle: Effect.succeed(
309 McpSchema.ReadResourceResult.make({
310 contents: [{ uri: "file:///metadata.txt", text: "metadata" }],
311 _meta: { result: "fixture" }
312 })
313 )
314 })
315 yield* server.addPrompt({
316 prompt: new McpSchema.Prompt({
317 name: "metadata-prompt",
318 icons: [PromptIcon],
319 _meta: { descriptor: "fixture" }
320 }),
321 annotations: Context.empty(),
322 completions: {
323 value: () =>
324 Effect.succeed(
325 McpSchema.CompleteResult.make({
326 completion: { values: ["metadata"] },
327 _meta: { result: "fixture" }
328 })
329 )
330 },
331 handle: () =>
332 Effect.succeed(
333 McpSchema.GetPromptResult.make({
334 messages: [{
335 role: "user",
336 content: { type: "text", text: "metadata", _meta: { content: "fixture" } }
337 }],
338 _meta: { result: "fixture" }
339 })
340 )
341 })
342
343 yield* server.addTool({
344 tool: new McpSchema.Tool({
345 name: "title-precedence",
346 title: "Canonical title",
347 icons: [ToolIcon],
348 inputSchema: {
349 type: "object",
350 properties: {}
351 },
352 annotations: {
353 title: "Legacy annotation title"
354 }
355 }),
356 annotations: Context.empty(),
357 handle: () =>
358 Effect.succeed(
359 new McpSchema.CallToolResult({
360 content: [{ type: "text", text: "title" }]
361 })
362 )
363 })
364 yield* server.addTool({
365 tool: makeTool("projection-mismatch", "Claims old support but returns audio"),
366 annotations: Context.empty(),
367 handle: () =>
368 Effect.sync(() => {
369 state.projectionMismatchInvocations++
370 return new McpSchema.CallToolResult({
371 content: [{
372 type: "audio",
373 data: new Uint8Array([1]),
374 mimeType: "audio/wav"
375 }]
376 })
377 })
378 })
379 yield* server.addTool({
380 tool: makeTool("internal-failure", "Fails with an internal error"),
381 annotations: Context.empty(),
382 handle: () => Effect.fail(new McpSchema.InternalError({ message: "database unavailable" }))
383 })
384 yield* server.addTool({
385 tool: makeTool("arguments", "Argument normalization probe"),
386 annotations: Context.empty(),
387 handle: (payload) =>
388 Effect.sync(() => {
389 state.argumentObservations.push(payload)
390 return new McpSchema.CallToolResult({
391 content: [{ type: "text", text: "arguments" }]
392 })
393 })
394 })
395 yield* server.addTool({
396 tool: makeTool("image", "Image for both revisions"),
397 annotations: Context.empty(),
398 handle: () =>
399 Effect.sync(() => {
400 state.imageInvocations++
401 return new McpSchema.CallToolResult({
402 content: [{
403 type: "image",
404 data: new Uint8Array([1, 2, 3]),
405 mimeType: "image/png"
406 }]
407 })
408 })
409 })
410 yield* server.addTool({
411 tool: makeTool("binary-resource", "Binary embedded resource"),
412 annotations: Context.empty(),
413 handle: () =>
414 Effect.succeed(
415 new McpSchema.CallToolResult({
416 content: [{
417 type: "resource",
418 resource: {
419 uri: "file:///binary.dat",
420 mimeType: "application/octet-stream",
421 blob: new Uint8Array([7, 8, 9])
422 }
423 }]
424 })
425 )
426 })
427 yield* server.addTool({
428 tool: makeTool("annotated-resource", "Annotated embedded resource"),
429 annotations: Context.empty(),
430 handle: () =>
431 Effect.succeed(
432 new McpSchema.CallToolResult({
433 content: [{
434 type: "resource",
435 resource: {
436 uri: "file:///annotated.txt",
437 mimeType: "text/plain",
438 text: "annotated",
439 _meta: { source: "fixture" }
440 },
441 annotations: {
442 audience: ["assistant"],
443 priority: 0.75,
444 lastModified: "2026-08-13T00:00:00Z"
445 },
446 _meta: { content: "fixture" }
447 }],
448 _meta: { result: "fixture" }
449 })
450 )
451 })
452 yield* server.addTool({
453 tool: makeTool("audio", "Current-revision audio"),
454 annotations: Context.empty(),
455 handle: () =>
456 Effect.sync(() => {
457 state.audioInvocations++
458 return new McpSchema.CallToolResult({
459 content: [{
460 type: "audio",
461 data: new Uint8Array([4, 5, 6]),
462 mimeType: "audio/wav"
463 }]
464 })
465 })
466 })
467 yield* server.addTool({
468 tool: new McpSchema.Tool({
469 name: "schema-output",
470 description: "Tool with an output schema",
471 inputSchema: {
472 type: "object",
473 properties: {}
474 },
475 outputSchema: {
476 type: "object",
477 properties: {
478 value: { type: "string" }
479 },
480 required: ["value"]
481 },
482 _meta: { descriptor: "fixture" }
483 }),
484 annotations: Context.empty(),
485 handle: () =>
486 Effect.succeed(
487 new McpSchema.CallToolResult({
488 content: [{ type: "text", text: "schema" }]
489 })
490 )
491 })
492 yield* server.addTool({
493 tool: makeTool("structured-object", "Object structured content"),
494 annotations: Context.empty(),
495 handle: () =>
496 Effect.succeed(
497 new McpSchema.CallToolResult({
498 content: [{ type: "text", text: "structured" }],
499 structuredContent: { value: "fixture" }
500 })
501 )
502 })
503 yield* server.addTool({
504 tool: makeTool("structured-scalar", "Scalar structured content"),
505 annotations: Context.empty(),
506 handle: () =>
507 Effect.succeed(
508 new McpSchema.CallToolResult({
509 content: [{ type: "text", text: "structured" }],
510 structuredContent: "fixture"
511 })
512 )
513 })
514 yield* server.addTool({
515 tool: makeTool("structured-string-mirror", "Mirrored string structured content"),
516 annotations: Context.empty(),
517 handle: () =>
518 Effect.succeed(
519 new McpSchema.CallToolResult({
520 content: [{ type: "text", text: JSON.stringify("fixture") }],
521 structuredContent: "fixture"
522 })
523 )
524 })
525 yield* server.addTool({
526 tool: makeTool("structured-string-multiple", "Multiple content blocks"),
527 annotations: Context.empty(),
528 handle: () =>
529 Effect.succeed(
530 new McpSchema.CallToolResult({
531 content: [{ type: "text", text: JSON.stringify("fixture") }, { type: "text", text: "extra" }],
532 structuredContent: "fixture"
533 })
534 )
535 })
536 yield* server.addTool({
537 tool: makeTool("invalid-structured-content", "Non-JSON structured content"),
538 annotations: Context.empty(),
539 handle: () =>
540 Effect.succeed({
541 content: [{ type: "text", text: "invalid" }],
542 structuredContent: Symbol("not-json")
543 } as unknown as McpSchema.CallToolResult)
544 })
545 yield* server.addTool({
546 tool: makeTool("resource-link", "Current-revision resource link"),
547 annotations: Context.empty(),
548 handle: () =>
549 Effect.sync(() => {
550 state.resourceLinkInvocations++
551 return new McpSchema.CallToolResult({
552 content: [{
553 type: "resource_link",
554 name: "linked",
555 title: "Linked resource",
556 description: "A linked fixture",
557 uri: "file:///linked.txt",
558 mimeType: "text/plain",
559 annotations: {
560 audience: ["user"],
561 priority: 0.5
562 },
563 size: 42,
564 _meta: { source: "fixture" }
565 }]
566 })
567 })
568 })
569 yield* server.addTool({
570 tool: makeTool("duplicate", "first descriptor"),
571 annotations: Context.empty(),
572 handle: () =>
573 Effect.sync(() => {
574 state.duplicateInvocations.push("first")
575 return new McpSchema.CallToolResult({
576 content: [{ type: "text", text: "first" }]
577 })
578 })
579 })
580 yield* server.addTool({
581 tool: makeTool("duplicate", "second descriptor"),
582 annotations: Context.empty(),
583 handle: () =>
584 Effect.sync(() => {
585 state.duplicateInvocations.push("second")
586 return new McpSchema.CallToolResult({
587 content: [{ type: "text", text: "second" }]
588 })
589 })
590 })
591 })
592 )
593 const serverLayer = registrations.pipe(
594 Layer.provide(McpServer.layerHttp({
595 name: "LowLevelProtocolAdapterServer",
596 version: "1.0.0",
597 description: "Low-level protocol adapter fixture",
598 websiteUrl: "https://example.com/low-level-mcp",
599 icons: [ServerIcon],
600 path: "/mcp",
601 protocols: [
602 McpProtocol.v2026_07_28,
603 McpProtocol.v2025_11_25,
604 McpProtocol.v2025_06_18,
605 McpProtocol.v2025_03_26,
606 McpProtocol.v2024_11_05
607 ]
608 }))
609 )
610 const harness = yield* makeHttpHarness(serverLayer)
611 return { ...harness, state }
612})
613
614const JsonRpcResponse = Schema.Union([
615 Schema.Struct({
616 jsonrpc: Schema.Literal("2.0"),
617 id: Schema.Number,
618 result: Schema.Record(Schema.String, Schema.Unknown)
619 }),
620 Schema.Struct({
621 jsonrpc: Schema.Literal("2.0"),
622 id: Schema.NullOr(Schema.Number),
623 error: Schema.Struct({
624 code: Schema.Number,
625 message: Schema.String,
626 data: Schema.optionalKey(Schema.Unknown)
627 })
628 })
629])
630type JsonRpcResponse = typeof JsonRpcResponse.Type
631
632const decodeJsonRpcResponse = Schema.decodeUnknownEffect(JsonRpcResponse)
633
634const readJsonRpcResponse = (response: Response) =>
635 readMcpHttpResponse(response).pipe(Effect.flatMap(decodeJsonRpcResponse))
636
637const modernMetadata = (
638 capabilities: Record<string, unknown> = {},
639 clientInfo: { readonly name: string; readonly version: string } = {
640 name: "ModernProtocolAdapterClient",
641 version: "1.0.0"
642 }
643) => ({
644 "io.modelcontextprotocol/protocolVersion": McpProtocol.v2026_07_28.protocolVersion,
645 "io.modelcontextprotocol/clientCapabilities": capabilities,
646 "io.modelcontextprotocol/clientInfo": clientInfo
647})
648
649const modernRequest = (
650 id: number,
651 method: string,
652 params: Record<string, unknown> = {},
653 metadata: Record<string, unknown> = modernMetadata()
654) => ({
655 jsonrpc: "2.0",
656 id,
657 method,
658 params: { ...params, _meta: metadata }
659})
660
661const modernHeaders = (
662 method: string,
663 protocolVersion: string = McpProtocol.v2026_07_28.protocolVersion
664): HeadersInit => ({
665 "MCP-Protocol-Version": protocolVersion,
666 "Mcp-Method": method
667})
668
669const initialize = Effect.fnUntraced(function*(
670 post: Effect.Success<ReturnType<typeof makeFixture>>["post"],
671 protocolVersion: "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05",
672 options?: {
673 readonly capabilities?: Record<string, unknown>
674 readonly clientInfo?: {
675 readonly name: string
676 readonly version: string
677 }
678 }
679) {
680 const response = yield* post({
681 jsonrpc: "2.0",
682 id: 1,
683 method: "initialize",
684 params: {
685 protocolVersion,
686 capabilities: options?.capabilities ?? {},
687 clientInfo: options?.clientInfo ?? {
688 name: "ProtocolAdapterClient",
689 version: "1.0.0"
690 }
691 }
692 })
693 const body = yield* readJsonRpcResponse(response)
694 if (!("result" in body)) {
695 return yield* Effect.die(`Initialization failed: ${body.error.message}`)
696 }
697 const sessionId = response.headers.get("Mcp-Session-Id")
698 assert.isNotNull(sessionId)
699 assert.strictEqual(body.result.protocolVersion, protocolVersion)
700
701 let requestId = 1
702 const request = Effect.fnUntraced(function*(method: string, params?: unknown) {
703 const response = yield* post({
704 jsonrpc: "2.0",
705 id: ++requestId,
706 method,
707 ...(params === undefined ? {} : { params })
708 }, {
709 "Mcp-Protocol-Version": protocolVersion,
710 "Mcp-Session-Id": sessionId
711 })
712 return yield* readJsonRpcResponse(response)
713 })
714
715 return { initializeResult: body.result, protocolVersion, request, sessionId }
716})
717
718const resultOf = (message: JsonRpcResponse): Record<string, unknown> => {
719 if (!("result" in message)) {
720 assert.fail(`Expected result, received error ${message.error.code}: ${message.error.message}`)
721 }
722 return message.result
723}
724
725const errorOf = (message: JsonRpcResponse): {
726 readonly code: number
727 readonly message: string
728} => {
729 if (!("error" in message)) {
730 assert.fail("Expected error, received result")
731 }
732 return message.error
733}
734
735const listedTools = (message: JsonRpcResponse): ReadonlyArray<Record<string, unknown>> => {
736 const tools = resultOf(message).tools
737 return Schema.decodeUnknownSync(Schema.Array(Schema.Record(Schema.String, Schema.Unknown)))(tools)
738}
739
740const textResult = (message: JsonRpcResponse): string => {
741 const content = resultOf(message).content
742 const [first] = Schema.decodeUnknownSync(
743 Schema.NonEmptyArray(Schema.Record(Schema.String, Schema.Unknown))
744 )(content)
745 assert.strictEqual(first.type, "text")
746 return Schema.decodeUnknownSync(Schema.String)(first.text)
747}
748
749describe("McpServer protocol adapters", () => {
750 it.effect("should negotiate a stateful protocol when initialize offers the stateless revision", () =>
751 Effect.gen(function*() {
752 const fixture = yield* makeFixture()
753 const response = yield* fixture.post({
754 jsonrpc: "2.0",
755 id: 1,
756 method: "initialize",
757 params: {
758 protocolVersion: "2026-07-28",
759 capabilities: {},
760 clientInfo: { name: "legacy-client", version: "1.0.0" }
761 }
762 })
763 const message = yield* readJsonRpcResponse(response)
764
765 assert.strictEqual(response.status, 200)
766 assert.isNotNull(response.headers.get("Mcp-Session-Id"))
767 assert.strictEqual(resultOf(message).protocolVersion, "2025-11-25")
768 }))
769
770 // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility
771 it.effect("should ignore a recognized legacy session when dispatching a modern request", () =>
772 Effect.gen(function*() {
773 const fixture = yield* makeFixture()
774 const legacy = yield* initialize(fixture.post, "2025-11-25")
775 const body = modernRequest(19, "server/discover")
776 const withoutSession = yield* fixture.post(body, modernHeaders("server/discover"))
777 const expected = resultOf(yield* readJsonRpcResponse(withoutSession))
778 const withSession = yield* fixture.post(body, {
779 ...modernHeaders("server/discover"),
780 "Mcp-Session-Id": legacy.sessionId
781 })
782 const actual = yield* readJsonRpcResponse(withSession)
783
784 assert.deepStrictEqual(resultOf(actual), expected)
785 assert.isNull(withSession.headers.get("Mcp-Session-Id"))
786 assert.deepStrictEqual(resultOf(yield* legacy.request("ping", {})), {})
787 }))
788
789 it.effect("should discover the modern server without initialization or a session", () =>
790 Effect.gen(function*() {
791 const fixture = yield* makeFixture()
792 const response = yield* fixture.post(
793 modernRequest(19, "server/discover"),
794 modernHeaders("server/discover")
795 )
796 const message = yield* readJsonRpcResponse(response)
797 const result = resultOf(message)
798
799 assert.strictEqual(response.status, 200)
800 assert.isNull(response.headers.get("Mcp-Session-Id"))
801 assert.deepStrictEqual(result.supportedVersions, [
802 "2026-07-28",
803 "2025-11-25",
804 "2025-06-18",
805 "2025-03-26",
806 "2024-11-05"
807 ])
808 }))
809
810 it.effect("should expose continuation input to modern resource and prompt handlers", () =>
811 Effect.gen(function*() {
812 const fixture = yield* makeFixture()
813 const continuation = {
814 inputResponses: { roots: { roots: [{ uri: "file:///workspace" }] } },
815 requestState: "round-2"
816 }
817 const cases = [
818 {
819 method: "resources/read",
820 params: { uri: "file:///input-context.txt", ...continuation },
821 read: (result: Record<string, unknown>) =>
822 (result.contents as ReadonlyArray<{ readonly text: string }>)[0]!.text
823 },
824 {
825 method: "prompts/get",
826 params: { name: "input-context", ...continuation },
827 read: (result: Record<string, unknown>) =>
828 (result.messages as ReadonlyArray<{ readonly content: { readonly text: string } }>)[0]!.content.text
829 }
830 ] as const
831
832 for (const [index, testCase] of cases.entries()) {
833 const response = yield* fixture.post(
834 modernRequest(index + 30, testCase.method, testCase.params),
835 {
836 ...modernHeaders(testCase.method),
837 "Mcp-Name": testCase.method === "resources/read" ?
838 "file:///input-context.txt" :
839 "input-context"
840 }
841 )
842 const message = yield* readJsonRpcResponse(response)
843 assert.deepStrictEqual(JSON.parse(testCase.read(resultOf(message))), continuation)
844 }
845 }))
846
847 it.effect("should keep log levels isolated when one session updates its level", () =>
848 Effect.gen(function*() {
849 const fixture = yield* makeFixture()
850 const first = yield* initialize(fixture.post, "2025-06-18")
851 const second = yield* initialize(fixture.post, "2025-06-18")
852
853 resultOf(yield* first.request("logging/setLevel", { level: "debug" }))
854
855 assert.strictEqual(
856 textResult(yield* first.request("tools/call", { name: "log-level" })),
857 "Debug"
858 )
859 assert.strictEqual(
860 textResult(yield* second.request("tools/call", { name: "log-level" })),
861 "Info"
862 )
863 }))
864
865 describe("registration request context", () => {
866 // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#earlier-streamable-http-revisions
867 // Effect must not expose a captured legacy client service to a stateless invocation.
868 it.effect("should omit a legacy client when a toolkit registered in a session handles a stateless request", () =>
869 Effect.gen(function*() {
870 const sessionToolkit = Toolkit.make(Tool.make("session-owner", {
871 success: Schema.Struct({ client: Schema.String, hasSession: Schema.Boolean }),
872 dependencies: [McpSchema.McpRequestContext]
873 }))
874 const registration = Layer.effectDiscard(McpServer.McpServer.use((server) =>
875 server.addTool({
876 tool: new McpSchema.Tool({ name: "register", inputSchema: { type: "object" } }),
877 annotations: Context.empty(),
878 handle: () =>
879 McpServer.registerToolkit(sessionToolkit).pipe(
880 Effect.provide(sessionToolkit.toLayer({
881 "session-owner": () =>
882 Effect.gen(function*() {
883 const request = yield* McpSchema.McpRequestContext
884 const session = yield* Effect.serviceOption(McpSchema.McpServerClient)
885 return { client: request.clientInfo!.name, hasSession: Option.isSome(session) }
886 })
887 })),
888 Effect.provideService(McpServer.McpServer, server),
889 Effect.as(new McpSchema.CallToolResult({ content: [] }))
890 )
891 })
892 ))
893 const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge(makeServerLayer({
894 name: "RegistrationContext",
895 protocols: [McpProtocol.v2026_07_28, McpProtocol.v2025_06_18]
896 }))))
897 const initialized = yield* harness.post({
898 jsonrpc: "2.0",
899 id: 1,
900 method: "initialize",
901 params: {
902 protocolVersion: "2025-06-18",
903 capabilities: {},
904 clientInfo: { name: "Alice", version: "1" }
905 }
906 })
907 yield* readMcpHttpResponse(initialized)
908 const sessionId = initialized.headers.get("mcp-session-id")
909 assert.isNotNull(sessionId)
910 const legacyHeaders = { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-06-18" }
911 // https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization
912 yield* harness.post({ jsonrpc: "2.0", method: "notifications/initialized" }, legacyHeaders)
913 const registered = yield* harness.post({
914 jsonrpc: "2.0",
915 id: 2,
916 method: "tools/call",
917 params: { name: "register" }
918 }, legacyHeaders).pipe(
919 Effect.flatMap(readMcpHttpResponse)
920 )
921 assert.deepNestedPropertyVal(registered, "result.content", [])
922 const invoked = yield* harness.post({
923 jsonrpc: "2.0",
924 id: 3,
925 method: "tools/call",
926 params: {
927 name: "session-owner",
928 _meta: {
929 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
930 "io.modelcontextprotocol/clientCapabilities": {},
931 "io.modelcontextprotocol/clientInfo": { name: "Bob", version: "1" }
932 }
933 }
934 }, {
935 "mcp-protocol-version": "2026-07-28",
936 "mcp-method": "tools/call",
937 "mcp-name": "session-owner"
938 }).pipe(Effect.flatMap(readMcpHttpResponse))
939 assert.deepNestedPropertyVal(invoked, "result.structuredContent", { client: "Bob", hasSession: false })
940 }))
941
942 // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#per-request-log-level
943 // Effect registration must preserve the invoking request's metadata, HTTP headers, and derived log level.
944 it.effect("should use the current HTTP request when another request registered the resource", () =>
945 Effect.gen(function*() {
946 const registration = Layer.effectDiscard(McpServer.McpServer.use((server) =>
947 server.addTool({
948 tool: new McpSchema.Tool({ name: "register", inputSchema: { type: "object" } }),
949 annotations: Context.empty(),
950 handle: () =>
951 McpServer.registerResource({
952 uri: "file:///http-owner",
953 name: "http-owner",
954 content: Effect.gen(function*() {
955 const request = yield* McpSchema.McpRequestContext
956 const http = Option.getOrThrow(yield* Effect.serviceOption(HttpServerRequest.HttpServerRequest))
957 const level = yield* CurrentLogLevel
958 return `${request.clientInfo?.name}:${request.requestMetadata?.owner}:${
959 http.headers["x-owner"]
960 }:${level}`
961 })
962 }).pipe(
963 Effect.provideService(McpServer.McpServer, server),
964 Effect.as(new McpSchema.CallToolResult({ content: [] }))
965 )
966 })
967 ))
968 const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge(makeServerLayer({
969 name: "RegistrationContext",
970 protocols: [McpProtocol.v2026_07_28]
971 }))))
972 const request = (name: string, method: string, params: Record<string, unknown>) =>
973 harness.post({
974 jsonrpc: "2.0",
975 id: name,
976 method,
977 params: {
978 ...params,
979 _meta: {
980 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
981 "io.modelcontextprotocol/clientCapabilities": {},
982 "io.modelcontextprotocol/clientInfo": { name, version: "1" },
983 "io.modelcontextprotocol/logLevel": name === "Alice" ? "error" : "debug",
984 owner: name
985 }
986 }
987 }, {
988 "mcp-protocol-version": "2026-07-28",
989 "mcp-method": method,
990 "mcp-name": method === "tools/call" ? "register" : "file:///http-owner",
991 "x-owner": name
992 }).pipe(Effect.flatMap(readMcpHttpResponse))
993 assert.deepNestedPropertyVal(yield* request("Alice", "tools/call", { name: "register" }), "result.content", [])
994 assert.deepNestedPropertyVal(
995 yield* request("Bob", "resources/read", { uri: "file:///http-owner" }),
996 "result.contents",
997 [
998 { uri: "file:///http-owner", text: "Bob:Bob:Bob:Debug" }
999 ]
1000 )
1001 }))
1002 })
1003
1004 it.effect("should isolate interleaved stateful and stateless request contexts", () =>
1005 Effect.gen(function*() {
1006 const fixture = yield* makeFixture()
1007 const legacy = yield* initialize(fixture.post, "2025-11-25", {
1008 capabilities: { roots: {} },
1009 clientInfo: { name: "legacy-client", version: "1.0.0" }
1010 })
1011
1012 assert.strictEqual(
1013 textResult(yield* legacy.request("tools/call", { name: "capability" })),
1014 JSON.stringify({ roots: {} })
1015 )
1016
1017 const modernResponse = yield* fixture.post(
1018 modernRequest(
1019 20,
1020 "tools/call",
1021 { name: "capability", arguments: {} },
1022 modernMetadata({ sampling: {} }, {
1023 name: "modern-client",
1024 version: "2.0.0"
1025 })
1026 ),
1027 { ...modernHeaders("tools/call"), "Mcp-Name": "capability", "Mcp-Session-Id": "ignored-modern-session" }
1028 )
1029 const modern = yield* Effect.promise<unknown>(() => modernResponse.json()).pipe(
1030 Effect.flatMap(decodeJsonRpcResponse)
1031 )
1032
1033 assert.isNull(modernResponse.headers.get("Mcp-Session-Id"))
1034 assert.strictEqual(resultOf(modern).structuredContent, JSON.stringify({ sampling: {} }))
1035 assert.strictEqual(
1036 textResult(yield* legacy.request("tools/call", { name: "capability" })),
1037 JSON.stringify({ roots: {} })
1038 )
1039 }))
1040
1041 it.effect("should keep requests out of legacy routing when modern metadata is malformed", () =>
1042 Effect.gen(function*() {
1043 const fixture = yield* makeFixture()
1044 const unsupportedVersion = "2099-01-01"
1045 const cases = [
1046 {
1047 name: "missing request metadata",
1048 body: { jsonrpc: "2.0", id: 30, method: "tools/list", params: {} },
1049 headers: modernHeaders("tools/list"),
1050 status: 400,
1051 code: McpSchema.INVALID_PARAMS_ERROR_CODE
1052 },
1053 {
1054 name: "missing protocol header",
1055 body: modernRequest(31, "tools/list"),
1056 headers: { "Mcp-Method": "tools/list" },
1057 status: 400,
1058 code: McpSchema.HEADER_MISMATCH_ERROR_CODE
1059 },
1060 {
1061 name: "malformed client identity",
1062 body: modernRequest(32, "tools/list", {}, {
1063 ...modernMetadata(),
1064 "io.modelcontextprotocol/clientInfo": true
1065 }),
1066 headers: modernHeaders("tools/list"),
1067 status: 200,
1068 code: McpSchema.INVALID_PARAMS_ERROR_CODE
1069 },
1070 {
1071 name: "unsupported protocol version",
1072 body: modernRequest(33, "tools/list", {}, {
1073 ...modernMetadata(),
1074 "io.modelcontextprotocol/protocolVersion": unsupportedVersion
1075 }),
1076 headers: modernHeaders("tools/list", unsupportedVersion),
1077 status: 400,
1078 code: -32022,
1079 supported: [
1080 "2026-07-28",
1081 "2025-11-25",
1082 "2025-06-18",
1083 "2025-03-26",
1084 "2024-11-05"
1085 ]
1086 },
1087 {
1088 name: "modern metadata on initialize",
1089 body: {
1090 jsonrpc: "2.0",
1091 id: 34,
1092 method: "initialize",
1093 params: {
1094 protocolVersion: "2025-11-25",
1095 capabilities: {},
1096 clientInfo: { name: "legacy-shape", version: "1.0.0" },
1097 _meta: modernMetadata()
1098 }
1099 },
1100 headers: modernHeaders("initialize"),
1101 status: 404,
1102 code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE
1103 }
1104 ] as const
1105
1106 for (const testCase of cases) {
1107 const response = yield* fixture.post(testCase.body, testCase.headers)
1108 const message = yield* readJsonRpcResponse(response)
1109 if (!("error" in message)) {
1110 assert.fail(`${testCase.name}: expected error, received result`)
1111 }
1112 const error = message.error
1113
1114 assert.strictEqual(response.status, testCase.status, testCase.name)
1115 assert.strictEqual(error.code, testCase.code, testCase.name)
1116 if ("supported" in testCase) {
1117 assert.deepStrictEqual(
1118 (error.data as { readonly supported?: ReadonlyArray<string> } | undefined)?.supported,
1119 testCase.supported,
1120 testCase.name
1121 )
1122 }
1123 }
1124 }))
1125
1126 it.effect("should reject client input when the selected protocol cannot encode it", () =>
1127 Effect.gen(function*() {
1128 const fixture = yield* makeFixture()
1129 const legacy = yield* initialize(fixture.post, "2025-11-25")
1130 const legacyError = errorOf(yield* legacy.request("tools/call", { name: MrtrTool.name, arguments: {} }))
1131 assert.strictEqual(legacyError.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
1132 assert.match(legacyError.message, /Client input is not supported/)
1133 }))
1134
1135 it.effect("should reject prompt content when the negotiated schema cannot represent it", () =>
1136 Effect.gen(function*() {
1137 const fixture = yield* makeFixture()
1138 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1139 const marchClient = yield* initialize(fixture.post, "2025-03-26")
1140 const juneClient = yield* initialize(fixture.post, "2025-06-18")
1141
1142 assert.strictEqual(
1143 errorOf(yield* oldClient.request("prompts/get", { name: "audio-prompt" })).code,
1144 McpSchema.INVALID_PARAMS_ERROR_CODE
1145 )
1146 assert.property(
1147 resultOf(yield* marchClient.request("prompts/get", { name: "audio-prompt" })),
1148 "messages"
1149 )
1150 assert.strictEqual(
1151 errorOf(yield* marchClient.request("prompts/get", { name: "resource-link-prompt" })).code,
1152 McpSchema.INVALID_PARAMS_ERROR_CODE
1153 )
1154 assert.property(
1155 resultOf(yield* juneClient.request("prompts/get", { name: "resource-link-prompt" })),
1156 "messages"
1157 )
1158 }))
1159
1160 it.effect("should project canonical notifications when encoding each protocol revision", () =>
1161 Effect.gen(function*() {
1162 for (
1163 const protocol of [
1164 McpProtocol.v2024_11_05,
1165 McpProtocol.v2025_03_26,
1166 McpProtocol.v2025_06_18
1167 ]
1168 ) {
1169 const metadata = { source: "test" }
1170 const progress = yield* protocol.projectNotification(McpCore.ServerNotification.Progress({
1171 progressToken: "task-1",
1172 progress: 1,
1173 total: 2,
1174 message: "half way",
1175 metadata
1176 }))
1177 assert.isDefined(progress)
1178 assert.strictEqual(progress.tag, "notifications/progress")
1179 assert.deepStrictEqual(progress.payload, {
1180 _meta: metadata,
1181 progressToken: "task-1",
1182 progress: 1,
1183 total: 2,
1184 message: protocol.protocolVersion === "2024-11-05" ? undefined : "half way"
1185 })
1186
1187 for (
1188 const [notification, expected] of [
1189 [
1190 McpCore.ServerNotification.Cancelled({ requestId: 1, reason: "done", metadata }),
1191 { tag: "notifications/cancelled", payload: { _meta: metadata, requestId: 1, reason: "done" } }
1192 ],
1193 [
1194 McpCore.ServerNotification.LoggingMessage({
1195 level: "info",
1196 logger: "test",
1197 data: { ready: true },
1198 metadata
1199 }),
1200 {
1201 tag: "notifications/message",
1202 payload: { _meta: metadata, level: "info", logger: "test", data: { ready: true } }
1203 }
1204 ],
1205 [
1206 McpCore.ServerNotification.ResourceUpdated({ uri: "test://resource", metadata }),
1207 {
1208 tag: "notifications/resources/updated",
1209 payload: { _meta: metadata, uri: "test://resource" }
1210 }
1211 ],
1212 [
1213 McpCore.ServerNotification.ResourcesChanged({ metadata }),
1214 { tag: "notifications/resources/list_changed", payload: { _meta: metadata } }
1215 ],
1216 [
1217 McpCore.ServerNotification.ToolsChanged({ metadata }),
1218 { tag: "notifications/tools/list_changed", payload: { _meta: metadata } }
1219 ],
1220 [
1221 McpCore.ServerNotification.PromptsChanged({ metadata }),
1222 { tag: "notifications/prompts/list_changed", payload: { _meta: metadata } }
1223 ]
1224 ] as const
1225 ) {
1226 assert.deepStrictEqual(yield* protocol.projectNotification(notification), expected)
1227 }
1228 }
1229
1230 const elicitationComplete = McpCore.ServerNotification.ElicitationComplete({ elicitationId: "elicitation-1" })
1231 assert.deepStrictEqual(yield* McpProtocol.v2025_11_25.projectNotification(elicitationComplete), {
1232 tag: "notifications/elicitation/complete",
1233 payload: { elicitationId: "elicitation-1" }
1234 })
1235 for (
1236 const protocol of [
1237 McpProtocol.v2024_11_05,
1238 McpProtocol.v2025_03_26,
1239 McpProtocol.v2025_06_18
1240 ]
1241 ) {
1242 assert.isUndefined(yield* protocol.projectNotification(elicitationComplete))
1243 }
1244 }))
1245
1246 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const) {
1247 it.effect(`should preserve server extensions when initializing ${protocolVersion} requests`, () =>
1248 Effect.gen(function*() {
1249 const extensions = { "example/extension": { enabled: true } }
1250 const fixture = yield* makeFixture(extensions)
1251 const client = yield* initialize(fixture.post, protocolVersion)
1252
1253 assert.deepNestedPropertyVal(client.initializeResult, "capabilities.extensions", extensions)
1254 }))
1255 }
1256
1257 it.effect("should omit elicitation when the negotiated protocol predates v2025-06-18", () =>
1258 Effect.gen(function*() {
1259 const fixture = yield* makeFixture()
1260 for (const protocolVersion of ["2024-11-05", "2025-03-26"] as const) {
1261 const client = yield* initialize(fixture.post, protocolVersion, {
1262 capabilities: { elicitation: {} }
1263 })
1264 assert.notProperty(client.initializeResult.capabilities, "elicitation")
1265 assert.strictEqual(
1266 textResult(yield* client.request("tools/call", { name: "capability" })),
1267 "{}"
1268 )
1269 }
1270 }))
1271
1272 // Conformance: tools-call-with-progress
1273 it.effect("should expose current request metadata when invoking a handler", () =>
1274 Effect.gen(function*() {
1275 const fixture = yield* makeFixture()
1276 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) {
1277 const response = yield* fixture.post({
1278 jsonrpc: "2.0",
1279 id: 1,
1280 method: "initialize",
1281 params: {
1282 protocolVersion,
1283 capabilities: {},
1284 clientInfo: { name: "metadata-client", version: "1.0.0" },
1285 _meta: { progressToken: protocolVersion }
1286 }
1287 })
1288 const sessionId = response.headers.get("Mcp-Session-Id")
1289 assert.isNotNull(sessionId)
1290 const toolResponse = yield* fixture.post({
1291 jsonrpc: "2.0",
1292 id: 2,
1293 method: "tools/call",
1294 params: {
1295 name: "initialize-metadata",
1296 _meta: { progressToken: `call-${protocolVersion}` }
1297 }
1298 }, {
1299 "Mcp-Protocol-Version": protocolVersion,
1300 "Mcp-Session-Id": sessionId
1301 })
1302 const body = yield* readJsonRpcResponse(toolResponse)
1303 assert.strictEqual(textResult(body), JSON.stringify({ progressToken: `call-${protocolVersion}` }))
1304 }
1305 }))
1306
1307 it.effect("should reject elicitation before transport when the protocol predates v2025-06-18", () =>
1308 Effect.gen(function*() {
1309 let sends = 0
1310 const reverseProtocol = yield* RpcClient.Protocol.make(() =>
1311 Effect.succeed({
1312 send: () =>
1313 Effect.sync(() => {
1314 sends++
1315 }),
1316 supportsAck: true,
1317 supportsTransferables: false,
1318 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
1319 })
1320 )
1321 for (
1322 const protocol of [
1323 McpProtocol.v2024_11_05,
1324 McpProtocol.v2025_03_26
1325 ]
1326 ) {
1327 const client = yield* protocol.makeReverseClient({
1328 protocolVersion: protocol.protocolVersion,
1329 clientCapabilities: { elicitation: {} },
1330 clientInfo: { name: "test", version: "1.0.0" }
1331 }).pipe(
1332 Effect.provideService(RpcClient.Protocol, reverseProtocol)
1333 )
1334 const error = yield* client.elicit({
1335 message: "test",
1336 requestedSchema: {
1337 type: "object",
1338 properties: {}
1339 }
1340 }).pipe(Effect.flip)
1341 assert.instanceOf(error, McpSchema.McpReverseOperationUnsupported)
1342 }
1343 assert.strictEqual(sends, 0)
1344 }).pipe(Effect.scoped))
1345
1346 it.effect("should reject direct reverse operations when the selected protocol is stateless", () =>
1347 Effect.gen(function*() {
1348 let sends = 0
1349 const reverseProtocol = yield* RpcClient.Protocol.make(() =>
1350 Effect.succeed({
1351 send: () =>
1352 Effect.sync(() => {
1353 sends++
1354 }),
1355 supportsAck: true,
1356 supportsTransferables: false,
1357 supportsStructuredClone: false,
1358 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
1359 })
1360 )
1361 const protocol = McpProtocol.v2026_07_28
1362 const client = yield* protocol.makeReverseClient({
1363 protocolVersion: protocol.protocolVersion,
1364 clientCapabilities: {},
1365 clientInfo: { name: "test", version: "1.0.0" }
1366 }).pipe(
1367 Effect.provideService(RpcClient.Protocol, reverseProtocol)
1368 )
1369 const operations: ReadonlyArray<
1370 readonly [
1371 string,
1372 Effect.Effect<
1373 unknown,
1374 McpSchema.McpReverseOperationError | McpSchema.McpReverseOperationUnsupported
1375 >
1376 ]
1377 > = [
1378 ["roots/list", client.listRoots()],
1379 [
1380 "sampling/createMessage",
1381 client.createMessage(McpSchema.CreateMessage.payloadSchema.make({
1382 messages: [{ role: "user", content: { type: "text", text: "sample" } }],
1383 maxTokens: 64
1384 }))
1385 ],
1386 [
1387 "elicitation/create",
1388 client.elicit({
1389 message: "test",
1390 requestedSchema: { type: "object", properties: {} }
1391 })
1392 ]
1393 ]
1394
1395 for (const [operation, effect] of operations) {
1396 const error = yield* effect.pipe(Effect.flip)
1397 assert.instanceOf(error, McpSchema.McpReverseOperationUnsupported)
1398 assert.strictEqual(error.operation, operation)
1399 assert.strictEqual(error.protocolVersion, protocol.protocolVersion)
1400 }
1401 assert.strictEqual(sends, 0)
1402 }).pipe(Effect.scoped))
1403
1404 it.effect("should omit June fields when projecting a March tool descriptor", () =>
1405 Effect.gen(function*() {
1406 const fixture = yield* makeFixture()
1407 const client = yield* initialize(fixture.post, "2025-03-26")
1408 const shared = listedTools(yield* client.request("tools/list"))
1409 .find((tool) => tool.name === "shared")
1410
1411 assert.isDefined(shared)
1412 assert.deepStrictEqual(shared.inputSchema, { type: "object" })
1413 assert.deepStrictEqual(shared.annotations, {
1414 title: "Shared tool title",
1415 readOnlyHint: false,
1416 destructiveHint: true,
1417 idempotentHint: false,
1418 openWorldHint: true
1419 })
1420 assert.notProperty(shared, "title")
1421 assert.notProperty(shared, "outputSchema")
1422 assert.notProperty(shared, "_meta")
1423 }))
1424
1425 it.effect("should prefer the canonical title when projecting March annotations", () =>
1426 Effect.gen(function*() {
1427 const fixture = yield* makeLowLevelFixture()
1428 const marchClient = yield* initialize(fixture.post, "2025-03-26")
1429 const tool = listedTools(yield* marchClient.request("tools/list"))
1430 .find((tool) => tool.name === "title-precedence")
1431
1432 assert.isDefined(tool)
1433 assert.deepStrictEqual(tool.annotations, {
1434 title: "Canonical title",
1435 readOnlyHint: false,
1436 destructiveHint: true,
1437 idempotentHint: false,
1438 openWorldHint: true
1439 })
1440 }))
1441
1442 it.effect("should return a typed protocol error when a result cannot be projected", () =>
1443 Effect.gen(function*() {
1444 const fixture = yield* makeLowLevelFixture()
1445 const client = yield* initialize(fixture.post, "2024-11-05")
1446 const error = errorOf(
1447 yield* client.request("tools/call", {
1448 name: "projection-mismatch"
1449 })
1450 )
1451
1452 assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
1453 assert.match(error.message, /audio tool content is not supported by MCP 2024-11-05/)
1454 assert.strictEqual(fixture.state.projectionMismatchInvocations, 1)
1455 }))
1456
1457 it.effect("should route each session through its negotiated RPC group when revisions coexist", () =>
1458 Effect.gen(function*() {
1459 const fixture = yield* makeFixture()
1460 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1461 const currentClient = yield* initialize(fixture.post, "2025-06-18")
1462
1463 const oldShared = listedTools(yield* oldClient.request("tools/list"))
1464 .find((tool) => tool.name === "shared")
1465 const currentShared = listedTools(yield* currentClient.request("tools/list"))
1466 .find((tool) => tool.name === "shared")
1467
1468 assert.isDefined(oldShared)
1469 assert.isDefined(currentShared)
1470 assert.notProperty(oldShared, "title")
1471 assert.strictEqual(currentShared.title, "Shared tool title")
1472 }))
1473
1474 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const) {
1475 it.effect(`should expose the negotiated client profile when serving ${protocolVersion} requests`, () =>
1476 Effect.gen(function*() {
1477 const fixture = yield* makeFixture()
1478 const advertisedCapabilities = {
1479 extensions: { "example/extension": { enabled: true } },
1480 roots: { listChanged: true },
1481 sampling: {}
1482 }
1483 const allowed = yield* initialize(fixture.post, protocolVersion, {
1484 capabilities: advertisedCapabilities,
1485 clientInfo: { name: "allowed-client", version: "2.0.0" }
1486 })
1487 const denied = yield* initialize(fixture.post, protocolVersion, {
1488 capabilities: {},
1489 clientInfo: { name: "other-client", version: "1.0.0" }
1490 })
1491
1492 const allowedTools = listedTools(yield* allowed.request("tools/list"))
1493 const deniedTools = listedTools(yield* denied.request("tools/list"))
1494 assert.isTrue(allowedTools.some((tool) => tool.name === "capability-gated"))
1495 assert.isFalse(deniedTools.some((tool) => tool.name === "capability-gated"))
1496
1497 const observed = JSON.parse(textResult(
1498 yield* allowed.request("tools/call", { name: "capability" })
1499 ))
1500 assert.deepStrictEqual(observed, advertisedCapabilities)
1501 assert.strictEqual(fixture.state.capabilityInvocations, 1)
1502 }))
1503 }
1504
1505 it.effect("should preserve tool titles and hints when listing tools for each revision", () =>
1506 Effect.gen(function*() {
1507 const fixture = yield* makeFixture()
1508 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1509
1510 const oldTools = listedTools(yield* oldClient.request("tools/list"))
1511 const oldShared = oldTools.find((tool) => tool.name === "shared")
1512
1513 assert.isDefined(oldShared)
1514 assert.notProperty(oldShared, "title")
1515 assert.notProperty(oldShared, "annotations")
1516 assert.notProperty(oldShared, "_meta")
1517 assert.notProperty(oldShared, "outputSchema")
1518
1519 for (const protocolVersion of ["2025-06-18", "2025-11-25"] as const) {
1520 const client = yield* initialize(fixture.post, protocolVersion)
1521 const shared = listedTools(yield* client.request("tools/list"))
1522 .find((tool) => tool.name === "shared")
1523
1524 assert.isDefined(shared)
1525 assert.strictEqual(shared.title, "Shared tool title")
1526 assert.deepStrictEqual(shared.annotations, {
1527 title: "Shared tool title",
1528 readOnlyHint: false,
1529 destructiveHint: true,
1530 idempotentHint: false,
1531 openWorldHint: true
1532 })
1533 }
1534 }))
1535
1536 it.effect("should project outputSchema only when the protocol revision supports it", () =>
1537 Effect.gen(function*() {
1538 const fixture = yield* makeLowLevelFixture()
1539 const currentClient = yield* initialize(fixture.post, "2025-06-18")
1540 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1541 const currentTools = listedTools(yield* currentClient.request("tools/list"))
1542 const oldTools = listedTools(yield* oldClient.request("tools/list"))
1543 const currentSchemaOutput = currentTools.find((tool) => tool.name === "schema-output")
1544 const oldSchemaOutput = oldTools.find((tool) => tool.name === "schema-output")
1545 assert.isDefined(currentSchemaOutput)
1546 assert.isDefined(oldSchemaOutput)
1547 assert.deepStrictEqual(currentSchemaOutput.outputSchema, {
1548 type: "object",
1549 properties: {
1550 value: { type: "string" }
1551 },
1552 required: ["value"]
1553 })
1554 assert.deepStrictEqual(currentSchemaOutput._meta, { descriptor: "fixture" })
1555 assert.notProperty(oldSchemaOutput, "outputSchema")
1556 assert.notProperty(oldSchemaOutput, "_meta")
1557 }))
1558
1559 it.effect("should inline identified output schemas on the 2025 wire", () =>
1560 Effect.gen(function*() {
1561 const fixture = yield* makeFixture()
1562
1563 for (const protocolVersion of ["2025-06-18", "2025-11-25"] as const) {
1564 const client = yield* initialize(fixture.post, protocolVersion)
1565 const tools = listedTools(yield* client.request("tools/list"))
1566 const tool = tools.find((tool) => tool.name === "identified-result")
1567 assert.isDefined(tool)
1568 assert.deepStrictEqual(tool.outputSchema, {
1569 type: "object",
1570 properties: { value: { type: "string" } },
1571 required: ["value"],
1572 additionalProperties: true,
1573 $defs: {
1574 IdentifiedResult: {
1575 type: "object",
1576 properties: { value: { type: "string" } },
1577 required: ["value"],
1578 additionalProperties: true
1579 }
1580 }
1581 })
1582 }
1583 }))
1584
1585 it.effect("should project non-object JSON Toolkit outputs only for the July protocol", () =>
1586 Effect.gen(function*() {
1587 const fixture = yield* makeFixture()
1588
1589 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const) {
1590 const client = yield* initialize(fixture.post, protocolVersion)
1591 const tools = listedTools(yield* client.request("tools/list"))
1592 for (const name of ["shared", "json-array", "union-result"]) {
1593 const tool = tools.find((tool) => tool.name === name)
1594 assert.isDefined(tool)
1595 assert.notProperty(tool, "outputSchema")
1596 }
1597
1598 for (const name of ["shared", "json-array"]) {
1599 const result = resultOf(yield* client.request("tools/call", { name }))
1600 assert.notProperty(result, "structuredContent")
1601 }
1602 const text = "| package | version |\n| effect | \"4.0\" |"
1603 const stringResult = resultOf(
1604 yield* client.request("tools/call", {
1605 name: "validated",
1606 arguments: { value: text }
1607 })
1608 )
1609 assert.deepStrictEqual(stringResult.content, [{ type: "text", text }])
1610 assert.notProperty(stringResult, "structuredContent")
1611 }
1612
1613 const listResponse = yield* fixture.post(
1614 modernRequest(42, "tools/list"),
1615 { ...modernHeaders("tools/list"), "Mcp-Name": "toolkit-output" }
1616 )
1617 const listResult = listedTools(
1618 yield* Effect.promise<unknown>(() => listResponse.json()).pipe(
1619 Effect.flatMap(decodeJsonRpcResponse)
1620 )
1621 )
1622 assert.deepStrictEqual(listResult.find((tool) => tool.name === "shared")?.outputSchema, { type: "string" })
1623 assert.deepStrictEqual(listResult.find((tool) => tool.name === "json-array")?.outputSchema, {
1624 type: "array",
1625 prefixItems: [{ type: "string" }, { type: "null" }],
1626 minItems: 2,
1627 maxItems: 2
1628 })
1629 assert.deepStrictEqual(listResult.find((tool) => tool.name === "union-result")?.outputSchema, {
1630 anyOf: [
1631 {
1632 type: "object",
1633 properties: {
1634 _tag: { type: "string", enum: ["a"] },
1635 a: {
1636 anyOf: [
1637 { type: "number" },
1638 { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }
1639 ]
1640 }
1641 },
1642 required: ["_tag", "a"],
1643 additionalProperties: true
1644 },
1645 {
1646 type: "object",
1647 properties: {
1648 _tag: { type: "string", enum: ["b"] },
1649 b: { type: "string" }
1650 },
1651 required: ["_tag", "b"],
1652 additionalProperties: true
1653 }
1654 ]
1655 })
1656
1657 const callResponse = yield* fixture.post(
1658 modernRequest(43, "tools/call", { name: "shared", arguments: {} }),
1659 { ...modernHeaders("tools/call"), "Mcp-Name": "shared" }
1660 )
1661 const callResult = resultOf(
1662 yield* Effect.promise<unknown>(() => callResponse.json()).pipe(
1663 Effect.flatMap(decodeJsonRpcResponse)
1664 )
1665 )
1666 assert.strictEqual(callResult.structuredContent, "shared-result")
1667 assert.deepStrictEqual(callResult.content, [{ type: "text", text: JSON.stringify("shared-result") }])
1668
1669 const arrayCallResponse = yield* fixture.post(
1670 modernRequest(44, "tools/call", { name: "json-array", arguments: {} }),
1671 { ...modernHeaders("tools/call"), "Mcp-Name": "json-array" }
1672 )
1673 const arrayCallResult = resultOf(
1674 yield* Effect.promise<unknown>(() => arrayCallResponse.json()).pipe(
1675 Effect.flatMap(decodeJsonRpcResponse)
1676 )
1677 )
1678 assert.deepStrictEqual(arrayCallResult.structuredContent, ["array", null])
1679 }))
1680
1681 it.effect("should encode binary content for every revision that can represent it", () =>
1682 Effect.gen(function*() {
1683 const fixture = yield* makeLowLevelFixture()
1684 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) {
1685 const client = yield* initialize(fixture.post, protocolVersion)
1686 const image = resultOf(yield* client.request("tools/call", { name: "image" }))
1687 assert.deepStrictEqual(image.content, [{
1688 type: "image",
1689 data: "AQID",
1690 mimeType: "image/png"
1691 }])
1692
1693 const embedded = resultOf(
1694 yield* client.request("tools/call", { name: "binary-resource" })
1695 )
1696 assert.deepStrictEqual(embedded.content, [{
1697 type: "resource",
1698 resource: {
1699 uri: "file:///binary.dat",
1700 mimeType: "application/octet-stream",
1701 blob: "BwgJ"
1702 }
1703 }])
1704 }
1705
1706 for (const protocolVersion of ["2025-03-26", "2025-06-18"] as const) {
1707 const client = yield* initialize(fixture.post, protocolVersion)
1708 const audio = resultOf(yield* client.request("tools/call", { name: "audio" }))
1709 assert.deepStrictEqual(audio.content, [{
1710 type: "audio",
1711 data: "BAUG",
1712 mimeType: "audio/wav"
1713 }])
1714 }
1715 }))
1716
1717 it.effect("should project structured content according to the negotiated revision", () =>
1718 Effect.gen(function*() {
1719 const fixture = yield* makeLowLevelFixture()
1720 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const) {
1721 const client = yield* initialize(fixture.post, protocolVersion)
1722 const mirror = resultOf(yield* client.request("tools/call", { name: "structured-string-mirror" }))
1723 const custom = resultOf(yield* client.request("tools/call", { name: "structured-scalar" }))
1724 const multiple = resultOf(yield* client.request("tools/call", { name: "structured-string-multiple" }))
1725 assert.deepStrictEqual(mirror.content, [{ type: "text", text: "fixture" }])
1726 assert.deepStrictEqual(custom.content, [{ type: "text", text: "structured" }])
1727 assert.deepStrictEqual(multiple.content, [
1728 { type: "text", text: JSON.stringify("fixture") },
1729 { type: "text", text: "extra" }
1730 ])
1731 assert.notProperty(mirror, "structuredContent")
1732 assert.notProperty(custom, "structuredContent")
1733 assert.notProperty(multiple, "structuredContent")
1734 }
1735 for (const protocolVersion of ["2024-11-05", "2025-03-26"] as const) {
1736 const client = yield* initialize(fixture.post, protocolVersion)
1737 const objectResult = resultOf(
1738 yield* client.request("tools/call", { name: "structured-object" })
1739 )
1740 const scalarResult = resultOf(
1741 yield* client.request("tools/call", { name: "structured-scalar" })
1742 )
1743 assert.notProperty(objectResult, "structuredContent")
1744 assert.notProperty(scalarResult, "structuredContent")
1745 }
1746
1747 for (const protocolVersion of ["2025-06-18", "2025-11-25"] as const) {
1748 const client = yield* initialize(fixture.post, protocolVersion)
1749 const objectResult = resultOf(
1750 yield* client.request("tools/call", { name: "structured-object" })
1751 )
1752 const scalarResult = resultOf(
1753 yield* client.request("tools/call", { name: "structured-scalar" })
1754 )
1755 assert.deepStrictEqual(objectResult.structuredContent, { value: "fixture" })
1756 assert.notProperty(scalarResult, "structuredContent")
1757 }
1758
1759 const modernResponse = yield* fixture.post(
1760 modernRequest(41, "tools/call", { name: "structured-scalar", arguments: {} }),
1761 { ...modernHeaders("tools/call"), "Mcp-Name": "structured-scalar" }
1762 )
1763 const modernResult = resultOf(
1764 yield* Effect.promise<unknown>(() => modernResponse.json()).pipe(
1765 Effect.flatMap(decodeJsonRpcResponse)
1766 )
1767 )
1768 assert.strictEqual(modernResult.structuredContent, "fixture")
1769 const modernMirrorResponse = yield* fixture.post(
1770 modernRequest(42, "tools/call", { name: "structured-string-mirror", arguments: {} }),
1771 { ...modernHeaders("tools/call"), "Mcp-Name": "structured-string-mirror" }
1772 )
1773 const modernMirror = resultOf(
1774 yield* Effect.promise<unknown>(() => modernMirrorResponse.json()).pipe(
1775 Effect.flatMap(decodeJsonRpcResponse)
1776 )
1777 )
1778 assert.strictEqual(modernMirror.structuredContent, "fixture")
1779 assert.deepStrictEqual(modernMirror.content, [{ type: "text", text: JSON.stringify("fixture") }])
1780 }))
1781
1782 it.effect("should preserve only supported metadata when projecting embedded resource content", () =>
1783 Effect.gen(function*() {
1784 const fixture = yield* makeLowLevelFixture()
1785
1786 for (const protocolVersion of ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const) {
1787 const client = yield* initialize(fixture.post, protocolVersion)
1788 const result = resultOf(
1789 yield* client.request("tools/call", { name: "annotated-resource" })
1790 )
1791 const [content] = Schema.decodeUnknownSync(
1792 Schema.NonEmptyArray(Schema.Record(Schema.String, Schema.Unknown))
1793 )(result.content)
1794 assert.deepStrictEqual(result._meta, { result: "fixture" })
1795 const resource = Schema.decodeUnknownSync(
1796 Schema.Record(Schema.String, Schema.Unknown)
1797 )(content.resource)
1798 assert.deepStrictEqual(
1799 content.annotations,
1800 protocolVersion === "2025-11-25"
1801 ? {
1802 audience: ["assistant"],
1803 priority: 0.75,
1804 lastModified: "2026-08-13T00:00:00Z"
1805 }
1806 : {
1807 audience: ["assistant"],
1808 priority: 0.75
1809 }
1810 )
1811 assert.strictEqual(resource.uri, "file:///annotated.txt")
1812 assert.strictEqual(resource.mimeType, "text/plain")
1813 assert.strictEqual(resource.text, "annotated")
1814 if (protocolVersion === "2025-11-25" || protocolVersion === "2025-06-18") {
1815 assert.deepStrictEqual(resource._meta, { source: "fixture" })
1816 assert.deepStrictEqual(content._meta, { content: "fixture" })
1817 } else {
1818 assert.notProperty(resource, "_meta")
1819 assert.notProperty(content, "_meta")
1820 }
1821 }
1822 }))
1823
1824 it.effect("should preserve result metadata while omitting June-only metadata for older revisions", () =>
1825 Effect.gen(function*() {
1826 const fixture = yield* makeLowLevelFixture()
1827 for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) {
1828 const client = yield* initialize(fixture.post, protocolVersion)
1829 const resources = resultOf(yield* client.request("resources/list"))
1830 const resource = (resources.resources as Array<Record<string, unknown>>).find(
1831 (_) => _.uri === "file:///metadata.txt"
1832 )!
1833 const read = resultOf(
1834 yield* client.request("resources/read", { uri: "file:///metadata.txt" })
1835 )
1836 const prompts = resultOf(yield* client.request("prompts/list"))
1837 const promptDescriptor = (prompts.prompts as Array<Record<string, unknown>>).find(
1838 (_) => _.name === "metadata-prompt"
1839 )!
1840 const prompt = resultOf(
1841 yield* client.request("prompts/get", { name: "metadata-prompt" })
1842 )
1843 const completion = resultOf(
1844 yield* client.request("completion/complete", {
1845 ref: { type: "ref/prompt", name: "metadata-prompt" },
1846 argument: { name: "value", value: "m" }
1847 })
1848 )
1849
1850 assert.deepStrictEqual(read._meta, { result: "fixture" })
1851 assert.deepStrictEqual(prompt._meta, { result: "fixture" })
1852 assert.deepStrictEqual(completion._meta, { result: "fixture" })
1853 const content = (prompt.messages as Array<Record<string, any>>)[0].content
1854 if (protocolVersion === "2025-06-18") {
1855 assert.deepStrictEqual(resource._meta, { descriptor: "fixture" })
1856 assert.deepStrictEqual(promptDescriptor._meta, { descriptor: "fixture" })
1857 assert.deepStrictEqual(content._meta, { content: "fixture" })
1858 } else {
1859 assert.notProperty(resource, "_meta")
1860 assert.notProperty(promptDescriptor, "_meta")
1861 assert.notProperty(content, "_meta")
1862 }
1863 }
1864 }))
1865
1866 it.effect("should expose implementation metadata and descriptor icons when supported by the protocol", () =>
1867 Effect.gen(function*() {
1868 const fixture = yield* makeLowLevelFixture()
1869 const november = yield* initialize(fixture.post, "2025-11-25")
1870 const june = yield* initialize(fixture.post, "2025-06-18")
1871
1872 assert.deepStrictEqual(november.initializeResult.serverInfo, {
1873 name: "LowLevelProtocolAdapterServer",
1874 version: "1.0.0",
1875 description: "Low-level protocol adapter fixture",
1876 websiteUrl: "https://example.com/low-level-mcp",
1877 icons: [{
1878 src: "https://example.com/server.svg",
1879 mimeType: "image/svg+xml",
1880 sizes: ["48x48", "any"],
1881 theme: "dark"
1882 }]
1883 })
1884 assert.deepStrictEqual(june.initializeResult.serverInfo, {
1885 name: "LowLevelProtocolAdapterServer",
1886 version: "1.0.0"
1887 })
1888
1889 const resources = resultOf(yield* november.request("resources/list"))
1890 const resource = (resources.resources as Array<Record<string, unknown>>).find(
1891 (_) => _.uri === "file:///metadata.txt"
1892 )
1893 assert.isDefined(resource)
1894 assert.deepStrictEqual(resource.icons, [{ src: "https://example.com/resource.svg" }])
1895
1896 const prompts = resultOf(yield* november.request("prompts/list"))
1897 const prompt = (prompts.prompts as Array<Record<string, unknown>>).find((_) => _.name === "metadata-prompt")
1898 assert.isDefined(prompt)
1899 assert.deepStrictEqual(prompt.icons, [{ src: "https://example.com/prompt.svg" }])
1900
1901 const tool = listedTools(yield* november.request("tools/list")).find((_) => _.name === "title-precedence")
1902 assert.isDefined(tool)
1903 assert.deepStrictEqual(tool.icons, [{ src: "https://example.com/tool.svg" }])
1904 }))
1905
1906 it.effect("should hide and reject a tool when its declaration is incompatible with the revision", () =>
1907 Effect.gen(function*() {
1908 const fixture = yield* makeFixture()
1909 const currentClient = yield* initialize(fixture.post, "2025-06-18")
1910 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1911
1912 const currentTools = listedTools(yield* currentClient.request("tools/list"))
1913 const oldTools = listedTools(yield* oldClient.request("tools/list"))
1914
1915 assert.isTrue(currentTools.some((tool) => tool.name === "structured-only"))
1916 assert.isFalse(oldTools.some((tool) => tool.name === "structured-only"))
1917
1918 const oldCall = yield* oldClient.request("tools/call", {
1919 name: "structured-only",
1920 arguments: {}
1921 })
1922 assert.property(oldCall, "error")
1923 assert.strictEqual(fixture.state.structuredInvocations, 0)
1924 }))
1925
1926 it.effect("should reject after invocation when a tool result cannot be projected", () =>
1927 Effect.gen(function*() {
1928 const fixture = yield* makeLowLevelFixture()
1929 const currentClient = yield* initialize(fixture.post, "2025-06-18")
1930 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1931
1932 const currentTools = listedTools(yield* currentClient.request("tools/list"))
1933 const oldTools = listedTools(yield* oldClient.request("tools/list"))
1934 assert.isTrue(currentTools.some((tool) => tool.name === "audio"))
1935 assert.isTrue(currentTools.some((tool) => tool.name === "resource-link"))
1936 assert.isTrue(oldTools.some((tool) => tool.name === "audio"))
1937 assert.isTrue(oldTools.some((tool) => tool.name === "resource-link"))
1938
1939 assert.property(
1940 yield* oldClient.request("tools/call", { name: "audio" }),
1941 "error"
1942 )
1943 assert.property(
1944 yield* oldClient.request("tools/call", { name: "resource-link" }),
1945 "error"
1946 )
1947 assert.strictEqual(fixture.state.audioInvocations, 1)
1948 assert.strictEqual(fixture.state.resourceLinkInvocations, 1)
1949 }))
1950
1951 it.effect("should return an error result for tool execution failures on 2025-11-25", () =>
1952 Effect.gen(function*() {
1953 const fixture = yield* makeLowLevelFixture()
1954 const client = yield* initialize(fixture.post, "2025-11-25")
1955 const result = resultOf(yield* client.request("tools/call", { name: "internal-failure" }))
1956 assert.isTrue(result.isError)
1957 assert.deepStrictEqual(result.content, [{ type: "text", text: "database unavailable" }])
1958 }))
1959
1960 it.effect("should report tool execution failures as internal errors on older revisions", () =>
1961 Effect.gen(function*() {
1962 const fixture = yield* makeLowLevelFixture()
1963 for (const protocolVersion of ["2025-06-18", "2025-03-26", "2024-11-05"] as const) {
1964 const client = yield* initialize(fixture.post, protocolVersion)
1965 const error = errorOf(yield* client.request("tools/call", { name: "internal-failure" }))
1966 assert.strictEqual(error.code, McpSchema.INTERNAL_ERROR_CODE)
1967 assert.strictEqual(error.message, "database unavailable")
1968 }
1969 }))
1970
1971 it.effect("should reject invalid structured content at the protocol serialization boundary", () =>
1972 Effect.gen(function*() {
1973 const fixture = yield* makeLowLevelFixture()
1974 const response = yield* fixture.post(
1975 modernRequest(50, "tools/call", { name: "invalid-structured-content", arguments: {} }),
1976 { ...modernHeaders("tools/call"), "Mcp-Name": "invalid-structured-content" }
1977 )
1978 const message = yield* readJsonRpcResponse(response)
1979
1980 assert.strictEqual(response.status, 200)
1981 assert.strictEqual(errorOf(message).code, McpSchema.INTERNAL_ERROR_CODE)
1982 }))
1983
1984 it("should reject a tool schema when inputSchema is not an object", () => {
1985 assert.throws(() =>
1986 Schema.decodeUnknownSync(McpSchema.Tool)({
1987 name: "invalid",
1988 inputSchema: "anything"
1989 })
1990 )
1991 })
1992
1993 it.effect("should list and invoke the latest tool when a name is registered twice", () =>
1994 Effect.gen(function*() {
1995 const fixture = yield* makeLowLevelFixture()
1996 const currentClient = yield* initialize(fixture.post, "2025-06-18")
1997 const oldClient = yield* initialize(fixture.post, "2024-11-05")
1998
1999 for (const client of [currentClient, oldClient]) {
2000 const duplicates = listedTools(
2001 yield* client.request("tools/list")
2002 ).filter((tool) => tool.name === "duplicate")
2003 assert.lengthOf(duplicates, 1)
2004 assert.strictEqual(duplicates[0]?.description, "second descriptor")
2005
2006 const result = resultOf(
2007 yield* client.request("tools/call", { name: "duplicate" })
2008 )
2009 assert.deepStrictEqual(result.content, [{ type: "text", text: "second" }])
2010 }
2011 assert.deepStrictEqual(fixture.state.duplicateInvocations, ["second", "second"])
2012 }))
2013})