Guide · MCP Protocol Primitives

MCP Server Notifications — types, patterns, and reliable delivery

MCP notifications are one-way messages from the server to the client that carry no response expectation. Unlike request/response pairs (which wait for an answer), a notification is fire-and-forget: the server sends it, the client processes it, no acknowledgement is returned. Notifications are how MCP servers push state changes to clients without polling — when the tool catalog changes, when a resource's content updates, when a long-running tool reports progress, or when the server emits a log message. Getting notifications right requires understanding which notification types to declare in capabilities, when to emit them, and how to handle delivery failures on transports that can silently drop events.

TL;DR

The four notification families: list-changed (tools/resources/prompts catalog changes), resource-updated (content changed for a specific subscribed URI), progress (long-running tool progress tokens), and logging (structured log messages from server to client). Declare each in capabilities before sending. Coalesce high-frequency list-changed notifications with a debounce — the client re-fetches the full list on each notification, so 100 notifications fired at once = 100 list fetches. Monitor notification delivery health: a server that emits notifications but has dead SSE connections is emitting to /dev/null silently.

Notification types at a glance

Notification Method Capability required Params
Tool list changed notifications/tools/list_changed tools.listChanged: true (none)
Resource list changed notifications/resources/list_changed resources.listChanged: true (none)
Resource updated notifications/resources/updated resources.subscribe: true { uri: string }
Prompt list changed notifications/prompts/list_changed prompts.listChanged: true (none)
Progress notifications/progress (no capability needed; tied to progressToken) { progressToken, progress, total? }
Log message notifications/message logging: {} { level, logger?, data }
Cancelled notifications/cancelled (no capability needed) { requestId, reason? }

List-change notifications

List-change notifications tell the client that the catalog of tools, resources, or prompts has changed and it should re-fetch the full list. The notification carries no diff — the client must re-issue the corresponding list request to discover what changed.

import { Server } from '@modelcontextprotocol/sdk/server/index.js';

const server = new Server(
  { name: 'my-mcp-server', version: '1.0.0' },
  {
    capabilities: {
      tools: { listChanged: true },
      resources: { listChanged: true },
      prompts: { listChanged: true }
    }
  }
);

// Emit after dynamic tool registration change
async function enableBetaTools(sessionId: string): Promise<void> {
  // Register the new tools on this session
  registerBetaToolSet(sessionId);
  // Notify the client
  await server.sendToolListChanged();
}

// Emit after resource catalog changes
async function afterCustomerCreated(customerId: string): Promise<void> {
  await server.sendResourceListChanged();
}

// Emit after prompt catalog changes
async function afterFeatureFlagChange(flagName: string, enabled: boolean): Promise<void> {
  if (isPromptFeatureFlag(flagName)) {
    await server.sendPromptListChanged();
  }
}

Coalescing high-frequency list changes. If your data changes rapidly (a stream of row inserts, a webhook fanout), firing a list-change notification per event causes the client to re-fetch the list on every event. Coalesce with a debounce — emit at most one notification per 500ms regardless of how many events fired:

let listChangeDebounce: NodeJS.Timeout | null = null;

function scheduleResourceListChanged(): void {
  if (listChangeDebounce) return; // already pending
  listChangeDebounce = setTimeout(async () => {
    listChangeDebounce = null;
    try {
      await server.sendResourceListChanged();
    } catch (e) {
      console.error('Failed to send resource list changed notification:', e);
    }
  }, 500);
}

Resource update notifications

Resource update notifications are URI-scoped: they tell the client that the content of a specific resource has changed, but only for clients that have subscribed to that URI. The client re-reads the resource after receiving this notification to get the updated content.

import {
  SubscribeRequestSchema,
  UnsubscribeRequestSchema
} from '@modelcontextprotocol/sdk/types.js';

// Track subscriptions per URI
const subscriptions = new Map<string, Set<string>>(); // uri → sessionIds

