The MCP (Model Context Protocol) has undergone several major iterations since Anthropic first released it in November 2024. The 2025-03-26 version represents the most significant update to date, comprehensively upgrading the protocol across authentication security, transport architecture, and tool metadata. If you're building or maintaining an MCP Server, this update deserves your full attention.

This article provides a deep dive into the technical details and design motivations behind these changes, with runnable code examples to help you adopt them quickly.

Key Takeaways

  • OAuth 2.1 mandatory upgrade: Deprecates implicit grant flow, enforces PKCE + HTTPS, supports dynamic client registration
  • Streamable HTTP replaces SSE: Single-endpoint design replaces dual-channel architecture, supports bidirectional communication and reconnection
  • Tool Annotations: Declare behavioral metadata for each tool (read-only/destructive/idempotent/open-world), driving intelligent permission control
  • JSON-RPC batching: Protocol-level mandatory support, dramatically reducing network overhead
  • Session management: Mcp-Session-Id header enables stateful connections and resume-from-disconnect
  • Ecosystem adoption: Claude, ChatGPT, VS Code, Cursor, and other major platforms fully supported

Looking for MCP Servers to use? Visit the MCP Directory for a complete ecosystem overview.

MCP Specification Evolution Timeline

The MCP protocol has gone through four significant versions since its inception:

Version Release Date Key Changes
2024-11-05 November 2024 Initial release, defines Tools/Resources/Prompts primitives, HTTP+SSE transport
2025-03-26 March 2025 OAuth 2.1, Streamable HTTP, Tool Annotations, JSON-RPC batching
2025-06-18 June 2025 Elicitation (server requests user input), security best practices, structured output
2025-11-25 November 2025 Further security enhancements, protocol stability improvements

The 2025-03-26 version is the most pivotal iteration, transforming MCP from a "local dev tool protocol" into an "enterprise production-ready protocol."

graph LR V1["2024-11-05 Initial Release"] -->|"4 months"| V2["2025-03-26 OAuth + Streamable HTTP"] V2 -->|"3 months"| V3["2025-06-18 Elicitation + Security"] V3 -->|"5 months"| V4["2025-11-25 Protocol Stabilization"] style V2 fill:#e6f3ff,stroke:#0066cc,stroke-width:3px

If you're not yet familiar with MCP fundamentals, start with MCP Protocol Deep Dive to understand the core architecture.

OAuth 2.1 Authentication Framework

Why Does MCP Need Authentication?

In MCP's initial version, Servers mostly ran locally via stdio, inheriting the current user's permissions with no need for additional authentication. However, as MCP Servers move to remote deployment, authentication becomes unavoidable:

  • Permission isolation: Different users access different data; remote Servers must identify callers
  • Tool safety: Destructive tools (e.g., database deletion) cannot be called by unauthorized AI Agents
  • Compliance: Enterprise environments require audit trails for API access

Key Upgrades from OAuth 2.0 to 2.1

MCP 2025-03-26 adopts the OAuth 2.1 standard directly, with three critical changes from OAuth 2.0:

Change OAuth 2.0 OAuth 2.1
Implicit grant flow Supported Completely deprecated
PKCE Optional Mandatory
HTTPS Recommended Mandatory

PKCE (Proof Key for Code Exchange) is the centerpiece of this upgrade. It eliminates man-in-the-middle attacks on authorization codes through a cryptographic challenge-response mechanism.

OAuth Authentication Flow

Here's the complete OAuth 2.1 + PKCE authorization flow in MCP:

sequenceDiagram participant C as "MCP Client" participant S as "MCP Server" participant A as "Authorization Server" participant U as "User" C->>S: GET /.well-known/oauth-authorization-server S-->>C: Return auth server metadata C->>A: POST /register (Dynamic Client Registration) A-->>C: Return client_id C->>C: Generate code_verifier + code_challenge C->>U: Open browser authorization page U->>A: Approve authorization A-->>C: Return authorization_code C->>A: POST /token (code + code_verifier) A-->>C: Return access_token + refresh_token C->>S: MCP request + Authorization: Bearer token S-->>C: Return tool call results

Dynamic Client Registration

The MCP spec mandates support for RFC 7591 Dynamic Client Registration, meaning MCP Clients don't need to be pre-configured on the Server side—they automatically register upon connection to obtain credentials:

json
POST /register HTTP/1.1
Content-Type: application/json

{
  "client_name": "My AI Assistant",
  "redirect_uris": ["http://localhost:3000/callback"],
  "grant_types": ["authorization_code"],
  "token_endpoint_auth_method": "none"
}

The response returns a client_id used throughout subsequent authorization flows.

Node.js Implementation: MCP Server with OAuth

Below is a Node.js implementation of an MCP Server compliant with the 2025-03-26 spec, integrating OAuth 2.1 authentication. If you need to inspect JWT token structures during debugging, use the JWT Generator & Parser.

