Topic: capability negotiation security

MCP server capability negotiation security — minimal capability request, downgrade attacks, feature flag injection

MCP capability negotiation happens in the initialize handshake — the very first message exchanged between client and server. The server's capabilities object in the response declares what features are active for the session: tools, resources, prompts, sampling, logging, experimental features. A server that requests every capability regardless of what it actually uses is the MCP equivalent of requesting root access: maximum blast radius, minimum audit trail. This page covers five capability negotiation security patterns: minimal capability declaration, explicit feature flags over discovery, client capability validation, downgrade attack detection, and initialization integrity checks.

1. Minimal capability declaration

The MCP initialize response carries a capabilities object that tells the client what the server supports. The most common anti-pattern is declaring every capability the SDK supports by default, regardless of whether the server implements them. This broadens the attack surface because it tells the client to route capability-specific traffic to the server — and traffic that arrives for unimplemented capabilities creates edge cases in error handling that are often undertested.

The minimal capability principle: declare only the capabilities your server actually implements and intends to respond to. If your server exposes tools and nothing else, the capability object should contain only tools. If sampling is not implemented, do not declare it — a client that attempts to use undeclared capabilities will fail at the protocol level rather than reaching your handler code.

// ANTI-PATTERN: declaring all capabilities regardless of implementation
const server = new McpServer({
  name: 'my-server',
  version: '1.0.0',
  capabilities: {
    tools: {},
    resources: { subscribe: true, listChanged: true },  // not implemented
    prompts: { listChanged: true },                       // not implemented
    sampling: {},                                         // not implemented
    logging: {},
    experimental: { anyFeature: true }                   // not implemented
  }
})

// SECURE: declare only what is implemented
const IMPLEMENTED_CAPABILITIES = {
  tools: {
    listChanged: false  // explicit: we don't push tool list changes
  }
  // no resources, prompts, sampling, or experimental
}

const server = new McpServer({
  name: 'my-server',
  version: '1.0.0',
  capabilities: IMPLEMENTED_CAPABILITIES
})

// Validate at startup that every declared capability has a handler
function validateCapabilityHandlers(capabilities: ServerCapabilities): void {
  if (capabilities.resources && !resourceListHandler) {
    throw new Error('resources capability declared but no list handler registered')
  }
  if (capabilities.prompts && !promptListHandler) {
    throw new Error('prompts capability declared but no list handler registered')
  }
  if (capabilities.sampling && !samplingHandler) {
    throw new Error('sampling capability declared but no createMessage handler registered')
  }
}
validateCapabilityHandlers(IMPLEMENTED_CAPABILITIES)

2. Explicit feature flags over discovery

