Guide · MCP Protocol Primitives

MCP Capabilities Negotiation — protocol handshake, feature detection, and version compatibility

Every MCP session begins with a capabilities negotiation: the client sends an initialize request declaring its protocol version and what capabilities it supports; the server responds with its own version and capabilities. Neither side should use a feature the other side hasn't declared. This handshake is the boundary between the protocol's guarantee layer and the application layer — miss it and you ship a server that fails silently when paired with clients that don't support sampling, roots, or subscriptions. This guide covers the initialize flow, what each capability declaration means, how to write defensive feature-detection code, how to handle version mismatches gracefully, and how to monitor whether the handshake is completing at all.

TL;DR

Declare your server capabilities in the Server constructor: { capabilities: { tools: {}, resources: { subscribe: true }, prompts: { listChanged: true } } }. Read client capabilities from server.getClientCapabilities() after initialization before using sampling, roots, or elicitation. Respond to protocol version mismatches by logging and gracefully degrading, not by crashing. Monitor your initialization path with a /health/init endpoint that verifies a probe client can complete the handshake end-to-end — a server that accepts TCP connections but hangs on the initialize response is invisible to standard uptime checks.

The initialize / initialized flow

The MCP handshake is a two-step exchange that happens before any tool, resource, or prompt request:

// Step 1: Client sends initialize
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "clientInfo": {
      "name": "Claude Desktop",
      "version": "1.2.3"
    },
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    }
  }
}

// Step 2: Server responds
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "serverInfo": {
      "name": "my-mcp-server",
      "version": "1.0.0"
    },
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts": { "listChanged": true },
      "logging": {}
    }
  }
}

// Step 3: Client sends initialized notification (no response expected)
{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

After receiving notifications/initialized, the session is live. The server must not send any other requests before notifications/initialized arrives — this includes sampling/createMessage, roots requests, or elicitation requests. The three-step handshake is mandatory even if both sides already know what capabilities they support.

Declaring server capabilities

Declare capabilities in the Server constructor. The capability object tells clients what they can do with your server:

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

const server = new Server(
  {
    name: 'my-mcp-server',
    version: '1.0.0'
  },
  {
    capabilities: {
      // Tools — declare if your server exposes tools
      tools: {
        listChanged: true // emit notifications/tools/list_changed when tool catalog changes
      },

      // Resources — declare if your server exposes resources
      resources: {
        subscribe: true,    // clients can subscribe to individual resource URIs
        listChanged: true   // emit notifications/resources/list_changed when catalog changes
      },

      // Prompts — declare if your server exposes prompts
      prompts: {
        listChanged: true   // emit notifications/prompts/list_changed when catalog changes
      },

      // Logging — declare if your server handles logging/setLevel requests
      logging: {},

      // Completions — declare if your server handles completion/complete requests
      completions: {},

      // Experimental — namespace for unreleased protocol features
      experimental: {
        'my-company/feature-v0': {}
      }
    }
  }
);
Capability What it unlocks Sub-fields
tools Server handles tools/list and tools/call listChanged: server emits notifications/tools/list_changed
resources Server handles resources/list and resources/read subscribe: clients can subscribe; listChanged: catalog notifications
prompts Server handles prompts/list and prompts/get listChanged: server emits notifications/prompts/list_changed
logging Server handles logging/setLevel No sub-fields
completions Server handles completion/complete for argument autocomplete No sub-fields

Only declare capabilities you implement. A client that sees resources: { subscribe: true } will send resources/subscribe requests. If your handler throws MethodNotFound, the client treats your server as broken, not as gracefully degraded.

Reading client capabilities in handlers

After initialization, use server.getClientCapabilities() to check what the connected client supports. Call it lazily — it returns null before initialization completes:

// In a tool handler — check before using sampling
server.tool('classify_ticket', { text: z.string() }, async ({ text }) => {
  const clientCaps = server.getClientCapabilities();

  if (clientCaps?.sampling) {
    // Safe to use sampling — client declared it
    return await classifyViaSampling(text);
  } else {
    // Fallback — client doesn't support sampling
    return await classifyWithKeywords(text);
  }
});

// Check roots capability before requesting filesystem context
server.tool('analyse_workspace', {}, async (_, { meta }) => {
  const clientCaps = server.getClientCapabilities();

  if (!clientCaps?.roots) {
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ error: 'Client does not support roots — cannot determine workspace boundary' })
      }]
    };
  }

  const roots = await server.requestRoots();
  // ... analyse files within declared roots
});

Protocol version compatibility

The current MCP protocol version is 2024-11-05. The server responds with the version it supports; if there is a mismatch the client decides whether to proceed. Handle mismatches defensively rather than hard-crashing:

import { InitializeRequestSchema } from '@modelcontextprotocol/sdk/types.js';

const SUPPORTED_VERSIONS = ['2024-11-05', '2025-03-26'];
const CURRENT_VERSION = '2024-11-05';

server.setRequestHandler(InitializeRequestSchema, async ({ params }) => {
  const requestedVersion = params.protocolVersion;

  if (!SUPPORTED_VERSIONS.includes(requestedVersion)) {
    console.warn(
      `Client requested protocol version ${requestedVersion}, ` +
      `server supports ${SUPPORTED_VERSIONS.join(', ')}. ` +
      `Responding with ${CURRENT_VERSION} — client may disconnect.`
    );
    // Per spec: respond with your supported version; client decides
  }

  return {
    protocolVersion: CURRENT_VERSION,
    serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
    capabilities: { tools: { listChanged: true } }
  };
});

The MCP spec states the server should respond with the version it actually supports even if the client asked for a different one. The client is then responsible for deciding whether to proceed with the mismatched version or disconnect. Never reject the initialize request outright based on version alone — the client's disconnect path is cleaner than an error response at the initialize level.