javascript
import express from 'express';
import crypto from 'crypto';
import jwt from 'jsonwebtoken';

const app = express();
app.use(express.json());

// --- OAuth 2.1 Endpoints ---

// Authorization server metadata discovery
app.get('/.well-known/oauth-authorization-server', (req, res) => {
  res.json({
    issuer: 'https://mcp.example.com',
    authorization_endpoint: 'https://mcp.example.com/authorize',
    token_endpoint: 'https://mcp.example.com/token',
    registration_endpoint: 'https://mcp.example.com/register',
    response_types_supported: ['code'],
    code_challenge_methods_supported: ['S256'],
    grant_types_supported: ['authorization_code', 'refresh_token']
  });
});

// Dynamic Client Registration (RFC 7591)
const clients = new Map();
app.post('/register', (req, res) => {
  const clientId = crypto.randomUUID();
  clients.set(clientId, {
    client_name: req.body.client_name,
    redirect_uris: req.body.redirect_uris,
    created_at: Date.now()
  });
  res.status(201).json({ client_id: clientId });
});

// Token endpoint (PKCE verification)
app.post('/token', (req, res) => {
  const { code, code_verifier, client_id } = req.body;
  const stored = authCodes.get(code);
  if (!stored) return res.status(400).json({ error: 'invalid_grant' });

  // Verify PKCE: SHA256(code_verifier) === code_challenge
  const challenge = crypto
    .createHash('sha256')
    .update(code_verifier)
    .digest('base64url');
  if (challenge !== stored.code_challenge) {
    return res.status(400).json({ error: 'invalid_grant' });
  }

  const accessToken = jwt.sign(
    { sub: stored.user_id, client_id, scope: stored.scope },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = crypto.randomUUID();

  res.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 900,
    refresh_token: refreshToken
  });
});

// --- MCP Streamable HTTP Endpoint ---
app.post('/mcp', authenticateToken, (req, res) => {
  const body = req.body;
  if (Array.isArray(body)) {
    // Batch mode
    const results = body.map(msg => handleJsonRpc(msg, req.user));
    res.json(results.filter(r => r !== null));
  } else {
    const result = handleJsonRpc(body, req.user);
    result ? res.json(result) : res.status(202).end();
  }
});

function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'unauthorized' });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(403).json({ error: 'invalid_token' });
  }
}

Python Implementation: OAuth PKCE Client

python
import hashlib
import base64
import os
import httpx

class MCPOAuthClient:
    def __init__(self, server_url: str):
        self.server_url = server_url
        self.client_id = None
        self.access_token = None

    async def discover(self):
        """Discover authorization server metadata"""
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{self.server_url}/.well-known/oauth-authorization-server"
            )
            return resp.json()

    async def register(self):
        """Dynamic client registration"""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{self.server_url}/register",
                json={
                    "client_name": "Python MCP Client",
                    "redirect_uris": ["http://localhost:8080/callback"],
                    "grant_types": ["authorization_code"]
                }
            )
            self.client_id = resp.json()["client_id"]

    def generate_pkce(self) -> tuple[str, str]:
        """Generate PKCE code_verifier and code_challenge"""
        code_verifier = base64.urlsafe_b64encode(
            os.urandom(32)
        ).decode('utf-8').rstrip('=')
        code_challenge = base64.urlsafe_b64encode(
            hashlib.sha256(code_verifier.encode()).digest()
        ).decode('utf-8').rstrip('=')
        return code_verifier, code_challenge

    async def exchange_token(self, code: str, code_verifier: str):
        """Exchange authorization code + code_verifier for Access Token"""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{self.server_url}/token",
                data={
                    "grant_type": "authorization_code",
                    "code": code,
                    "code_verifier": code_verifier,
                    "client_id": self.client_id
                }
            )
            tokens = resp.json()
            self.access_token = tokens["access_token"]
            return tokens

    async def call_tool(self, tool_name: str, arguments: dict):
        """Call an MCP tool"""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{self.server_url}/mcp",
                headers={"Authorization": f"Bearer {self.access_token}"},
                json={
                    "jsonrpc": "2.0",
                    "id": 1,
                    "method": "tools/call",
                    "params": {
                        "name": tool_name,
                        "arguments": arguments
                    }
                }
            )
            return resp.json()

Streamable HTTP: Transport Layer Revolution

From Dual Endpoints to Single Endpoint

The original MCP (2024-11-05) used an HTTP+SSE dual-channel approach: GET /sse established a Server→Client push channel, while POST /message received Client→Server JSON-RPC requests. This design had significant engineering pain points—if you've ever implemented this scheme (see Building SSE Transport from Scratch with Go), you know the complexity of managing dual connections firsthand.

