1import { assert, describe, it } from "@effect/vitest"
2import { Effect, Schema } from "effect"
3import * as McpProtocol from "effect/ai/internal/mcpProtocol"
4import * as McpProtocolRegistry from "effect/ai/internal/mcpProtocolRegistry"
5import * as McpRuntime from "effect/ai/internal/mcpRuntime"
6import * as McpSchema2025_06_18 from "effect/ai/internal/mcpSchema/v2025_06_18"
7import type * as PublicMcpProtocol from "effect/ai/McpProtocol"
8import * as McpSchema from "effect/ai/McpSchema"
9import * as Rpc from "effect/rpc/Rpc"
10import * as RpcGroup from "effect/rpc/RpcGroup"
11
12const makeTestProtocol = <
13 const Version extends string,
14 const Discriminator extends string
15>(
16 protocolVersion: Version,
17 discriminator: Discriminator
18) => {
19 const Payload = Schema.Struct({
20 protocol: Schema.Literal(discriminator),
21 value: Schema.String
22 })
23 const TestRequest = Rpc.make("test/shape", {
24 payload: Payload,
25 success: Schema.Struct({
26 protocol: Schema.Literal(protocolVersion),
27 value: Schema.String
28 })
29 })
30
31 return McpProtocol.make({
32 protocolVersion,
33 runtime: McpRuntime.stateful({
34 jsonRpc: { acceptsBatches: false },
35 http: { requiresVersionHeader: true }
36 }),
37 clientRpcs: RpcGroup.make(TestRequest),
38 clientNotificationRpcs: RpcGroup.make(),
39 serverRequestRpcs: RpcGroup.make(),
40 serverNotificationRpcs: RpcGroup.make(),
41 toReverseClient: () => ({
42 listRoots: () =>
43 Effect.fail(
44 new McpSchema.McpReverseOperationUnsupported({
45 operation: "roots/list",
46 protocolVersion: "2025-06-18",
47 reason: "Synthetic test adapter"
48 })
49 ),
50 createMessage: () =>
51 Effect.fail(
52 new McpSchema.McpReverseOperationUnsupported({
53 operation: "sampling/createMessage",
54 protocolVersion: "2025-06-18",
55 reason: "Synthetic test adapter"
56 })
57 ),
58 elicit: () =>
59 Effect.fail(
60 new McpSchema.McpReverseOperationUnsupported({
61 operation: "elicitation/create",
62 protocolVersion: "2025-06-18",
63 reason: "Synthetic test adapter"
64 })
65 )
66 }),
67 projectNotification: () => Effect.succeed(undefined),
68 normalizeCancellation: () => Effect.succeed({ requestId: "" })
69 })
70}
71
72const request = (payload: unknown) => ({
73 _tag: "Request" as const,
74 id: 1,
75 tag: "test/shape",
76 payload,
77 headers: []
78})
79
80describe("McpProtocolRegistry", () => {
81 it.effect("should reject an empty declaration when runtime input bypasses the non-empty type", () =>
82 Effect.gen(function*() {
83 const protocols = [] as unknown as Parameters<typeof McpProtocolRegistry.make>[0]
84
85 const error = yield* Effect.flip(McpProtocolRegistry.make(protocols))
86
87 assert.strictEqual(error._tag, "IllegalArgumentError")
88 assert.match(error.message, /at least one MCP protocol/)
89 }))
90
91 it.effect("should reject duplicate versions when distinct adapters declare the same version", () =>
92 Effect.gen(function*() {
93 const first = makeTestProtocol("test-a", "a")
94 const second = makeTestProtocol("test-a", "other-a")
95
96 const error = yield* Effect.flip(McpProtocolRegistry.make([first, second]))
97
98 assert.strictEqual(error._tag, "IllegalArgumentError")
99 assert.match(error.message, /Duplicate MCP protocol version: test-a/)
100 }))
101
102 it.effect("should select the matching adapter when the offered version is declared", () =>
103 Effect.gen(function*() {
104 const first = makeTestProtocol("test-a", "a")
105 const second = makeTestProtocol("test-b", "b")
106 const registry = yield* McpProtocolRegistry.make([first, second])
107 const selected: typeof first | typeof second = registry.select("test-b")
108
109 assert.strictEqual(selected, second)
110 }))
111
112 it.effect("should select the first declared adapter when the offered version is unsupported", () =>
113 Effect.gen(function*() {
114 const first = makeTestProtocol("test-a", "a")
115 const second = makeTestProtocol("test-b", "b")
116 const registry = yield* McpProtocolRegistry.make([first, second])
117
118 assert.strictEqual(registry.select("unsupported"), first)
119 }))
120
121 it.effect("should preserve declaration order when the source array mutates after registry creation", () =>
122 Effect.gen(function*() {
123 const first = makeTestProtocol("test-a", "a")
124 const second = makeTestProtocol("test-b", "b")
125 const protocols: [PublicMcpProtocol.AnyProtocolAdapter, PublicMcpProtocol.AnyProtocolAdapter] = [
126 first,
127 second
128 ]
129 const registry = yield* McpProtocolRegistry.make(protocols)
130
131 protocols.reverse()
132
133 assert.strictEqual(registry.select("unsupported"), first)
134 assert.strictEqual(registry.select("test-b"), second)
135 }))
136
137 it.effect("should route to only the selected namespace when protocol schemas are incompatible", () =>
138 Effect.gen(function*() {
139 const first = makeTestProtocol("test-a", "a")
140 const second = makeTestProtocol("test-b", "b")
141 const registry = yield* McpProtocolRegistry.make([first, second])
142 const selected = registry.select("test-a")
143
144 const selectedRequest = registry.routeClientRequest(
145 selected,
146 request({ protocol: "b", value: "wrong adapter" })
147 )
148 const unselectedRequest = registry.routeClientRequest(
149 second,
150 request({ protocol: "b", value: "other adapter" })
151 )
152
153 assert.notStrictEqual(selectedRequest.tag, unselectedRequest.tag)
154 assert.notStrictEqual(selectedRequest.tag, "test/shape")
155
156 const selectedRpc = first.clientRpcs.requests.get("test/shape")
157 const unselectedRpc = second.clientRpcs.requests.get("test/shape")
158 assert.isDefined(selectedRpc)
159 assert.isDefined(unselectedRpc)
160
161 const invalid = yield* Effect.exit(
162 selected.payloadCodecs(selectedRpc).decode(selectedRequest.payload)
163 )
164
165 assert.strictEqual(invalid._tag, "Failure")
166 const valid = yield* second.payloadCodecs(unselectedRpc).decode(unselectedRequest.payload)
167 assert.deepStrictEqual(valid, {
168 protocol: "b",
169 value: "other adapter"
170 })
171 }))
172})
173
174describe("MCP v2025-06-18 schema", () => {
175 it("accepts resource links in prompt messages", () => {
176 const message = Schema.decodeUnknownSync(McpSchema2025_06_18.PromptMessage)({
177 role: "user",
178 content: {
179 type: "resource_link",
180 uri: "file:///example.txt",
181 name: "example"
182 }
183 })
184
185 assert.strictEqual(message.content.type, "resource_link")
186 })
187
188 it("omits later core annotation fields", () => {
189 const annotations = Schema.decodeUnknownSync(McpSchema2025_06_18.Annotations)({
190 audience: ["user"],
191 lastModified: "2026-07-26"
192 })
193 assert.notProperty(annotations, "lastModified")
194 })
195})
196
197describe("McpProtocol errors", () => {
198 it("should preserve tagged unsupported errors without requiring the local class prototype", () => {
199 const unsupported = {
200 _tag: "McpReverseOperationUnsupported",
201 operation: "roots/list",
202 protocolVersion: "2025-06-18",
203 reason: "Client did not advertise roots"
204 }
205 assert.strictEqual<unknown>(McpProtocol.reverseError("roots/list")(unsupported), unsupported)
206
207 const cause = new Error("Transport failed")
208 const wrapped = McpProtocol.reverseError("roots/list")(cause)
209 assert.strictEqual(wrapped._tag, "McpReverseOperationError")
210 assert.propertyVal(wrapped, "cause", cause)
211 })
212})