Some MCP servers implement optional features — rate-limited tool access, subscription-based resource updates, experimental prompt templates — and expose them to clients through capability negotiation rather than hardcoded behavior. The security risk arises when those features are enabled through discovery (the client checks what's available and uses it) rather than explicit activation (the server only enables features for sessions that are explicitly authorized for them).

An attacker with access to the MCP transport can send a crafted initialize request that claims to be a client supporting features that grant elevated access. If the server enables features based solely on what the client claims to support, the attacker has used the negotiation protocol to elevate their capabilities.

// VULNERABLE: enabling features based on client-claimed capabilities
server.setRequestHandler(InitializeRequestSchema, async (request) => {
  const clientCaps = request.params.capabilities

  // WRONG: client controls which features it gets
  const enableAdvancedTools = clientCaps.experimental?.advancedMode === true
  const enableResourceSubscriptions = clientCaps.resources?.subscribe === true

  return {
    protocolVersion: '2025-11-05',
    serverInfo: { name: 'my-server', version: '1.0.0' },
    capabilities: {
      tools: {},
      ...(enableAdvancedTools ? { experimental: { advancedMode: true } } : {}),
      ...(enableResourceSubscriptions ? { resources: { subscribe: true } } : {})
    }
  }
})

// SECURE: features are enabled by server-side authorization, not client claims
interface SessionConfig {
  allowAdvancedTools: boolean
  allowSubscriptions: boolean
}

function getSessionConfig(clientId: string | undefined): SessionConfig {
  // authorization check — client identity from transport auth, not initialize params
  const authorized = authorizedClients.get(clientId ?? '')
  return {
    allowAdvancedTools: authorized?.tier === 'premium',
    allowSubscriptions: authorized?.plan === 'team'
  }
}

server.setRequestHandler(InitializeRequestSchema, async (request, context) => {
  // clientId comes from transport authentication, not the request body
  const config = getSessionConfig(context.clientId)

  return {
    protocolVersion: '2025-11-05',
    serverInfo: { name: 'my-server', version: '1.0.0' },
    capabilities: {
      tools: {},
      ...(config.allowAdvancedTools ? { experimental: { advancedMode: true } } : {}),
      ...(config.allowSubscriptions ? { resources: { subscribe: true } } : {})
    }
  }
})

3. Client capability validation before use

Some MCP servers use client capabilities — notably sampling (asking the client's LLM to generate content) and roots (reading the client's filesystem context). Before invoking any client capability, validate that the client declared it in the initialize exchange. Attempting to call a capability the client did not declare will result in an error at the transport level, but the error handling path is often less tested than the success path — and error paths in security-sensitive code are frequent vulnerability locations.

// Track client capabilities from initialization
interface SessionState {
  clientCapabilities: ClientCapabilities
  protocolVersion: string
  initialized: boolean
}

const sessionState = new Map<string, SessionState>()

server.setRequestHandler(InitializeRequestSchema, async (request, context) => {
  sessionState.set(context.sessionId, {
    clientCapabilities: request.params.capabilities,
    protocolVersion: request.params.protocolVersion,
    initialized: true
  })
  return { /* ... */ }
})

// Before using client sampling capability
async function requestClientSampling(
  sessionId: string,
  messages: SamplingMessage[]
): Promise<string> {
  const state = sessionState.get(sessionId)

  if (!state?.initialized) {
    throw new Error('Session not initialized — cannot use client capabilities')
  }

  if (!state.clientCapabilities.sampling) {
    // Explicit failure rather than attempting the call and handling transport error
    throw new McpError(
      ErrorCode.MethodNotFound,
      'Client does not support sampling capability — cannot request LLM generation'
    )
  }

  return await client.createMessage({ messages, maxTokens: 1024 })
}

// Before reading client roots
async function readClientRoots(sessionId: string): Promise<Root[]> {
  const state = sessionState.get(sessionId)
  if (!state?.clientCapabilities.roots) {
    throw new McpError(
      ErrorCode.MethodNotFound,
      'Client does not support roots capability'
    )
  }
  return await client.listRoots()
}

4. Downgrade attack detection

MCP protocol versioning follows a date-based scheme (e.g., 2025-11-05). A server that supports a newer protocol version may also accept older versions for compatibility. If newer protocol versions introduced security-relevant features — tighter schema validation, additional required fields, explicit permission grants — accepting a downgrade to an older version bypasses those features.

Downgrade attacks in MCP are currently theoretical, but the pattern is common enough in other protocol security contexts (TLS downgrade, HTTP/2 to HTTP/1.1, SAML version attacks) that implementing detection is prudent. At minimum, log when a client requests a protocol version older than your server's preferred version, and validate that your security invariants hold under the older version.

const PREFERRED_PROTOCOL = '2025-11-05'
const MINIMUM_PROTOCOL = '2024-11-05'
const SECURITY_BASELINE_PROTOCOL = '2025-11-05' // versions below this require extra validation

function negotiateProtocolVersion(
  clientVersion: string
): { version: string; downgraded: boolean } {
  const supported = ['2025-11-05', '2024-11-05']

  if (!supported.includes(clientVersion)) {
    // Reject versions we don't know
    throw new McpError(
      ErrorCode.InvalidRequest,
      `Unsupported protocol version: ${clientVersion}. Supported: ${supported.join(', ')}`
    )
  }

  const downgraded = clientVersion !== PREFERRED_PROTOCOL

  if (downgraded) {
    // Log the downgrade event for audit trail
    logger.warn('protocol_downgrade', {
      requestedVersion: clientVersion,
      preferredVersion: PREFERRED_PROTOCOL,
      sessionId: context.sessionId
    })

    // If the requested version is below the security baseline, apply compensating controls
    if (clientVersion < SECURITY_BASELINE_PROTOCOL) {
      logger.warn('protocol_below_security_baseline', {
        version: clientVersion,
        baseline: SECURITY_BASELINE_PROTOCOL
      })
      // Disable features that require baseline security guarantees
      disableFeaturesBelowBaseline()
    }
  }

  return { version: clientVersion, downgraded }
}

5. Initialization integrity checks

The initialize/initialized handshake is a two-step sequence. After the server sends its initialize response, it must receive the client's initialized notification before the session is active. Servers that skip the initialized notification check and begin accepting tool calls immediately after sending the initialize response open a race condition: a malicious client can send tool calls before the initialization sequence completes, potentially bypassing initialization-time security checks.

// Track session initialization state
enum SessionPhase {
  AWAITING_INIT = 'awaiting_init',
  INIT_SENT = 'init_sent',        // server sent initialize response
  ACTIVE = 'active',              // client sent initialized notification
  TERMINATED = 'terminated'
}

const sessionPhase = new Map<string, SessionPhase>()

server.setRequestHandler(InitializeRequestSchema, async (request, context) => {
  sessionPhase.set(context.sessionId, SessionPhase.INIT_SENT)
  return buildInitializeResponse(request.params)
})

server.setNotificationHandler(InitializedNotificationSchema, (notification, context) => {
  const phase = sessionPhase.get(context.sessionId)
  if (phase !== SessionPhase.INIT_SENT) {
    logger.warn('unexpected_initialized_notification', { phase, sessionId: context.sessionId })
    return
  }
  sessionPhase.set(context.sessionId, SessionPhase.ACTIVE)
  logger.info('session_active', { sessionId: context.sessionId })
})

// Guard on all tool, resource, and prompt handlers
function requireActiveSession(context: RequestContext): void {
  const phase = sessionPhase.get(context.sessionId)
  if (phase !== SessionPhase.ACTIVE) {
    throw new McpError(
      ErrorCode.InvalidRequest,
      `Session ${context.sessionId} is not in ACTIVE phase (current: ${phase ?? 'unknown'}). ` +
      'The initialized notification must be received before tool calls are accepted.'
    )
  }
}

server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  requireActiveSession(context)
  // ... tool handler logic
})

SkillAudit checks for capability negotiation

SkillAudit scans for these patterns automatically. Scan your MCP server.