The 2025-03-26 version introduces Streamable HTTP to completely overhaul the transport layer:

Comparison Legacy HTTP+SSE Streamable HTTP
Endpoints 2 (GET /sse + POST /message) 1 (POST /mcp)
Communication Unidirectional push (SSE) + request-response (POST) Bidirectional
Reconnection Requires full session rebuild Supports Last-Event-ID resume
Simple requests Forced through streaming Can return direct JSON response
Connection time ~320ms ~180ms (44% reduction)

Core Mechanism

The intelligence of Streamable HTTP lies in protocol negotiation—the Client declares its capabilities via the Accept header, and the Server chooses its response strategy accordingly:

http
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: sess_abc123

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": { "name": "long_running_task", "arguments": {} }
}

The Server can choose between two response strategies:

Strategy 1: Simple JSON response (short tasks)

http
HTTP/1.1 200 OK
Content-Type: application/json

{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "done"}]}}

Strategy 2: SSE streaming response (long tasks)

http
HTTP/1.1 200 OK
Content-Type: text/event-stream

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":50,"message":"Processing..."}}

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"Complete"}]}}

Session Management and Reconnection

The new Mcp-Session-Id HTTP header provides critical reliability for long-running tasks. When reconnecting after a network interruption, the Client carries the previous Session ID and Last-Event-ID, allowing the Server to resume from the breakpoint without reinitialization:

javascript
// Client reconnection logic
async function reconnect(sessionId, lastEventId) {
  const response = await fetch('/mcp', {
    method: 'GET',
    headers: {
      'Accept': 'text/event-stream',
      'Mcp-Session-Id': sessionId,
      'Last-Event-ID': lastEventId
    }
  });
  // Server continues pushing events from after lastEventId
  const reader = response.body.getReader();
  // ... process SSE event stream
}

For connection management strategies in high-concurrency scenarios, refer to High-Concurrency MCP Gateway Architecture.

Tool Annotations: Behavioral Metadata for Tools

Why Tool Annotations?

In the initial MCP version, tools only had name, description, and inputSchema fields. Clients (or LLMs) could only "guess" what a tool does based on text descriptions—dangerously unreliable for destructive operations.

The 2025-03-26 version introduces Tool Annotations, allowing Servers to declare structured behavioral metadata for each tool:

typescript
interface ToolAnnotations {
  title?: string;              // Semantic title (for UI display)
  readOnlyHint?: boolean;      // Read-only (default: false)
  destructiveHint?: boolean;   // Destructive (default: true)
  idempotentHint?: boolean;    // Idempotent (default: false)
  openWorldHint?: boolean;     // External interaction (default: true)
}

The Four Hint Fields Explained

Hint Field Default Meaning Typical Scenarios
readOnlyHint false Tool doesn't modify any external state Database queries, file reads
destructiveHint true Tool may cause irreversible damage Deleting records, resetting configs
idempotentHint false Repeated calls with same args have no additional effect Update operations (PUT semantics)
openWorldHint true Tool interacts with the outside world Sending emails, calling third-party APIs

Important: All Hint fields are advisory only. Clients must not use them as the sole basis for security controls. Actual permission checks must be enforced by the Server's RBAC engine.

Practical Examples

json
{
  "tools": [
    {
      "name": "query_database",
      "description": "Execute a read-only SQL query",
      "inputSchema": {
        "type": "object",
        "properties": {
          "sql": { "type": "string" }
        }
      },
      "annotations": {
        "title": "Database Query",
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      }
    },
    {
      "name": "delete_user",
      "description": "Permanently delete a user account",
      "inputSchema": {
        "type": "object",
        "properties": {
          "user_id": { "type": "string" }
        }
      },
      "annotations": {
        "title": "Delete User Account",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": false
      }
    },
    {
      "name": "send_email",
      "description": "Send an email to the specified recipient",
      "inputSchema": {
        "type": "object",
        "properties": {
          "to": { "type": "string" },
          "subject": { "type": "string" },
          "body": { "type": "string" }
        }
      },
      "annotations": {
        "title": "Send Email",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      }
    }
  ]
}

Clients can use these Annotations to build intelligent UIs—for example, automatically showing confirmation dialogs for destructiveHint: true tools, or skipping confirmation for readOnlyHint: true tools. This aligns with the security philosophy of Function Calling and Tool Use.

JSON-RPC Batching and Progress Notifications

Protocol-Level Batching Support

The 2025-03-26 version mandates that all MCP implementations must support the JSON-RPC 2.0 batching specification. This allows Clients to send multiple JSON-RPC calls in a single HTTP request, dramatically reducing network overhead.

json
[
  {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "analyze_text", "arguments": {"text": "hello"}}},
  {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "translate", "arguments": {"text": "hello", "target": "zh"}}},
  {"jsonrpc": "2.0", "method": "notifications/initialized"}
]

