Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/resource-template-enabled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/server': patch
---

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.

Callers using `disable()` to withdraw access to a family of resources were therefore still serving them. Tools, prompts and static resources are unchanged.
21 changes: 15 additions & 6 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 };
});
Expand Down Expand Up @@ -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);
}
}
Expand Down
108 changes: 108 additions & 0 deletions packages/server/test/server/resourceTemplateEnabled.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => {
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']);
});
});
63 changes: 61 additions & 2 deletions test/e2e/scenarios/dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 = () => {
Expand Down
Loading