MP-301e · Module 1

Change Notifications

4 min read

MCP subscriptions are the protocol's mechanism for live data. When a client calls resources/subscribe with a URI, the server begins monitoring the underlying data source and emits notifications/resources/updated whenever the data changes. The client then decides whether to re-read the resource immediately or batch the update. This pull-on-notify pattern keeps the protocol lightweight — the server signals change; the client controls when it fetches the new state.

The notification lifecycle has strict ordering requirements. A client must receive a subscribe acknowledgment before the server emits any notifications for that URI. If the server detects a change before the acknowledgment is sent, it must queue the notification. Similarly, when a client calls resources/unsubscribe, the server must stop emitting notifications for that URI before sending the unsubscribe acknowledgment. Violating this ordering creates race conditions where clients miss updates or receive notifications for resources they no longer track.

Debouncing is essential for high-frequency data sources. A database table that changes 100 times per second should not trigger 100 notifications — each notification prompts a client re-read, and the combined load overwhelms both the server and the AI model. Instead, the server debounces: it waits for a quiet period (e.g., 500ms with no changes) before emitting a single notification. This coalesces rapid changes into a single update event, reducing noise while preserving eventual consistency.

import { EventEmitter } from "events";

class SubscriptionManager {
  private subscriptions = new Map<string, Set<string>>(); // URI → client IDs
  private debounceTimers = new Map<string, NodeJS.Timeout>();
  private debounceMs = 500;

  subscribe(clientId: string, uri: string): void {
    if (!this.subscriptions.has(uri)) {
      this.subscriptions.set(uri, new Set());
      this.startWatching(uri);
    }
    this.subscriptions.get(uri)!.add(clientId);
  }

  unsubscribe(clientId: string, uri: string): void {
    const clients = this.subscriptions.get(uri);
    if (!clients) return;
    clients.delete(clientId);
    if (clients.size === 0) {
      this.subscriptions.delete(uri);
      this.stopWatching(uri);
    }
  }

  /** Called by the data source watcher when data changes */
  onChange(uri: string): void {
    // Debounce: wait for quiet period before notifying
    const existing = this.debounceTimers.get(uri);
    if (existing) clearTimeout(existing);

    this.debounceTimers.set(uri, setTimeout(() => {
      this.debounceTimers.delete(uri);
      this.emitNotification(uri);
    }, this.debounceMs));
  }

  private emitNotification(uri: string): void {
    const clients = this.subscriptions.get(uri);
    if (!clients || clients.size === 0) return;

    server.notification({
      method: "notifications/resources/updated",
      params: { uri },
    });
  }

  private startWatching(uri: string): void {
    // Implementation depends on data source type:
    // - PostgreSQL: LISTEN/NOTIFY
    // - Filesystem: inotify/FSEvents
    // - API: webhook registration or polling
  }

  private stopWatching(uri: string): void {
    // Clean up watcher resources
  }
}
  1. Implement subscribe/unsubscribe Track active subscriptions by URI and client ID. Start data source watching on first subscription, stop on last unsubscription. Clean up all subscriptions when a client disconnects.
  2. Add debouncing Set a debounce window (250-1000ms depending on data source). Coalesce rapid changes into a single notification to prevent notification storms.
  3. Handle ordering Queue notifications that arrive before subscribe acknowledgment. Stop notifications before unsubscribe acknowledgment. Test both race conditions explicitly.