Skip to content

Commit 40eae95

Browse files
committed
feat: add per-request HTTP headers support for call_tool()
Implements per-request headers functionality to enable various use cases such as multi-tenant authentication, request tracing, A/B testing, and debugging while maintaining a single persistent connection. Changes: - Add extra_headers parameter to ClientSession.call_tool() - Extend ClientMessageMetadata to support extra_headers - Update StreamableHTTPTransport to merge per-request headers - Add tests - Include usage examples and documentation - Maintain full backward compatibility This addresses GitHub issues #1509 and supports multi-tenant scenarios where different requests require different authentication tokens or contextual headers. Future work will extend extra_headers support to other ClientSession methods (get_prompt, read_resource, etc.) based on maintainer feedback.
1 parent 6c26d08 commit 40eae95

File tree

6 files changed

+598
-1
lines changed

6 files changed

+598
-1
lines changed

README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2153,6 +2153,114 @@ if __name__ == "__main__":
21532153
_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_
21542154
<!-- /snippet-source -->
21552155

2156+
### Per-Request HTTP Headers
2157+
2158+
When using HTTP transports, you can pass custom headers on a per-request basis. This enables various use cases such as request tracing, authentication context, A/B testing, debugging flags, and more while maintaining a single persistent connection:
2159+
2160+
<!-- snippet-source examples/snippets/clients/per_request_headers_example.py -->
2161+
```python
2162+
"""
2163+
Example demonstrating per-request HTTP headers functionality.
2164+
Run from the repository root:
2165+
uv run examples/snippets/clients/per_request_headers_example.py
2166+
"""
2167+
2168+
import asyncio
2169+
2170+
from mcp import ClientSession
2171+
from mcp.client.streamable_http import streamablehttp_client
2172+
2173+
2174+
async def main():
2175+
"""Demonstrate per-request headers usage."""
2176+
# Connect to a streamable HTTP server
2177+
async with streamablehttp_client("http://localhost:8000/mcp") as (
2178+
read_stream,
2179+
write_stream,
2180+
_,
2181+
):
2182+
async with ClientSession(read_stream, write_stream) as session:
2183+
await session.initialize()
2184+
2185+
# Example 1: Request tracing and debugging
2186+
result = await session.call_tool(
2187+
"analyze_data",
2188+
arguments={"dataset": "sales_q4"},
2189+
extra_headers={
2190+
"X-Request-ID": "req-12345",
2191+
"X-Debug-Mode": "true",
2192+
"X-Trace-ID": "trace-abc-456"
2193+
}
2194+
)
2195+
2196+
print(f"Result with tracing: {result}")
2197+
2198+
# Example 2: A/B testing context
2199+
result = await session.call_tool(
2200+
"get_recommendations",
2201+
arguments={"user_id": "user123"},
2202+
extra_headers={
2203+
"X-Experiment-ID": "rec-algo-v2",
2204+
"X-Variant": "variant-b"
2205+
}
2206+
)
2207+
2208+
print(f"Result with A/B test context: {result}")
2209+
2210+
# Example 3: Per-user authentication context
2211+
users = [
2212+
{"id": "user1", "token": "token_abc123"},
2213+
{"id": "user2", "token": "token_def456"},
2214+
{"id": "user3", "token": "token_ghi789"},
2215+
]
2216+
2217+
for user in users:
2218+
print(f"Making request for {user['id']}")
2219+
2220+
# Call tool with user-specific authentication header
2221+
result = await session.call_tool(
2222+
"get_user_data",
2223+
arguments={"user_id": user["id"]},
2224+
extra_headers={"Authorization": f"Bearer {user['token']}"}
2225+
)
2226+
2227+
print(f"Result for {user['id']}: {result}")
2228+
2229+
# Headers can also be combined with other per-request parameters
2230+
result = await session.call_tool(
2231+
"slow_operation",
2232+
arguments={"data": "example"},
2233+
extra_headers={
2234+
"X-Request-ID": "req-12345",
2235+
"X-Priority": "high"
2236+
},
2237+
read_timeout_seconds=30.0 # Extended timeout for slow operation
2238+
)
2239+
2240+
print(f"Slow operation result: {result}")
2241+
2242+
2243+
if __name__ == "__main__":
2244+
asyncio.run(main())
2245+
```
2246+
2247+
_Full example: [examples/snippets/clients/per_request_headers_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/per_request_headers_example.py)_
2248+
<!-- /snippet-source -->
2249+
2250+
The `extra_headers` parameter is available for all `ClientSession` methods that make server requests:
2251+
- `call_tool()`
2252+
- `get_prompt()`
2253+
- `read_resource()`
2254+
- `list_tools()`
2255+
- `list_prompts()`
2256+
- `list_resources()`
2257+
- `list_resource_templates()`
2258+
- `subscribe()`
2259+
- `unsubscribe()`
2260+
- `set_logging_level()`
2261+
2262+
Per-request headers are merged with the transport's default headers, with per-request headers taking precedence for duplicate keys.
2263+
21562264
### Client Display Utilities
21572265

