Skip to content

Commit af61a08

Browse files
authored
feat(protocol): Debounce notifications to improve network efficiancy (#746)
1 parent f3584f2 commit af61a08

File tree

3 files changed

+269
-0
lines changed

3 files changed

+269
-0
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,43 @@ const transport = new StdioServerTransport();
913913
await server.connect(transport);
914914
```
915915

916+
### Improving Network Efficiency with Notification Debouncing
917+
918+
When performing bulk updates that trigger notifications (e.g., enabling or disabling multiple tools in a loop), the SDK can send a large number of messages in a short period. To improve performance and reduce network traffic, you can enable notification debouncing.
919+
920+
This feature coalesces multiple, rapid calls for the same notification type into a single message. For example, if you disable five tools in a row, only one `notifications/tools/list_changed` message will be sent instead of five.
921+
922+
> [!IMPORTANT]
923+
> This feature is designed for "simple" notifications that do not carry unique data in their parameters. To prevent silent data loss, debouncing is **automatically bypassed** for any notification that contains a `params` object or a `relatedRequestId`. Such notifications will always be sent immediately.
924+
925+
This is an opt-in feature configured during server initialization.
926+
927+
```typescript
928+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
929+
930+
const server = new McpServer(
931+
{
932+
name: "efficient-server",
933+
version: "1.0.0"
934+
},
935+
{
936+
// Enable notification debouncing for specific methods
937+
debouncedNotificationMethods: [
938+
'notifications/tools/list_changed',
939+
'notifications/resources/list_changed',
940+
'notifications/prompts/list_changed'
941+
]
942+
}
943+
);
944+
945+
// Now, any rapid changes to tools, resources, or prompts will result
946+
// in a single, consolidated notification for each type.
947+
server.registerTool("tool1", ...).disable();
948+
server.registerTool("tool2", ...).disable();
949+
server.registerTool("tool3", ...).disable();
950+
// Only one 'notifications/tools/list_changed' is sent.
951+
```
952+
916953
### Low-Level Server
917954

918955
For more control, you can use the low-level Server class directly:

src/shared/protocol.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,189 @@ describe("protocol tests", () => {
466466
await expect(requestPromise).resolves.toEqual({ result: "success" });
467467
});
468468
});
469+
470+
describe("Debounced Notifications", () => {
471+
// We need to flush the microtask queue to test the debouncing logic.
472+
// This helper function does that.
473+
const flushMicrotasks = () => new Promise(resolve => setImmediate(resolve));
474+
475+
it("should NOT debounce a notification that has parameters", async () => {
476+
// ARRANGE
477+
protocol = new (class extends Protocol<Request, Notification, Result> {
478+
protected assertCapabilityForMethod(): void {}
479+
protected assertNotificationCapability(): void {}
480+
protected assertRequestHandlerCapability(): void {}
481+
})({ debouncedNotificationMethods: ['test/debounced_with_params'] });
482+
await protocol.connect(transport);
483+
484+
// ACT
485+
// These notifications are configured for debouncing but contain params, so they should be sent immediately.
486+
await protocol.notification({ method: 'test/debounced_with_params', params: { data: 1 } });
487+
await protocol.notification({ method: 'test/debounced_with_params', params: { data: 2 } });
488+
489+
// ASSERT
490+
// Both should have been sent immediately to avoid data loss.
491+
expect(sendSpy).toHaveBeenCalledTimes(2);
492+
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ params: { data: 1 } }), undefined);
493+
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ params: { data: 2 } }), undefined);
494+
});
495+
496+
it("should NOT debounce a notification that has a relatedRequestId", async () => {
497+
// ARRANGE
498+
protocol = new (class extends Protocol<Request, Notification, Result> {
499+
protected assertCapabilityForMethod(): void {}
500+
protected assertNotificationCapability(): void {}
501+
protected assertRequestHandlerCapability(): void {}
502+
})({ debouncedNotificationMethods: ['test/debounced_with_options'] });
503+
await protocol.connect(transport);
504+
505+
// ACT
506+
await protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 'req-1' });
507+
await protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 'req-2' });
508+
509+
// ASSERT
510+
expect(sendSpy).toHaveBeenCalledTimes(2);
511+
expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-1' });
512+
expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-2' });
513+
});
514+
515+
it("should clear pending debounced notifications on connection close", async () => {
516+
// ARRANGE
517+
protocol = new (class extends Protocol<Request, Notification, Result> {
518+
protected assertCapabilityForMethod(): void {}
519+
protected assertNotificationCapability(): void {}
520+
protected assertRequestHandlerCapability(): void {}
521+
})({ debouncedNotificationMethods: ['test/debounced'] });
522+
await protocol.connect(transport);
523+
524+
// ACT
525+
// Schedule a notification but don't flush the microtask queue.
526+
protocol.notification({ method: 'test/debounced' });
527+
528+
// Close the connection. This should clear the pending set.
529+
await protocol.close();
530+
531+
// Now, flush the microtask queue.
532+
await flushMicrotasks();
533+
534+
// ASSERT
535+
// The send should never have happened because the transport was cleared.
536+
expect(sendSpy).not.toHaveBeenCalled();
537+
});
538+
539+
it("should debounce multiple synchronous calls when params property is omitted", async () => {
540+
// ARRANGE
541+
protocol = new (class extends Protocol<Request, Notification, Result> {
542+
protected assertCapabilityForMethod(): void {}
543+
protected assertNotificationCapability(): void {}
544+
protected assertRequestHandlerCapability(): void {}
545+
})({ debouncedNotificationMethods: ['test/debounced'] });
546+
await protocol.connect(transport);
547+
548+
// ACT
549+
// This is the more idiomatic way to write a notification with no params.
550+
protocol.notification({ method: 'test/debounced' });
551+
protocol.notification({ method: 'test/debounced' });
552+
protocol.notification({ method: 'test/debounced' });
553+
554+
expect(sendSpy).not.toHaveBeenCalled();
555+
await flushMicrotasks();
556+
557+
// ASSERT
558+
expect(sendSpy).toHaveBeenCalledTimes(1);
559+
// The final sent object might not even have the `params` key, which is fine.
560+
// We can check that it was called and that the params are "falsy".
561+
const sentNotification = sendSpy.mock.calls[0][0];
562+
expect(sentNotification.method).toBe('test/debounced');
563+
expect(sentNotification.params).toBeUndefined();
564+
});
565+
566+
it("should debounce calls when params is explicitly undefined", async () => {
567+
// ARRANGE
568+
protocol = new (class extends Protocol<Request, Notification, Result> {
569+
protected assertCapabilityForMethod(): void {}
570+
protected assertNotificationCapability(): void {}
571+
protected assertRequestHandlerCapability(): void {}
572+
})({ debouncedNotificationMethods: ['test/debounced'] });
573+
await protocol.connect(transport);
574+
575+
// ACT
576+
protocol.notification({ method: 'test/debounced', params: undefined });
577+
protocol.notification({ method: 'test/debounced', params: undefined });
578+
await flushMicrotasks();
579+
580+
// ASSERT
581+
expect(sendSpy).toHaveBeenCalledTimes(1);
582+
expect(sendSpy).toHaveBeenCalledWith(
583+
expect.objectContaining({
584+
method: 'test/debounced',
585+
params: undefined
586+
}),
587+
undefined
588+
);
589+
});
590+
591+
it("should send non-debounced notifications immediately and multiple times", async () => {
592+
// ARRANGE
593+
protocol = new (class extends Protocol<Request, Notification, Result> {
594+
protected assertCapabilityForMethod(): void {}
595+
protected assertNotificationCapability(): void {}
596+
protected assertRequestHandlerCapability(): void {}
597+
})({ debouncedNotificationMethods: ['test/debounced'] }); // Configure for a different method
598+
await protocol.connect(transport);
599+
600+
// ACT
601+
// Call a non-debounced notification method multiple times.
602+
await protocol.notification({ method: 'test/immediate' });
603+
await protocol.notification({ method: 'test/immediate' });
604+
605+
// ASSERT
606+
// Since this method is not in the debounce list, it should be sent every time.
607+
expect(sendSpy).toHaveBeenCalledTimes(2);
608+
});
609+
610+
it("should not debounce any notifications if the option is not provided", async () => {
611+
// ARRANGE
612+
// Use the default protocol from beforeEach, which has no debounce options.
613+
await protocol.connect(transport);
614+
615+
// ACT
616+
await protocol.notification({ method: 'any/method' });
617+
await protocol.notification({ method: 'any/method' });
618+
619+
// ASSERT
620+
// Without the config, behavior should be immediate sending.
621+
expect(sendSpy).toHaveBeenCalledTimes(2);
622+
});
623+
624+
it("should handle sequential batches of debounced notifications correctly", async () => {
625+
// ARRANGE
626+
protocol = new (class extends Protocol<Request, Notification, Result> {
627+
protected assertCapabilityForMethod(): void {}
628+
protected assertNotificationCapability(): void {}
629+
protected assertRequestHandlerCapability(): void {}
630+
})({ debouncedNotificationMethods: ['test/debounced'] });
631+
await protocol.connect(transport);
632+
633+
// ACT (Batch 1)
634+
protocol.notification({ method: 'test/debounced' });
635+
protocol.notification({ method: 'test/debounced' });
636+
await flushMicrotasks();
637+
638+
// ASSERT (Batch 1)
639+
expect(sendSpy).toHaveBeenCalledTimes(1);
640+
641+
// ACT (Batch 2)
642+
// After the first batch has been sent, a new batch should be possible.
643+
protocol.notification({ method: 'test/debounced' });
644+
protocol.notification({ method: 'test/debounced' });
645+
await flushMicrotasks();
646+
647+
// ASSERT (Batch 2)
648+
// The total number of sends should now be 2.
649+
expect(sendSpy).toHaveBeenCalledTimes(2);
650+
});
651+
});
469652
});
470653

471654
describe("mergeCapabilities", () => {

src/shared/protocol.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export type ProtocolOptions = {
4545
* Currently this defaults to false, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to true.
4646
*/
4747
enforceStrictCapabilities?: boolean;
48+
/**
49+
* An array of notification method names that should be automatically debounced.
50+
* Any notifications with a method in this list will be coalesced if they
51+
* occur in the same tick of the event loop.
52+
* e.g., ['notifications/tools/list_changed']
53+
*/
54+
debouncedNotificationMethods?: string[];
4855
};
4956

5057
/**
@@ -191,6 +198,7 @@ export abstract class Protocol<
191198
> = new Map();
192199
private _progressHandlers: Map<number, ProgressCallback> = new Map();
193200
private _timeoutInfo: Map<number, TimeoutInfo> = new Map();
201+
private _pendingDebouncedNotifications = new Set<string>();
194202

195203
/**
196204
* Callback for when the connection is closed for any reason.
@@ -321,6 +329,7 @@ export abstract class Protocol<
321329
const responseHandlers = this._responseHandlers;
322330
this._responseHandlers = new Map();
323331
this._progressHandlers.clear();
332+
this._pendingDebouncedNotifications.clear();
324333
this._transport = undefined;
325334
this.onclose?.();
326335

@@ -632,6 +641,46 @@ export abstract class Protocol<
632641

633642
this.assertNotificationCapability(notification.method);
634643

644+
const debouncedMethods = this._options?.debouncedNotificationMethods ?? [];
645+
// A notification can only be debounced if it's in the list AND it's "simple"
646+
// (i.e., has no parameters and no related request ID that could be lost).
647+
const canDebounce = debouncedMethods.includes(notification.method)
648+
&& !notification.params
649+
&& !(options?.relatedRequestId);
650+
651+
if (canDebounce) {
652+
// If a notification of this type is already scheduled, do nothing.
653+
if (this._pendingDebouncedNotifications.has(notification.method)) {
654+
return;
655+
}
656+
657+
// Mark this notification type as pending.
658+
this._pendingDebouncedNotifications.add(notification.method);
659+
660+
// Schedule the actual send to happen in the next microtask.
661+
// This allows all synchronous calls in the current event loop tick to be coalesced.
662+
Promise.resolve().then(() => {
663+
// Un-mark the notification so the next one can be scheduled.
664+
this._pendingDebouncedNotifications.delete(notification.method);
665+
666+
// SAFETY CHECK: If the connection was closed while this was pending, abort.
667+
if (!this._transport) {
668+
return;
669+
}
670+
671+
const jsonrpcNotification: JSONRPCNotification = {
672+
...notification,
673+
jsonrpc: "2.0",
674+
};
675+
// Send the notification, but don't await it here to avoid blocking.
676+
// Handle potential errors with a .catch().
677+
this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error));
678+
});
679+
680+
// Return immediately.
681+
return;
682+
}
683+
635684
const jsonrpcNotification: JSONRPCNotification = {
636685
...notification,
637686
jsonrpc: "2.0",

0 commit comments

Comments
 (0)