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-Idheader 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."
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:
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:
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.
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
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:
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/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/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:
// 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:
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
{
"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.
[
{"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:
{
"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)
// ❌ 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
// 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
// ❌ 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
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.