21582266
When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts:
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
Example demonstrating per-request headers functionality for MCP client.
3+
4+
This example shows how to use the new extra_headers parameter in call_tool()
5+
to send different HTTP headers with each tool call, enabling various use cases
6+
such as per-user authentication, request tracing, A/B testing, debugging flags,
7+
and multi-tenant applications.
8+
"""
9+
10+
import asyncio
11+
12+
from mcp import ClientSession
13+
from mcp.client.streamable_http import streamablehttp_client
14+
15+
16+
async def main():
17+
"""Demonstrate per-request headers functionality."""
18+
19+
# Connection-level headers (static for the entire session)
20+
connection_headers = {"Authorization": "Bearer org-level-token", "X-Org-ID": "org-123"}
21+
22+
# Connect to MCP server with connection-level headers
23+
async with streamablehttp_client("https://mcp.example.com/mcp", headers=connection_headers) as (
24+
read_stream,
25+
write_stream,
26+
_,
27+
):
28+
async with ClientSession(read_stream, write_stream) as session:
29+
await session.initialize()
30+
31+
# Example 1: Call tool without per-request headers
32+
# Uses only connection-level headers
33+
print("=== Example 1: Default headers ===")
34+
result = await session.call_tool("get_data", {})
35+
print(f"Result: {result}")
36+
37+
# Example 2: Request tracing and correlation
38+
print("\n=== Example 2: Request tracing ===")
39+
tracing_headers = {
40+
"X-Request-ID": "req-12345",
41+
"X-Trace-ID": "trace-abc-456",
42+
"X-Correlation-ID": "corr-789",
43+
}
44+
45+
result = await session.call_tool("process_data", {"type": "analytics"}, extra_headers=tracing_headers)
46+
print(f"Result with tracing: {result}")
47+
48+
# Example 3: A/B testing and feature flags
49+
print("\n=== Example 3: A/B testing ===")
50+
experiment_headers = {
51+
"X-Experiment-ID": "new-ui-test",
52+
"X-Variant": "variant-b",
53+
"X-Feature-Flags": "enable-beta-features,new-algorithm",
54+
}
55+
56+
result = await session.call_tool(
57+
"get_recommendations", {"user_id": "user123"}, extra_headers=experiment_headers
58+
)
59+
print(f"Result with A/B testing: {result}")
60+
61+
# Example 4: Debug and profiling
62+
print("\n=== Example 4: Debug mode ===")
63+
debug_headers = {"X-Debug-Mode": "true", "X-Profile": "performance", "X-Log-Level": "verbose"}
64+
65+
result = await session.call_tool("complex_calculation", {"data": [1, 2, 3]}, extra_headers=debug_headers)
66+
print(f"Result with debugging: {result}")
67+
68+
# Example 5: Authentication context (user-specific)
69+
print("\n=== Example 5: User authentication ===")
70+
user_headers = {"X-Auth-Token": "user-token-12345", "X-User-ID": "alice", "X-Session-ID": "sess-789"}
71+
72+
result = await session.call_tool(
73+
"get_user_data", {"fields": ["profile", "preferences"]}, extra_headers=user_headers
74+
)
75+
print(f"Result for user: {result}")
76+
77+
# Example 6: Override connection-level headers
78+
print("\n=== Example 6: Override connection-level authorization ===")
79+
override_headers = {
80+
"Authorization": "Bearer user-specific-token", # Overrides connection-level
81+
"X-Special-Permission": "admin",
82+
}
83+
84+
result = await session.call_tool("admin_operation", {"operation": "reset"}, extra_headers=override_headers)
85+
print(f"Result with overridden auth: {result}")
86+
87+
# Example 7: Combine with other call_tool parameters
88+
print("\n=== Example 7: Combined with meta and other parameters ===")
89+
combined_headers = {"X-Request-Source": "api", "X-Priority": "high", "X-Client-Version": "1.2.3"}
90+
91+
meta_data = {"correlation_id": "req-123", "client_version": "1.0.0"}
92+
93+
result = await session.call_tool(
94+
"complex_operation",
95+
{"param1": "value1", "param2": "value2"},
96+
meta=meta_data,
97+
extra_headers=combined_headers,
98+
)
99+
print(f"Result with combined parameters: {result}")
100+
101+
102+
# Multi-tenant example showing how different users can use the same connection
103+
async def multi_tenant_example():
104+
"""Example of multi-tenant usage with per-request headers."""
105+
106+
print("\n" + "=" * 60)
107+
print("MULTI-TENANT EXAMPLE")
108+
print("=" * 60)
109+
110+
# Organization-level connection
111+
org_headers = {"Authorization": "Bearer org-api-key-xyz789", "X-Org-ID": "org-acme-corp"}
112+
113+
async with streamablehttp_client("https://mcp.example.com/mcp", headers=org_headers) as (
114+
read_stream,
115+
write_stream,
116+
_,
117+
):
118+
async with ClientSession(read_stream, write_stream) as session:
119+
await session.initialize()
120+
121+
# Simulate handling requests from different tenants/users
122+
tenants = [
123+
{"tenant_id": "tenant-001", "user_id": "alice", "auth_token": "alice-jwt-token-abc123"},
124+
{"tenant_id": "tenant-002", "user_id": "bob", "auth_token": "bob-jwt-token-def456"},
125+
{"tenant_id": "tenant-001", "user_id": "charlie", "auth_token": "charlie-jwt-token-ghi789"},
126+
]
127+
128+
for i, tenant in enumerate(tenants, 1):
129+
print(f"\n--- Request {i}: {tenant['user_id']} from {tenant['tenant_id']} ---")
130+
131+
# Each request gets tenant-specific headers
132+
tenant_headers = {
133+
"X-Tenant-ID": tenant["tenant_id"],
134+
"X-User-ID": tenant["user_id"],
135+
"X-Auth-Token": tenant["auth_token"],
136+
"X-Request-ID": f"req-{i}-{tenant['user_id']}",
137+
}
138+
139+
try:
140+
result = await session.call_tool(
141+
"get_tenant_data", {"data_type": "dashboard"}, extra_headers=tenant_headers
142+
)
143+
print(f"Success for {tenant['user_id']}: {len(result.content)} items")
144+
145+
except Exception as e:
146+
print(f"Error for {tenant['user_id']}: {e}")
147+
148+
149+
if __name__ == "__main__":
150+
print("MCP Client Per-Request Headers Example")
151+
print("=" * 50)
152+
153+
# Note: This example assumes a running MCP server at the specified URL
154+
# In practice, you would replace with your actual MCP server endpoint
155+
156+
try:
157+
asyncio.run(main())
158+
asyncio.run(multi_tenant_example())
159+
except Exception as e:
160+
print(f"Example requires a running MCP server. Error: {e}")
161+
print("\nThis example demonstrates the API usage patterns.")
162+
print("Replace 'https://mcp.example.com/mcp' with your actual MCP server URL.")

src/mcp/client/session.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,33 @@ async def call_tool(
330330
progress_callback: ProgressFnT | None = None,
331331
*,
332332
meta: dict[str, Any] | None = None,
333+
extra_headers: dict[str, str] | None = None,
333334
) -> types.CallToolResult:
334-
"""Send a tools/call request with optional progress callback support."""
335+
"""Send a tools/call request with optional progress callback support.
336+
337+
Args:
338+
name: The name of the tool to call.
339+
arguments: The arguments to pass to the tool.
340+
read_timeout_seconds: Optional timeout for reading the response.
341+
progress_callback: Optional callback for progress notifications.
342+
meta: Optional meta parameters for the request.
343+
extra_headers: Additional HTTP headers to include in this specific request.
344+
These are merged with connection-level headers, with extra_headers
345+
taking precedence for duplicate keys. Useful for per-request
346+
authentication, tracing, debugging, A/B testing, and more.
347+
"""
335348

336349
_meta: types.RequestParams.Meta | None = None
337350
if meta is not None:
338351
_meta = types.RequestParams.Meta(**meta)
339352

353+
# Create metadata to pass extra headers to the transport layer
354+
metadata = None
355+
if extra_headers:
356+
from mcp.shared.message import ClientMessageMetadata
357+
358+
metadata = ClientMessageMetadata(extra_headers=extra_headers)
359+
340360
result = await self.send_request(
341361
types.ClientRequest(
342362
types.CallToolRequest(
@@ -345,6 +365,7 @@ async def call_tool(
345365
),
346366
types.CallToolResult,
347367
request_read_timeout_seconds=read_timeout_seconds,
368+
metadata=metadata,
348369
progress_callback=progress_callback,
349370
)
350371

src/mcp/client/streamable_http.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ async def handle_get_stream(
220220
async def _handle_resumption_request(self, ctx: RequestContext) -> None:
221221
"""Handle a resumption request using GET with SSE."""
222222
headers = self._prepare_request_headers(ctx.headers)
223+
224+
# Merge extra headers from metadata if present
225+
if ctx.metadata and ctx.metadata.extra_headers:
226+
headers.update(ctx.metadata.extra_headers)
227+
223228
if ctx.metadata and ctx.metadata.resumption_token:
224229
headers[LAST_EVENT_ID] = ctx.metadata.resumption_token
225230
else:
@@ -254,6 +259,11 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
254259
async def _handle_post_request(self, ctx: RequestContext) -> None:
255260
"""Handle a POST request with response processing."""
256261
headers = self._prepare_request_headers(ctx.headers)
262+
263+
# Merge extra headers from metadata if present
264+
if ctx.metadata and ctx.metadata.extra_headers:
265+
headers.update(ctx.metadata.extra_headers)
266+
257267
message = ctx.session_message.message
258268
is_initialization = self._is_initialization_request(message)
259269

src/mcp/shared/message.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class ClientMessageMetadata:
2121

2222
resumption_token: ResumptionToken | None = None
2323
on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None
24+
extra_headers: dict[str, str] | None = None
2425

2526

2627
@dataclass

0 commit comments

Comments
 (0)