| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import type * as McpProtocol from "effect/ai/McpProtocol" |
| 3 | import * as McpSchema from "effect/ai/McpSchema" |
| 4 | import * as Context from "effect/Context" |
| 5 | import * as Effect from "effect/Effect" |
| 6 | import * as Schema from "effect/Schema" |
| 7 | import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts" |
| 8 | import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" |
| 9 | |
| 10 | const decodeResources = Schema.decodeUnknownEffect(McpSchema.ListResourcesResult) |
| 11 | const decodeResourceTemplates = Schema.decodeUnknownEffect(McpSchema.ListResourceTemplatesResult) |
| 12 | const decodeReadResource = Schema.decodeUnknownEffect(McpSchema.ReadResourceResult) |
| 13 | const decodeResourceUpdated = Schema.decodeUnknownEffect(McpSchema.ResourceUpdatedNotification.payloadSchema) |
| 14 | const makeResource = (uri: string, name: string) => ({ |
| 15 | resource: new McpSchema.Resource({ uri, name }), |
| 16 | annotations: Context.empty(), |
| 17 | handle: Effect.succeed( |
| 18 | McpSchema.ReadResourceResult.make({ |
| 19 | contents: [{ uri, text: name }] |
| 20 | }) |
| 21 | ) |
| 22 | }) |
| 23 | |
| 24 | export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => |
| 25 | it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { |
| 26 | describe("Resources", () => { |
| 27 | // Shared capability contract; each dated entrypoint owns its normative specification revision. |
| 28 | describe("Capabilities", () => { |
| 29 | it.effect("MUST advertise resources when resources are registered", () => |
| 30 | Effect.gen(function*() { |
| 31 | const test = yield* McpConformance |
| 32 | const initialized = yield* test.initialize({ server: "features" }) |
| 33 | |
| 34 | assert.property(initialized.message.result.capabilities, "resources") |
| 35 | })) |
| 36 | |
| 37 | it.effect("MUST NOT advertise resources when resources are not supported", () => |
| 38 | Effect.gen(function*() { |
| 39 | const test = yield* McpConformance |
| 40 | const initialized = yield* test.initialize() |
| 41 | |
| 42 | assert.notProperty(initialized.message.result.capabilities, "resources") |
| 43 | })) |
| 44 | }) |
| 45 | |
| 46 | describe("Listing Resources", () => { |
| 47 | it.effect("MUST list every resource visible to the initialized client", () => |
| 48 | Effect.gen(function*() { |
| 49 | const test = yield* McpConformance |
| 50 | const initialized = yield* test.initialize({ server: "features" }) |
| 51 | yield* test.notifyInitialized(initialized) |
| 52 | const response = yield* test.send(initialized, { |
| 53 | jsonrpc: "2.0", |
| 54 | id: 2, |
| 55 | method: "resources/list", |
| 56 | params: {} |
| 57 | }) |
| 58 | const result = yield* test.decodeResult(response).pipe( |
| 59 | Effect.flatMap((message) => decodeResources(message.result)) |
| 60 | ) |
| 61 | |
| 62 | assert.deepStrictEqual(result.resources.map((resource) => resource.uri).sort(), [ |
| 63 | "file:///binary", |
| 64 | "file:///multiple", |
| 65 | "file:///test" |
| 66 | ]) |
| 67 | })) |
| 68 | |
| 69 | it.effect("SCHEMA preserves resource URI, name, description, and MIME type", () => |
| 70 | Effect.gen(function*() { |
| 71 | const test = yield* McpConformance |
| 72 | const initialized = yield* test.initialize({ server: "features" }) |
| 73 | yield* test.notifyInitialized(initialized) |
| 74 | const response = yield* test.send(initialized, { |
| 75 | jsonrpc: "2.0", |
| 76 | id: 2, |
| 77 | method: "resources/list", |
| 78 | params: {} |
| 79 | }) |
| 80 | const result = yield* test.decodeResult(response).pipe( |
| 81 | Effect.flatMap((message) => decodeResources(message.result)) |
| 82 | ) |
| 83 | |
| 84 | const resource = result.resources.find((resource) => resource.uri === "file:///test") |
| 85 | assert.isDefined(resource) |
| 86 | assert.deepInclude(resource, { |
| 87 | uri: "file:///test", |
| 88 | name: "TestResource", |
| 89 | description: "A test resource", |
| 90 | mimeType: "text/plain" |
| 91 | }) |
| 92 | })) |
| 93 | }) |
| 94 | |
| 95 | describe("Reading Resources", () => { |
| 96 | it.effect("MUST read text resource contents", () => |
| 97 | Effect.gen(function*() { |
| 98 | const test = yield* McpConformance |
| 99 | const initialized = yield* test.initialize({ server: "features" }) |
| 100 | yield* test.notifyInitialized(initialized) |
| 101 | const response = yield* test.send(initialized, { |
| 102 | jsonrpc: "2.0", |
| 103 | id: 2, |
| 104 | method: "resources/read", |
| 105 | params: { uri: "file:///test" } |
| 106 | }) |
| 107 | const result = yield* test.decodeResult(response).pipe( |
| 108 | Effect.flatMap((message) => decodeReadResource(message.result)) |
| 109 | ) |
| 110 | |
| 111 | assert.deepStrictEqual(result.contents, [{ |
| 112 | uri: "file:///test", |
| 113 | mimeType: "text/plain", |
| 114 | text: "test" |
| 115 | }]) |
| 116 | })) |
| 117 | it.effect("MUST read binary resource contents as base64", () => |
| 118 | Effect.gen(function*() { |
| 119 | const test = yield* McpConformance |
| 120 | const initialized = yield* test.initialize({ server: "features" }) |
| 121 | yield* test.notifyInitialized(initialized) |
| 122 | const response = yield* test.send(initialized, { |
| 123 | jsonrpc: "2.0", |
| 124 | id: 2, |
| 125 | method: "resources/read", |
| 126 | params: { uri: "file:///binary" } |
| 127 | }) |
| 128 | const body = yield* Effect.promise<unknown>(() => response.json()) |
| 129 | const message = yield* Schema.decodeUnknownEffect(Schema.Struct({ |
| 130 | result: Schema.Struct({ |
| 131 | contents: Schema.Array(Schema.Struct({ |
| 132 | uri: Schema.String, |
| 133 | mimeType: Schema.String, |
| 134 | blob: Schema.String |
| 135 | })) |
| 136 | }) |
| 137 | }))(body) |
| 138 | |
| 139 | assert.deepStrictEqual(message.result.contents, [{ |
| 140 | uri: "file:///binary", |
| 141 | mimeType: "application/octet-stream", |
| 142 | blob: "AQID" |
| 143 | }]) |
| 144 | })) |
| 145 | it.effect("SCHEMA preserves the resource URI and MIME type in returned contents", () => |
| 146 | Effect.gen(function*() { |
| 147 | const test = yield* McpConformance |
| 148 | const initialized = yield* test.initialize({ server: "features" }) |
| 149 | yield* test.notifyInitialized(initialized) |
| 150 | const response = yield* test.send(initialized, { |
| 151 | jsonrpc: "2.0", |
| 152 | id: 2, |
| 153 | method: "resources/read", |
| 154 | params: { uri: "file:///test" } |
| 155 | }) |
| 156 | const result = yield* test.decodeResult(response).pipe( |
| 157 | Effect.flatMap((message) => decodeReadResource(message.result)) |
| 158 | ) |
| 159 | |
| 160 | assert.strictEqual(result.contents[0]?.uri, "file:///test") |
| 161 | assert.strictEqual(result.contents[0]?.mimeType, "text/plain") |
| 162 | })) |
| 163 | it.effect("MUST return multiple resource contents in order", () => |
| 164 | Effect.gen(function*() { |
| 165 | const test = yield* McpConformance |
| 166 | const initialized = yield* test.initialize({ server: "features" }) |
| 167 | yield* test.notifyInitialized(initialized) |
| 168 | const response = yield* test.send(initialized, { |
| 169 | jsonrpc: "2.0", |
| 170 | id: 2, |
| 171 | method: "resources/read", |
| 172 | params: { uri: "file:///multiple" } |
| 173 | }) |
| 174 | const result = yield* test.decodeResult(response).pipe( |
| 175 | Effect.flatMap((message) => decodeReadResource(message.result)) |
| 176 | ) |
| 177 | |
| 178 | assert.deepStrictEqual(result.contents.map((content) => content.uri), [ |
| 179 | "file:///multiple#first", |
| 180 | "file:///multiple#second" |
| 181 | ]) |
| 182 | })) |
| 183 | |
| 184 | it.effect("should return the revision-specific error when the resource URI is unknown", () => |
| 185 | Effect.gen(function*() { |
| 186 | const test = yield* McpConformance |
| 187 | const initialized = yield* test.initialize({ server: "features" }) |
| 188 | yield* test.notifyInitialized(initialized) |
| 189 | const response = yield* test.send(initialized, { |
| 190 | jsonrpc: "2.0", |
| 191 | id: 2, |
| 192 | method: "resources/read", |
| 193 | params: { uri: "file:///missing" } |
| 194 | }) |
| 195 | const error = yield* test.decodeError(response) |
| 196 | |
| 197 | assert.strictEqual( |
| 198 | error.error.code, |
| 199 | protocol.protocolVersion === "2026-07-28" ? -32602 : -32002 |
| 200 | ) |
| 201 | })) |
| 202 | }) |
| 203 | |
| 204 | describe("Resource Templates", () => { |
| 205 | it.effect("MUST list every registered resource template", () => |
| 206 | Effect.gen(function*() { |
| 207 | const test = yield* McpConformance |
| 208 | const initialized = yield* test.initialize({ server: "features" }) |
| 209 | yield* test.notifyInitialized(initialized) |
| 210 | const response = yield* test.send(initialized, { |
| 211 | jsonrpc: "2.0", |
| 212 | id: 2, |
| 213 | method: "resources/templates/list", |
| 214 | params: {} |
| 215 | }) |
| 216 | const result = yield* test.decodeResult(response).pipe( |
| 217 | Effect.flatMap((message) => decodeResourceTemplates(message.result)) |
| 218 | ) |
| 219 | |
| 220 | const template = result.resourceTemplates.find( |
| 221 | (template) => template.name === "TestResourceTemplate" |
| 222 | ) |
| 223 | assert.deepStrictEqual( |
| 224 | template && { |
| 225 | uriTemplate: template.uriTemplate, |
| 226 | name: template.name, |
| 227 | description: template.description, |
| 228 | mimeType: template.mimeType |
| 229 | }, |
| 230 | { |
| 231 | uriTemplate: "file:///template/{path}", |
| 232 | name: "TestResourceTemplate", |
| 233 | description: "A test resource template", |
| 234 | mimeType: "text/plain" |
| 235 | } |
| 236 | ) |
| 237 | })) |
| 238 | |
| 239 | it.effect("MUST match and decode a concrete resource-template URI", () => |
| 240 | Effect.gen(function*() { |
| 241 | const test = yield* McpConformance |
| 242 | const initialized = yield* test.initialize({ server: "features" }) |
| 243 | yield* test.notifyInitialized(initialized) |
| 244 | const response = yield* test.send(initialized, { |
| 245 | jsonrpc: "2.0", |
| 246 | id: 2, |
| 247 | method: "resources/read", |
| 248 | params: { uri: "file:///template/encoded%20path" } |
| 249 | }) |
| 250 | const result = yield* test.decodeResult(response).pipe( |
| 251 | Effect.flatMap((message) => decodeReadResource(message.result)) |
| 252 | ) |
| 253 | |
| 254 | assert.strictEqual( |
| 255 | result.contents[0] && "text" in result.contents[0] ? result.contents[0].text : undefined, |
| 256 | "file:///template/encoded%20path:encoded path" |
| 257 | ) |
| 258 | })) |
| 259 | it.effect("MUST not invoke the handler when template parameter decoding fails", () => |
| 260 | Effect.gen(function*() { |
| 261 | const test = yield* McpConformance |
| 262 | yield* test.resetObservations |
| 263 | const initialized = yield* test.initialize({ server: "features" }) |
| 264 | yield* test.notifyInitialized(initialized) |
| 265 | const response = yield* test.send(initialized, { |
| 266 | jsonrpc: "2.0", |
| 267 | id: 2, |
| 268 | method: "resources/read", |
| 269 | params: { uri: "file:///numeric/not-a-number" } |
| 270 | }) |
| 271 | const error = yield* test.decodeError(response) |
| 272 | |
| 273 | assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) |
| 274 | assert.strictEqual((yield* test.observations).resourceTemplateInvocations, 0) |
| 275 | })) |
| 276 | }) |
| 277 | }) |
| 278 | }) |
| 279 | |
| 280 | export const statefulLegacySuite = ( |
| 281 | protocol: McpProtocol.ProtocolAdapter<McpProtocol.StatefulProtocolVersion>, |
| 282 | layer: McpConformanceLayer |
| 283 | ) => |
| 284 | it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { |
| 285 | // https://modelcontextprotocol.io/specification/2025-11-25/server/resources |
| 286 | describe("Resources > Legacy notifications", () => { |
| 287 | it.effect("should advertise resource list-change notifications when supported", () => |
| 288 | Effect.gen(function*() { |
| 289 | const test = yield* McpConformance |
| 290 | const initialized = yield* test.initialize({ server: "features" }) |
| 291 | |
| 292 | assert.strictEqual(initialized.message.result.capabilities.resources?.listChanged, true) |
| 293 | })) |
| 294 | |
| 295 | it.effect("should send a resource list-change notification when the advertised list changes", () => |
| 296 | Effect.gen(function*() { |
| 297 | const fixture = yield* makeMcpStdioHarness(protocol) |
| 298 | yield* fixture.server.addResource( |
| 299 | makeResource("file:///baseline-list-changed", "baseline-list-changed-resource") |
| 300 | ) |
| 301 | yield* fixture.initialize() |
| 302 | yield* fixture.server.addResource( |
| 303 | makeResource("file:///dynamic-list-changed", "dynamic-list-changed-resource") |
| 304 | ) |
| 305 | yield* fixture.flushListChanged |
| 306 | |
| 307 | const notification = yield* fixture.awaitOutboundMethod("notifications/resources/list_changed") |
| 308 | assert.strictEqual(notification.jsonrpc, "2.0") |
| 309 | assert.strictEqual(notification.method, "notifications/resources/list_changed") |
| 310 | assert.notProperty(notification, "id") |
| 311 | |
| 312 | const response = yield* fixture.sendRequest("resources/list", {}) |
| 313 | const result = yield* decodeResources(response.result) |
| 314 | assert.isTrue(result.resources.some((resource) => resource.uri === "file:///dynamic-list-changed")) |
| 315 | })) |
| 316 | }) |
| 317 | |
| 318 | describe("Resources > Legacy subscriptions", () => { |
| 319 | it.effect("should deliver a resource update with its URI when the resource is subscribed", () => |
| 320 | Effect.gen(function*() { |
| 321 | const fixture = yield* makeMcpStdioHarness(protocol) |
| 322 | yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) |
| 323 | const initialized = yield* fixture.initialize() |
| 324 | const initializeResult = yield* Schema.decodeUnknownEffect(McpSchema.InitializeResult)(initialized.result) |
| 325 | assert.strictEqual(initializeResult.capabilities.resources?.subscribe, true) |
| 326 | |
| 327 | const response = yield* fixture.sendRequest("resources/subscribe", { |
| 328 | uri: "file:///subscription-target" |
| 329 | }) |
| 330 | assert.notProperty(response, "error") |
| 331 | assert.deepStrictEqual(response.result, {}) |
| 332 | |
| 333 | yield* fixture.server.notifications["notifications/resources/updated"]({ |
| 334 | uri: "file:///subscription-target" |
| 335 | }) |
| 336 | const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") |
| 337 | assert.strictEqual(notification.jsonrpc, "2.0") |
| 338 | assert.strictEqual(notification.method, "notifications/resources/updated") |
| 339 | assert.notProperty(notification, "id") |
| 340 | const payload = yield* decodeResourceUpdated(notification.params) |
| 341 | assert.strictEqual(payload.uri, "file:///subscription-target") |
| 342 | })) |
| 343 | |
| 344 | it.effect("should deliver updates only for subscribed resource URIs", () => |
| 345 | Effect.gen(function*() { |
| 346 | const fixture = yield* makeMcpStdioHarness(protocol) |
| 347 | yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) |
| 348 | yield* fixture.initialize() |
| 349 | yield* fixture.sendRequest("resources/subscribe", { uri: "file:///subscription-target" }) |
| 350 | |
| 351 | yield* fixture.server.notifications["notifications/resources/updated"]({ uri: "file:///not-subscribed" }) |
| 352 | yield* fixture.server.notifications["notifications/resources/updated"]({ |
| 353 | uri: "file:///subscription-target" |
| 354 | }) |
| 355 | const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") |
| 356 | const payload = yield* decodeResourceUpdated(notification.params) |
| 357 | assert.strictEqual(payload.uri, "file:///subscription-target") |
| 358 | })) |
| 359 | |
| 360 | it.effect("should stop delivering updates after a resource is unsubscribed", () => |
| 361 | Effect.gen(function*() { |
| 362 | const fixture = yield* makeMcpStdioHarness(protocol) |
| 363 | yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) |
| 364 | yield* fixture.server.addResource(makeResource("file:///subscription-sentinel", "subscription-sentinel")) |
| 365 | yield* fixture.initialize() |
| 366 | yield* fixture.sendRequest("resources/subscribe", { uri: "file:///subscription-target" }) |
| 367 | yield* fixture.sendRequest("resources/subscribe", { uri: "file:///subscription-sentinel" }) |
| 368 | |
| 369 | const response = yield* fixture.sendRequest("resources/unsubscribe", { |
| 370 | uri: "file:///subscription-target" |
| 371 | }) |
| 372 | assert.notProperty(response, "error") |
| 373 | assert.deepStrictEqual(response.result, {}) |
| 374 | |
| 375 | yield* fixture.server.notifications["notifications/resources/updated"]({ |
| 376 | uri: "file:///subscription-target" |
| 377 | }) |
| 378 | yield* fixture.server.notifications["notifications/resources/updated"]({ |
| 379 | uri: "file:///subscription-sentinel" |
| 380 | }) |
| 381 | const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") |
| 382 | const payload = yield* decodeResourceUpdated(notification.params) |
| 383 | assert.strictEqual(payload.uri, "file:///subscription-sentinel") |
| 384 | })) |
| 385 | }) |
| 386 | }) |