From aa98e658f376e30dd923a0f98c3536b0e5089d6b Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:31:48 +0700 Subject: [PATCH 1/2] fix(server): warn once when sendResourceUpdated lacks resources.subscribe Resource update notifications only reach clients that opted in via resources/subscribe, which is gated on the advertised subscribe capability. Warn once on sendResourceUpdated when that bit is missing so missing capabilities are obvious during development. Closes #2545 --- packages/server/src/server/server.ts | 15 +++++++++++ packages/server/test/server/server.test.ts | 31 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5de0d8919c..69e9c739b4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -289,6 +289,8 @@ export class Server extends Protocol { private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; private _legacyShim?: LegacyInputRequiredShim; + /** Emit at most one warn when sendResourceUpdated is used without resources.subscribe. */ + private _warnedResourceUpdatedWithoutSubscribe = false; /** Lazily-built legacy shim; the loop lives in legacyInputRequiredShim.ts behind a narrow host contract. */ private _legacyInputRequiredShim(): LegacyInputRequiredShim { @@ -1298,6 +1300,19 @@ export class Server extends Protocol { } async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { + // Resource update notifications only reach clients that subscribed via + // resources/subscribe. That method is gated on the advertised + // `resources.subscribe` capability — without it, clients cannot opt in + // and the notification is effectively a no-op for them. Warn once so + // the missing capability is easy to spot during development (#2545). + if (!this._capabilities.resources?.subscribe && !this._warnedResourceUpdatedWithoutSubscribe) { + this._warnedResourceUpdatedWithoutSubscribe = true; + console.warn( + '[mcp-sdk] sendResourceUpdated() called without advertising capabilities.resources.subscribe. ' + + 'Clients cannot subscribe to resource updates unless the server sets ' + + '{ resources: { subscribe: true } } in its capabilities.' + ); + } return this.notification({ method: 'notifications/resources/updated', params diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0a96a0bb66..55d765a0a6 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -242,4 +242,35 @@ describe('Server', () => { expect(result.structuredContent).toEqual({ ok: true }); }); }); + + describe('sendResourceUpdated capability warning', () => { + async function connectServer(server: Server): Promise { + const [, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + } + + it('warns once when resources.subscribe is not advertised', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); + await connectServer(server); + + await server.sendResourceUpdated({ uri: 'test://resource' }); + await server.sendResourceUpdated({ uri: 'test://resource-2' }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('resources.subscribe')); + warnSpy.mockRestore(); + }); + + it('does not warn when resources.subscribe is advertised', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); + await connectServer(server); + + await server.sendResourceUpdated({ uri: 'test://resource' }); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); }); From 010294f2e683f0cd79e8049da1cc5a40de786fee Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:42:00 +0700 Subject: [PATCH 2/2] fix(server): throw on resource subscribe capability mismatches Align with other local capability checks: sendResourceUpdated throws SdkError(CapabilityNotSupported) via assertNotificationCapability when resources.subscribe is missing, and setRequestHandler gates resources/subscribe|unsubscribe the same way. Covers both halves of #2545 (notification send + handler registration). --- packages/server/src/server/server.ts | 45 ++++++++++++++-------- packages/server/test/server/server.test.ts | 36 ++++++++++------- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 69e9c739b4..3c67faab02 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -289,9 +289,6 @@ export class Server extends Protocol { private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; private _legacyShim?: LegacyInputRequiredShim; - /** Emit at most one warn when sendResourceUpdated is used without resources.subscribe. */ - private _warnedResourceUpdatedWithoutSubscribe = false; - /** Lazily-built legacy shim; the loop lives in legacyInputRequiredShim.ts behind a narrow host contract. */ private _legacyInputRequiredShim(): LegacyInputRequiredShim { return (this._legacyShim ??= new LegacyInputRequiredShim({ @@ -795,7 +792,19 @@ export class Server extends Protocol { break; } - case 'notifications/resources/updated': + case 'notifications/resources/updated': { + // Resource updates only reach clients that opted in via + // resources/subscribe, which requires the advertised + // resources.subscribe capability (#2545). + if (!this._capabilities.resources?.subscribe) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support resource subscriptions (required for ${method})` + ); + } + break; + } + case 'notifications/resources/list_changed': { if (!this._capabilities.resources) { throw new SdkError( @@ -881,6 +890,20 @@ export class Server extends Protocol { break; } + case 'resources/subscribe': + case 'resources/unsubscribe': { + // Handler registration must match the advertised bit; otherwise + // clients that subscribe (or the server's own handlers) see a + // silent dead-end when the capability was never declared (#2545). + if (!this._capabilities.resources?.subscribe) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support resource subscriptions (required for ${method})` + ); + } + break; + } + case 'tools/call': case 'tools/list': { if (!this._capabilities.tools) { @@ -1300,19 +1323,7 @@ export class Server extends Protocol { } async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { - // Resource update notifications only reach clients that subscribed via - // resources/subscribe. That method is gated on the advertised - // `resources.subscribe` capability — without it, clients cannot opt in - // and the notification is effectively a no-op for them. Warn once so - // the missing capability is easy to spot during development (#2545). - if (!this._capabilities.resources?.subscribe && !this._warnedResourceUpdatedWithoutSubscribe) { - this._warnedResourceUpdatedWithoutSubscribe = true; - console.warn( - '[mcp-sdk] sendResourceUpdated() called without advertising capabilities.resources.subscribe. ' + - 'Clients cannot subscribe to resource updates unless the server sets ' + - '{ resources: { subscribe: true } } in its capabilities.' - ); - } + // assertNotificationCapability requires resources.subscribe (#2545). return this.notification({ method: 'notifications/resources/updated', params diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 55d765a0a6..4070e5c85e 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -243,34 +243,42 @@ describe('Server', () => { }); }); - describe('sendResourceUpdated capability warning', () => { + describe('resource subscription capabilities (#2545)', () => { async function connectServer(server: Server): Promise { const [, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); } - it('warns once when resources.subscribe is not advertised', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('sendResourceUpdated throws without resources.subscribe', async () => { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); await connectServer(server); - await server.sendResourceUpdated({ uri: 'test://resource' }); - await server.sendResourceUpdated({ uri: 'test://resource-2' }); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('resources.subscribe')); - warnSpy.mockRestore(); + await expect(server.sendResourceUpdated({ uri: 'test://resource' })).rejects.toThrow(/resource subscriptions/); }); - it('does not warn when resources.subscribe is advertised', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('sendResourceUpdated succeeds when resources.subscribe is advertised', async () => { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); await connectServer(server); - await server.sendResourceUpdated({ uri: 'test://resource' }); + await expect(server.sendResourceUpdated({ uri: 'test://resource' })).resolves.toBeUndefined(); + }); + + it('setRequestHandler rejects resources/subscribe without the capability', () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); + + expect(() => server.setRequestHandler('resources/subscribe', async () => ({}))).toThrow(/resource subscriptions/); + }); + + it('setRequestHandler rejects resources/unsubscribe without the capability', () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: {} } }); + + expect(() => server.setRequestHandler('resources/unsubscribe', async () => ({}))).toThrow(/resource subscriptions/); + }); + + it('setRequestHandler allows resources/subscribe when the capability is advertised', () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); + expect(() => server.setRequestHandler('resources/subscribe', async () => ({}))).not.toThrow(); }); }); });