Capability-conditional feature registration

Some server features only make sense when the client supports certain capabilities. Register them conditionally after the session is initialized rather than at server startup:

// Register sampling-dependent tools only when client supports sampling
server.on('initialized', () => {
  const caps = server.getClientCapabilities();

  if (caps?.sampling) {
    // Register tools that require sampling for core functionality
    server.tool('analyse_with_ai', { data: z.string() }, handleAnalyseWithAi);
    server.tool('generate_summary', { uri: z.string() }, handleGenerateSummary);

    // Notify client that the tool list has changed
    server.sendToolListChanged();
  }

  if (caps?.roots) {
    // Register workspace-aware tools only when client declares workspace roots
    server.tool('analyse_workspace', {}, handleAnalyseWorkspace);
    server.sendToolListChanged();
  }
});

This pattern keeps your tool catalog clean — clients that don't support sampling don't see sampling-dependent tools in their tools/list response, avoiding confusing failures when those tools are called without their prerequisite capability.

Experimental capabilities

The experimental namespace lets servers and clients negotiate features that aren't in the published spec yet. Use it for early-adoption features, private protocol extensions, or unreleased spec drafts. Both sides must declare the experimental key for it to mean anything:

// Server declares experimental support
const server = new Server(
  { name: 'my-server', version: '1.0.0' },
  {
    capabilities: {
      tools: {},
      experimental: {
        // Namespaced to avoid collisions with the spec and other vendors
        'my-company/batch-tools': { version: '0.2' },
        'my-company/streaming-results': { version: '0.1' }
      }
    }
  }
);

// Check in a handler
server.tool('run_batch', { operations: z.array(z.string()) }, async ({ operations }) => {
  const caps = server.getClientCapabilities();
  const batchCap = caps?.experimental?.['my-company/batch-tools'] as { version: string } | undefined;

  if (!batchCap) {
    // Client doesn't support the batch protocol extension
    // Fall back to single-operation execution
    return await runSingleOperation(operations[0]);
  }

  return await runBatchOperations(operations, batchCap.version);
});

Monitoring the initialization path

A standard HTTP uptime check (ping the TCP port, verify HTTP 200) tells you nothing about whether MCP initialization is succeeding. A server can accept a TCP connection and return HTTP 200 on /health while consistently hanging on the MCP handshake — the JSON-RPC initialize request never completes because the handler is deadlocked, a database call inside initialization is timing out, or a missing async await causes the response to be sent before the handler has run.

The correct probe for MCP servers is a full protocol-layer check that completes the initialize/initialized handshake. This is exactly what AliveMCP's MCP-aware probe does — it connects, runs the handshake, and verifies the server returns a valid capabilities response before declaring the endpoint healthy.

Expose a companion HTTP endpoint for your monitoring stack to use alongside the protocol probe:

// Track initialization metrics
let initializationCount = 0;
let lastInitializedAt: Date | null = null;
let initializationErrorCount = 0;

server.on('initialized', () => {
  initializationCount++;
  lastInitializedAt = new Date();
});

server.on('close', (error) => {
  if (error) initializationErrorCount++;
});

app.get('/health/init', (req, res) => {
  const secondsSinceInit = lastInitializedAt
    ? (Date.now() - lastInitializedAt.getTime()) / 1000
    : null;

  res.json({
    status: 'ok',
    total_sessions: initializationCount,
    last_initialized_seconds_ago: secondsSinceInit,
    error_count: initializationErrorCount,
    server_capabilities: server.capabilities
  });
});

Wire AliveMCP to both the protocol probe and /health/init. Protocol-layer failures — negotiate timeout, capability mismatch causing client disconnect, initialized notification never arriving — don't generate HTTP errors that standard monitors catch. They only appear as a missing or stalled protocol handshake.

Frequently asked questions

Does every capability sub-field have to be true?

No — the presence of the sub-field key, not its value, signals support. { resources: { subscribe: true } } and { resources: { subscribe: false } } are technically both valid, but conventionally you omit a sub-field to indicate lack of support rather than setting it to false. The listChanged sub-field on a capability means "this party will emit the corresponding list-change notification" — if you declare it but never send the notification, clients will never know the catalog changed. Only declare it if you actually implement the notification emission.

Can I change capabilities after initialization?

No — the capabilities negotiated at initialize time are fixed for the lifetime of the session. You can add or remove individual tools, resources, and prompts (and notify via list-changed notifications), but you cannot retroactively add sampling or roots capability after the handshake. If a new client connection arrives, it negotiates capabilities fresh. Design your server so capabilities declarations reflect the server's general support, not per-session state.

What happens if I use sampling without the client declaring support?

The server sends a sampling/createMessage request; the client responds with a MethodNotFound error (code -32601). Your handler receives an exception. If the exception is uncaught, the tool handler crashes and the client receives an InternalError result. If caught, you return to your fallback path. Always check clientCapabilities.sampling before calling createMessage — the capability check is cheaper than a round-trip that you know will fail.

How do I test capabilities negotiation in my server?

Use the MCP Inspector (MCP Inspector guide) to connect to your server and inspect the handshake. The Inspector shows you the full initialize/initialized exchange, the client capabilities it sent, and the server capabilities it received. For automated testing, use the MCP TypeScript SDK's InMemoryTransport to write integration tests that create both a real server instance and a test client, run the handshake, and assert on the capabilities response. See MCP Server Testing for the full test harness pattern.

Further reading

Know when your MCP handshake stops completing

AliveMCP runs a full MCP protocol probe — initialize handshake included — every 60 seconds, catching hangs and negotiation failures that HTTP checks miss entirely.

Start monitoring free