1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as Context from "effect/Context"
5import * as Effect from "effect/Effect"
6import * as Schema from "effect/Schema"
7import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts"
8import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
9
10const decodeResources = Schema.decodeUnknownEffect(McpSchema.ListResourcesResult)
11const decodeResourceTemplates = Schema.decodeUnknownEffect(McpSchema.ListResourceTemplatesResult)
12const decodeReadResource = Schema.decodeUnknownEffect(McpSchema.ReadResourceResult)
13const decodeResourceUpdated = Schema.decodeUnknownEffect(McpSchema.ResourceUpdatedNotification.payloadSchema)
14const 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
24export 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
280export 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 })