The performance gains from batching are substantial:

Metric Single-Request Mode Batch Mode Improvement
TCP handshakes (100 requests) 100 1 99%
Total header overhead ~150KB ~2KB 98.7%
Total time (3G network) 12.3s 1.8s 85.4%

Enhanced Progress Notifications

The updated progress notification adds a message field supporting semantic status descriptions:

json
{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "task_123",
    "progress": 65,
    "total": 100,
    "message": "Data cleaning: processed 12000/20000 records"
  }
}

This enables UIs to display meaningful progress information rather than just a percentage number.

Ecosystem Support and MCP Registry

Platform Support Status

As of 2026, MCP has achieved comprehensive ecosystem adoption:

Platform Status Protocol Version Notes
Claude Desktop ✅ Native 2025-03-26+ Anthropic's official client, most complete support
ChatGPT ✅ Supported 2025-03-26+ Via Plugin/Actions integration
VS Code / Copilot ✅ Supported 2025-03-26+ GitHub Copilot Agent mode
Cursor ✅ Supported 2025-03-26+ Deep integration, custom MCP Server support
Windsurf ✅ Supported 2025-03-26+ Codeium's AI IDE

Discover more MCP-compatible AI clients and apps in the AI Directory.

GitHub MCP Registry

In September 2025, GitHub launched the official MCP Registry preview—an "app store" for MCP Servers providing a centralized discovery and distribution platform:

  • Authoritative index: Single source of truth for publicly available MCP Servers
  • Community-driven: Maintained by core contributors including Anthropic, GitHub, and Microsoft
  • Client integration: Tools like VS Code can install MCP Servers directly from the Registry
  • Quality assurance: Provides reputation scoring and security audit information

For Server developers, publishing your MCP Server to the Registry significantly increases discoverability. You can use the JSON Formatter to validate and prettify your Registry configuration files.

Migration Guide: Upgrading to the New Spec

Migration Checklist

If you're maintaining an MCP Server based on the 2024-11-05 version, here's the complete migration roadmap to 2025-03-26:

Step 1: Transport Layer Upgrade (Highest Priority)

javascript
// ❌ Legacy: Dual-endpoint HTTP+SSE
app.get('/sse', handleSSE);
app.post('/message', handleMessage);

// ✅ New: Single-endpoint Streamable HTTP
app.post('/mcp', handleStreamableHTTP);
app.get('/mcp', handleSSEStream); // Optional: for server-initiated push

Step 2: Implement OAuth 2.1 Authentication

javascript
// Add metadata discovery endpoint
app.get('/.well-known/oauth-authorization-server', (req, res) => {
  res.json({ /* auth server configuration */ });
});

// Add dynamic client registration
app.post('/register', handleDynamicRegistration);

// Add auth middleware to all MCP endpoints
app.post('/mcp', authenticateOAuth, handleMCP);

Step 3: Add Tool Annotations

javascript
// ❌ Legacy: Basic description only
{ name: 'delete_file', description: 'Delete a file' }

// ✅ New: Includes behavioral metadata
{
  name: 'delete_file',
  description: 'Delete a file',
  annotations: {
    title: 'Delete File',
    readOnlyHint: false,
    destructiveHint: true,
    idempotentHint: false,
    openWorldHint: false
  }
}

Step 4: Support JSON-RPC Batching and Session Management

javascript
app.post('/mcp', (req, res) => {
  // Handle Mcp-Session-Id
  const sessionId = req.headers['mcp-session-id'] || generateSessionId();
  res.setHeader('Mcp-Session-Id', sessionId);

  // Support batching
  if (Array.isArray(req.body)) {
    const results = req.body.map(msg => processMessage(msg, sessionId));
    return res.json(results.filter(Boolean));
  }
  // Single message handling
  const result = processMessage(req.body, sessionId);
  result ? res.json(result) : res.status(202).end();
});

Backward Compatibility

The MCP spec recommends that Servers support both old and new transport modes during the transition period, using request headers for version negotiation. Requests from legacy Clients are automatically routed to compatibility mode. For advanced MCP implementation techniques, see Advanced MCP Protocol Practice.

Summary

The MCP 2025-03-26 version marks a critical step in the protocol's evolution from "usable" to "reliable." OAuth 2.1 solves remote deployment authentication challenges, Streamable HTTP simplifies transport architecture, and Tool Annotations make tool invocations safer and more controllable. For developers building AI applications, keeping up with these spec changes will help you build more secure, efficient, and maintainable MCP integrations.

If you're looking for ready-to-use MCP Servers or want to publish your own, visit the MCP Server Directory to explore the complete ecosystem.


This article is part of the series: MCP Protocol Mastery — A complete learning path from fundamentals to enterprise-grade implementation.