server.setRequestHandler(SubscribeRequestSchema, async ({ params }, { sessionId }) => {
  if (!subscriptions.has(params.uri)) {
    subscriptions.set(params.uri, new Set());
  }
  subscriptions.get(params.uri)!.add(sessionId);
  return {};
});

server.setRequestHandler(UnsubscribeRequestSchema, async ({ params }, { sessionId }) => {
  subscriptions.get(params.uri)?.delete(sessionId);
  return {};
});

// Emit resource update when content changes
async function onDatabaseRowUpdated(tableName: string, rowId: string): Promise<void> {
  const uri = `db://${tableName}/${rowId}`;
  const subscribers = subscriptions.get(uri);
  if (!subscribers || subscribers.size === 0) return;

  for (const sessionId of subscribers) {
    try {
      await server.sendResourceUpdated({ uri }, sessionId);
    } catch (e) {
      // Session may have disconnected — remove stale subscription
      console.warn(`Failed to notify session ${sessionId} of ${uri} update:`, e);
      subscribers.delete(sessionId);
    }
  }
}

Progress notifications

Progress notifications let a long-running tool report intermediate progress to the client without completing the tool call. The client provides a _meta.progressToken in the tool call parameters; the server emits notifications/progress with monotonically increasing progress values:

server.tool('process_large_dataset', {
  dataset_id: z.string()
}, async ({ dataset_id }, { meta }) => {
  const progressToken = meta?.progressToken;

  const rows = await db.query(
    'SELECT * FROM datasets WHERE id = $1',
    [dataset_id]
  );

  const total = rows.rowCount;
  let processed = 0;

  for (const row of rows.rows) {
    await processRow(row);
    processed++;

    // Emit progress every 100 rows
    if (progressToken && processed % 100 === 0) {
      await server.sendProgress({
        progressToken,
        progress: processed,
        total
      });
    }
  }

  return {
    content: [{
      type: 'text',
      text: JSON.stringify({ processed, total, status: 'complete' })
    }]
  };
});

Rules for progress notifications:

Logging notifications

The MCP logging system lets servers emit structured log messages to clients. Clients display these in developer tools, debug panels, or forward them to external log sinks. The client can set the minimum log level via logging/setLevel:

// Declare logging capability
const server = new Server(
  { name: 'my-mcp-server', version: '1.0.0' },
  { capabilities: { tools: {}, logging: {} } }
);

// Current minimum log level (set by client via logging/setLevel)
let minLogLevel: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency' = 'info';

import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js';
server.setRequestHandler(SetLevelRequestSchema, async ({ params }) => {
  minLogLevel = params.level;
  return {};
});

// Log levels in ascending severity order
const LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];

async function mcpLog(
  level: typeof minLogLevel,
  message: string,
  data?: Record<string, unknown>
): Promise<void> {
  // Only emit if level meets minimum threshold
  if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(minLogLevel)) return;

  try {
    await server.sendLoggingMessage({
      level,
      logger: 'my-mcp-server',
      data: { message, ...data }
    });
  } catch {
    // Logging failure should not affect tool execution
  }
}

// Usage in a tool handler
server.tool('sync_data', { source: z.string() }, async ({ source }) => {
  await mcpLog('info', 'Starting data sync', { source });
  try {
    const result = await syncFromSource(source);
    await mcpLog('info', 'Sync complete', { rows: result.rowCount });
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  } catch (e) {
    await mcpLog('error', 'Sync failed', { error: (e as Error).message, source });
    throw e;
  }
});

Notification delivery on SSE vs stdio transports

Notifications are delivered differently depending on the transport:

Transport Notification mechanism Silent failure mode
SSE (HTTP) Written to the open SSE stream as data: ... events Client disconnects; server continues writing to a dead socket; no error until write buffer fills
stdio (local) Written to stdout; client reads from its end of the pipe Client process exits; writes fail immediately with EPIPE (visible)
WebSocket Sent as a WebSocket text frame WebSocket closes with no reconnect; server WebSocket send() throws; catch it

