| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Effect, Schema } from "effect" |
| 3 | import * as McpProtocol from "effect/ai/internal/mcpProtocol" |
| 4 | import * as McpProtocolRegistry from "effect/ai/internal/mcpProtocolRegistry" |
| 5 | import * as McpRuntime from "effect/ai/internal/mcpRuntime" |
| 6 | import * as McpSchema2025_06_18 from "effect/ai/internal/mcpSchema/v2025_06_18" |
| 7 | import type * as PublicMcpProtocol from "effect/ai/McpProtocol" |
| 8 | import * as McpSchema from "effect/ai/McpSchema" |
| 9 | import * as Rpc from "effect/rpc/Rpc" |
| 10 | import * as RpcGroup from "effect/rpc/RpcGroup" |
| 11 | |
| 12 | const 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 | |
| 72 | const request = (payload: unknown) => ({ |
| 73 | _tag: "Request" as const, |
| 74 | id: 1, |
| 75 | tag: "test/shape", |
| 76 | payload, |
| 77 | headers: [] |
| 78 | }) |
| 79 | |
| 80 | describe("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 | |
| 174 | describe("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 | |
| 197 | describe("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 | }) |