From 421e26958b8eac4ddf8740ba47efd31661e5d1ab Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Fri, 21 Aug 2026 00:26:11 -0700 Subject: [PATCH 1/2] fix(server): honour enabled on resource templates registerResource returns a handle with enable(), disable() and enabled for every primitive, but nothing ever read the flag for resource templates. A disabled template stayed in resources/list and resources/templates/list, was still served by resources/read, and still answered completion/complete, while disable() fired resources/list_changed as if the list had changed. Static resources registered through the same call were already guarded, so the two behaved differently three lines apart in the same handler. Co-Authored-By: Claude Opus 5 --- .changeset/resource-template-enabled.md | 9 ++ packages/server/src/server/mcp.ts | 21 +++- .../server/resourceTemplateEnabled.test.ts | 108 ++++++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 .changeset/resource-template-enabled.md create mode 100644 packages/server/test/server/resourceTemplateEnabled.test.ts diff --git a/.changeset/resource-template-enabled.md b/.changeset/resource-template-enabled.md new file mode 100644 index 0000000000..6fc728a4e5 --- /dev/null +++ b/.changeset/resource-template-enabled.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Honour `enabled` on resource templates. `registerResource` returns a handle with `enable()`, `disable()` and `enabled` for every primitive, but the resource-template registry was the only one nothing ever read: the flag was stored and the `list_changed` notification fired, while the template stayed listed and readable. + +A disabled template was still returned by `resources/list` and `resources/templates/list`, still served by `resources/read`, and still answered `completion/complete`. Static resources registered through the same call were already guarded, so the two behaved differently in the same handler — `disable()` on a static resource errored the read, `disable()` on a template did not. + +Callers using `disable()` to withdraw access to a family of resources were therefore still serving them. Tools, prompts and static resources are unchanged. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..d4d97dbbbf 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -411,6 +411,10 @@ export class McpServer { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); } + if (!template.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${ref.uri} disabled`); + } + const completer = template.resourceTemplate.completeCallback(request.params.argument.name); if (!completer) { return EMPTY_COMPLETION_RESULT; @@ -448,7 +452,7 @@ export class McpServer { const templateResources: Resource[] = []; for (const template of Object.values(this._registeredResourceTemplates)) { - if (!template.resourceTemplate.listCallback) { + if (!template.enabled || !template.resourceTemplate.listCallback) { continue; } @@ -466,11 +470,13 @@ export class McpServer { }); this.server.setRequestHandler('resources/templates/list', async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ - name, - uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata - })); + const resourceTemplates = Object.entries(this._registeredResourceTemplates) + .filter(([, template]) => template.enabled) + .map(([name, template]) => ({ + name, + uriTemplate: template.resourceTemplate.uriTemplate.toString(), + ...template.metadata + })); return { resourceTemplates }; }); @@ -502,6 +508,9 @@ export class McpServer { for (const template of Object.values(this._registeredResourceTemplates)) { const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); if (variables) { + if (!template.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); + } return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint); } } diff --git a/packages/server/test/server/resourceTemplateEnabled.test.ts b/packages/server/test/server/resourceTemplateEnabled.test.ts new file mode 100644 index 0000000000..3356941e67 --- /dev/null +++ b/packages/server/test/server/resourceTemplateEnabled.test.ts @@ -0,0 +1,108 @@ +/** + * Resource templates must honour `enabled` like every other primitive. + * + * The `mcpserver:handle:enable-disable` requirement says `disable()` removes the + * item from list results and calling/reading it errors. Tools, prompts and static + * resources implement that; resource templates registered `enabled` but never read + * it, so a disabled template stayed listed, readable and completable. + */ +import { describe, expect, it } from 'vitest'; + +import { invoke } from '../../src/server/invoke'; +import { McpServer, ResourceTemplate } from '../../src/server/mcp'; + +const LEGACY = { classification: { era: 'legacy' as const } }; + +const call = async (server: McpServer, method: string, params: Record = {}) => { + const response = await invoke(server, { jsonrpc: '2.0', id: 1, method, params }, LEGACY); + return (await response.json()) as { result?: any; error?: { message: string } }; +}; + +const makeServer = () => { + const server = new McpServer({ name: 'test', version: '0' }); + const template = server.registerResource( + 'secret', + new ResourceTemplate('secret://{id}', { + list: async () => ({ resources: [{ name: 'alpha', uri: 'secret://alpha' }] }), + complete: { id: async () => ['alpha', 'beta'] } + }), + {}, + async (uri, variables) => ({ contents: [{ uri: uri.toString(), text: `SECRET ${variables.id}` }] }) + ); + return { server, template }; +}; + +const completeId = (server: McpServer) => + call(server, 'completion/complete', { + ref: { type: 'ref/resource', uri: 'secret://{id}' }, + argument: { name: 'id', value: '' } + }); + +describe('resource template enabled', () => { + it('drops a disabled template from resources/list', async () => { + const { server, template } = makeServer(); + template.disable(); + + const listed = await call(server, 'resources/list'); + + expect(listed.result.resources).toEqual([]); + }); + + it('drops a disabled template from resources/templates/list', async () => { + const { server, template } = makeServer(); + template.disable(); + + const templates = await call(server, 'resources/templates/list'); + + expect(templates.result.resourceTemplates).toEqual([]); + }); + + it('rejects a read that matches a disabled template', async () => { + const { server, template } = makeServer(); + template.disable(); + + const read = await call(server, 'resources/read', { uri: 'secret://alpha' }); + + expect(read.result).toBeUndefined(); + expect(read.error?.message).toContain('disabled'); + }); + + it('rejects completion for a disabled template', async () => { + const { server, template } = makeServer(); + template.disable(); + + const completion = await completeId(server); + + expect(completion.result).toBeUndefined(); + expect(completion.error?.message).toContain('disabled'); + }); + + it('does not fall through to a later template that matches the same uri', async () => { + const { server, template } = makeServer(); + server.registerResource('fallback', new ResourceTemplate('secret://{name}', { list: undefined }), {}, async uri => ({ + contents: [{ uri: uri.toString(), text: 'FALLBACK' }] + })); + template.disable(); + + const read = await call(server, 'resources/read', { uri: 'secret://alpha' }); + + expect(read.result).toBeUndefined(); + expect(read.error?.message).toContain('disabled'); + }); + + it('restores the template on enable()', async () => { + const { server, template } = makeServer(); + template.disable(); + template.enable(); + + const listed = await call(server, 'resources/list'); + const templates = await call(server, 'resources/templates/list'); + const read = await call(server, 'resources/read', { uri: 'secret://alpha' }); + const completion = await completeId(server); + + expect(listed.result.resources.map((resource: { uri: string }) => resource.uri)).toEqual(['secret://alpha']); + expect(templates.result.resourceTemplates).toHaveLength(1); + expect(read.result.contents[0].text).toBe('SECRET alpha'); + expect(completion.result.completion.values).toEqual(['alpha', 'beta']); + }); +}); From 543361c6a3781197eb8271e9cc685c3627b39b01 Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Fri, 21 Aug 2026 00:29:26 -0700 Subject: [PATCH 2/2] test(e2e): verify enable-disable for resource templates The mcpserver:handle:enable-disable requirement is written generically but was only exercised with a RegisteredTool, which is why the resource-template gap went unnoticed. Adds a second body for a template, and corrects the changeset to say every registration handle rather than registerResource. Co-Authored-By: Claude Opus 5 --- .changeset/resource-template-enabled.md | 2 +- test/e2e/scenarios/dynamic.test.ts | 63 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/.changeset/resource-template-enabled.md b/.changeset/resource-template-enabled.md index 6fc728a4e5..9a3d1396a9 100644 --- a/.changeset/resource-template-enabled.md +++ b/.changeset/resource-template-enabled.md @@ -2,7 +2,7 @@ '@modelcontextprotocol/server': patch --- -Honour `enabled` on resource templates. `registerResource` returns a handle with `enable()`, `disable()` and `enabled` for every primitive, but the resource-template registry was the only one nothing ever read: the flag was stored and the `list_changed` notification fired, while the template stayed listed and readable. +Honour `enabled` on resource templates. Every registration handle exposes `enable()`, `disable()` and `enabled`, but the resource-template registry was the only one nothing ever read: the flag was stored and the `list_changed` notification fired, while the template stayed listed and readable. A disabled template was still returned by `resources/list` and `resources/templates/list`, still served by `resources/read`, and still answered `completion/complete`. Static resources registered through the same call were already guarded, so the two behaved differently in the same handler — `disable()` on a static resource errored the read, `disable()` on a template did not. diff --git a/test/e2e/scenarios/dynamic.test.ts b/test/e2e/scenarios/dynamic.test.ts index 4e180d6cc6..021df72e42 100644 --- a/test/e2e/scenarios/dynamic.test.ts +++ b/test/e2e/scenarios/dynamic.test.ts @@ -7,8 +7,8 @@ */ import { Client } from '@modelcontextprotocol/client'; -import type { Prompt, RegisteredTool, Resource, Tool } from '@modelcontextprotocol/server'; -import { McpServer, ProtocolErrorCode, Server } from '@modelcontextprotocol/server'; +import type { Prompt, RegisteredResourceTemplate, RegisteredTool, Resource, Tool } from '@modelcontextprotocol/server'; +import { McpServer, ProtocolErrorCode, ResourceTemplate, Server } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; @@ -230,6 +230,65 @@ verifies('mcpserver:handle:enable-disable', async ({ transport }: TestArgs) => { expect(restored.content).toEqual([{ type: 'text', text: 'toggle-probe' }]); }); +verifies( + 'mcpserver:handle:enable-disable', + async ({ transport }: TestArgs) => { + let handle!: RegisteredResourceTemplate; + let server!: McpServer; + + const makeServer = () => { + server = new McpServer({ name: 's', version: '0' }); + handle = server.registerResource( + 'toggle-template', + new ResourceTemplate('probe://{id}', { + list: async () => ({ resources: [{ name: 'probe-1', uri: 'probe://1' }] }) + }), + {}, + async uri => ({ contents: [{ uri: uri.toString(), text: 'probe-body' }] }) + ); + return server; + }; + + let listChanged = 0; + const client = newClient(); + client.setNotificationHandler('notifications/resources/list_changed', () => { + listChanged++; + }); + + await using _ = await wire(transport, makeServer, client); + + const initialTemplates = await client.listResourceTemplates(); + expect(initialTemplates.resourceTemplates.map(t => t.name)).toContain('toggle-template'); + const initialResources = await client.listResources(); + expect(initialResources.resources.map(r => r.uri)).toContain('probe://1'); + const initialRead = await client.readResource({ uri: 'probe://1' }); + expect(initialRead.contents[0]).toMatchObject({ text: 'probe-body' }); + + const beforeDisable = listChanged; + handle.disable(); + await waitUntil(() => listChanged > beforeDisable); + + const disabledTemplates = await client.listResourceTemplates(); + expect(disabledTemplates.resourceTemplates.map(t => t.name)).not.toContain('toggle-template'); + const disabledResources = await client.listResources(); + expect(disabledResources.resources.map(r => r.uri)).not.toContain('probe://1'); + await expect(client.readResource({ uri: 'probe://1' })).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringMatching(/disabled/i) + }); + + const beforeEnable = listChanged; + handle.enable(); + await waitUntil(() => listChanged > beforeEnable); + + const restoredTemplates = await client.listResourceTemplates(); + expect(restoredTemplates.resourceTemplates.map(t => t.name)).toContain('toggle-template'); + const restoredRead = await client.readResource({ uri: 'probe://1' }); + expect(restoredRead.contents[0]).toMatchObject({ text: 'probe-body' }); + }, + { title: 'resource template' } +); + verifies('mcpserver:list-changed:debounce', async ({ transport }: TestArgs) => { let server!: McpServer; const makeServer = () => {