SSE transport is the most problematic. A client behind a load balancer that terminates idle connections will silently drop the connection; the server's SSE write may queue in the kernel buffer and succeed locally for minutes before failing. Implement an SSE heartbeat (send a comment line : heartbeat\n\n every 30 seconds) to detect dead connections faster, and remove sessions from subscription maps when the SSE stream errors.

// SSE heartbeat for faster dead-connection detection
function startSseHeartbeat(res: express.Response, sessionId: string): NodeJS.Timeout {
  return setInterval(() => {
    try {
      res.write(': heartbeat\n\n');
    } catch {
      // Connection is dead — clean up
      clearInterval(heartbeatTimer);
      cleanupSession(sessionId);
    }
  }, 30_000);
}

Monitoring notification delivery

Add notification metrics to your health endpoint so you know whether notifications are being delivered rather than silently dropped:

const notificationMetrics = {
  sent: 0,
  failed: 0,
  activeSubscriptions: 0,
  activeSessions: 0
};

// Wrap sendResourceUpdated with metric tracking
async function sendResourceUpdatedTracked(params: { uri: string }, sessionId: string): Promise<void> {
  try {
    await server.sendResourceUpdated(params, sessionId);
    notificationMetrics.sent++;
  } catch (e) {
    notificationMetrics.failed++;
    throw e;
  }
}

app.get('/health/notifications', (req, res) => {
  const errorRate = notificationMetrics.sent > 0
    ? notificationMetrics.failed / (notificationMetrics.sent + notificationMetrics.failed)
    : 0;

  const status = errorRate > 0.1 ? 'degraded' : 'ok';

  res.status(status === 'ok' ? 200 : 503).json({
    status,
    metrics: {
      sent: notificationMetrics.sent,
      failed: notificationMetrics.failed,
      error_rate: errorRate.toFixed(3),
      active_subscriptions: [...subscriptions.values()].reduce((s, v) => s + v.size, 0)
    }
  });
});

Wire AliveMCP to /health/notifications. A spike in failed notifications means sessions are disconnecting faster than they're reconnecting — typically a sign of a backend restart, a load balancer timeout, or an upstream network partition that clients are experiencing but your server health check isn't detecting.

Frequently asked questions

Can clients send notifications too?

Yes. The MCP protocol allows both directions. Client notifications include notifications/initialized (session ready), notifications/cancelled (client is cancelling a pending request), and notifications/roots/list_changed (the workspace roots the client declared have changed). Your server can set handlers for these. The most important one to handle is notifications/roots/list_changed — if your server uses client-declared roots to scope filesystem access, you should re-fetch the roots list when you receive this notification.

What happens if I send a notification before the session is initialized?

Sending a notification before the initialized handshake is complete is a protocol violation. The SDK's transport layer will queue or discard the notification, and the client may reject it or disconnect if it arrives out of order. The safe pattern: only emit notifications inside event handlers registered after server.on('initialized', ...), or inside tool/resource/prompt handlers (which are only called after initialization).

Should I queue notifications for offline clients?

No — MCP notifications are session-scoped and ephemeral. There is no durable notification queue in the MCP protocol. When a client reconnects, it re-initializes the session and re-fetches the full list of tools/resources/prompts, which is the point-in-time state as of reconnection. Progress notifications for in-flight tool calls are lost on disconnect — those tool calls are also considered cancelled at the MCP layer. If you need durable message delivery (job status updates, system alerts), use a separate webhook or polling mechanism outside the MCP protocol.

Do notifications work with the MCP Inspector?

Yes. The MCP Inspector displays incoming notifications in its notification log panel. You can observe list-changed notifications being emitted when you trigger catalog changes, resource-updated notifications when subscribed resources change, and progress notifications during long-running tool calls. Use the Inspector to verify notification delivery before writing automated notification tests.

Further reading

Know when your notification delivery degrades

AliveMCP monitors your server's session and notification health endpoints, alerting you when clients are silently disconnecting and subscriptions are accumulating stale sessions.

Start monitoring free