Skip to content

Commit 48a45fc

Browse files
authored
Add per-server HTTP header propagation configuration for MCP tools (#27)
1 parent 5acef24 commit 48a45fc

File tree

7 files changed

+459
-145
lines changed

7 files changed

+459
-145
lines changed

adk/README.md

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,36 @@ The JSON configuration for `AGENT_TOOLS` should follow this structure:
7272
{
7373
"tool_name": {
7474
"url": "https://mcp-tool-endpoint:8000/mcp",
75-
"timeout": 30 // Optional: connect timeout in seconds
75+
"timeout": 30, // Optional: connect timeout in seconds (default: 30)
76+
"propagate_headers": ["X-API-Key", "Authorization"] // Optional: list of headers to propagate (default: [])
77+
}
78+
}
79+
```
80+
81+
### Header Propagation
82+
83+
You can configure which HTTP headers are passed from the incoming A2A request to each MCP server using the `propagate_headers` field. This provides fine-grained control over which headers each MCP server receives.
84+
85+
**Key features:**
86+
- **Per-server configuration**: Each MCP server can receive different headers
87+
- **Security**: Headers are only sent to servers explicitly configured to receive them
88+
- **Case-insensitive matching**: Header names are matched case-insensitively
89+
- **Default behavior**: When `propagate_headers` is not specified or is empty, no headers are passed
90+
91+
**Example configuration:**
92+
```json5
93+
{
94+
"github_api": {
95+
"url": "https://github-mcp.example.com/mcp",
96+
"propagate_headers": ["Authorization", "X-GitHub-Token"]
97+
},
98+
"stripe_api": {
99+
"url": "https://stripe-mcp.example.com/mcp",
100+
"propagate_headers": ["X-Stripe-Key"]
101+
},
102+
"public_tool": {
103+
"url": "https://public-mcp.example.com/mcp"
104+
// No propagate_headers - no headers will be passed
76105
}
77106
}
78107
```
@@ -102,46 +131,68 @@ Body logging behavior:
102131
**Note**: Starlette body logging is more limited than HTTPX because it must avoid consuming request/response streams.
103132
Bodies are only captured when already buffered in the ASGI scope.
104133

105-
## External API Token Passing
134+
## HTTP Header Propagation to MCP Tools
106135

107-
The SDK supports passing external API tokens from A2A requests to MCP tools. This enables MCP servers to authenticate with external APIs on behalf of users.
136+
The SDK supports passing HTTP headers from A2A requests to MCP tools. This enables MCP servers to authenticate with external APIs on behalf of users, and provides flexible header-based configuration.
108137

109138
### How It Works
110139

111-
1. **Token Capture**: When an A2A request includes the `X-External-Token` header, the SDK automatically captures and stores it in the ADK session state
112-
2. **Secure Storage**: The token is stored in ADK's session state (not in memory state accessible to the LLM), ensuring the agent cannot directly access or leak it
113-
3. **Automatic Injection**: When MCP tools are invoked, the SDK uses ADK's `header_provider` hook to retrieve the token from the session and inject it as the `X-External-Token` header in tool requests
140+
1. **Header Capture**: When an A2A request is received, all HTTP headers are captured and stored in the ADK session state
141+
2. **Secure Storage**: Headers are stored in ADK's session state (not in memory state accessible to the LLM), ensuring the agent cannot directly access or leak sensitive information
142+
3. **Per-Server Filtering**: Each MCP server receives only the headers configured in its `propagate_headers` list
143+
4. **Automatic Injection**: When MCP tools are invoked, the SDK uses ADK's `header_provider` hook to retrieve the configured headers from the session and inject them into tool requests
114144

115-
**Current Limitations**: The token is only passed to MCP servers. Propagation to sub-agents is not currently supported due to ADK limitations in passing custom HTTP headers in A2A requests.
145+
### Configuration
146+
147+
Configure which headers to propagate using the `propagate_headers` field in your MCP tool configuration:
148+
149+
```json5
150+
{
151+
"weather_api": {
152+
"url": "https://weather-mcp.example.com/mcp",
153+
"propagate_headers": ["X-API-Key", "X-User-Location"]
154+
},
155+
"database_tool": {
156+
"url": "https://db-mcp.example.com/mcp",
157+
"propagate_headers": ["Authorization"]
158+
}
159+
}
160+
```
116161

117162
### Usage Example
118163

119-
Simply include the `X-External-Token` header in your A2A requests:
164+
Include the headers you want to propagate in your A2A requests:
120165

121166
```bash
122167
curl -X POST http://localhost:8000/ \
123168
-H "Content-Type: application/json" \
124-
-H "X-External-Token: your-api-token-here" \
169+
-H "X-API-Key: your-api-key" \
170+
-H "Authorization: Bearer your-token" \
171+
-H "X-User-Location: US-West" \
125172
-d '{
126173
"jsonrpc": "2.0",
127174
"id": 1,
128175
"method": "message/send",
129176
"params": {
130177
"message": {
131178
"role": "user",
132-
"parts": [{"kind": "text", "text": "Your message"}],
179+
"parts": [{"kind": "text", "text": "What is the weather?"}],
133180
"messageId": "msg-123",
134181
"contextId": "ctx-123"
135182
}
136183
}
137184
}'
138185
```
139186

140-
The SDK will automatically pass `your-api-token-here` to all MCP tool calls and sub-agent requests made during that session.
187+
Based on the configuration above:
188+
- `weather_api` MCP server will receive `X-API-Key` and `X-User-Location` headers
189+
- `database_tool` MCP server will receive only the `Authorization` header
190+
191+
**Limitations**: Header propagation is only supported for MCP servers. Propagation to sub-agents is not currently supported due to ADK limitations in passing custom HTTP headers in A2A requests.
141192

142193
### Security Considerations
143194

144-
- Tokens are stored in ADK session state (separate from memory state that the LLM can access)
145-
- Tokens are not directly accessible to agent code through normal session state queries
146-
- Tokens persist for the session duration and are managed by ADK's session lifecycle
195+
- Headers are stored in ADK session state (separate from memory state that the LLM can access)
196+
- Headers are not directly accessible to agent code through normal session state queries
197+
- Headers persist for the session duration and are managed by ADK's session lifecycle
147198
- This is a simple authentication mechanism; for production use, consider implementing more sophisticated authentication and authorization schemes

adk/agenticlayer/agent.py

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import logging
6+
from typing import Callable
67

78
import httpx
89
from a2a.client import A2ACardResolver
@@ -17,31 +18,64 @@
1718
from httpx_retries import Retry, RetryTransport
1819

1920
from agenticlayer.config import InteractionType, McpTool, SubAgent
20-
from agenticlayer.constants import EXTERNAL_TOKEN_SESSION_KEY
21+
from agenticlayer.constants import HTTP_HEADERS_SESSION_KEY
2122

2223
logger = logging.getLogger(__name__)
2324

2425

25-
def _get_mcp_headers_from_session(readonly_context: ReadonlyContext) -> dict[str, str]:
26-
"""Header provider function for MCP tools that retrieves token from ADK session.
26+
def _create_header_provider(propagate_headers: list[str]) -> Callable[[ReadonlyContext], dict[str, str]]:
27+
"""Create a header provider function for a specific MCP server.
2728
28-
This function is called by the ADK when MCP tools are invoked. It reads the
29-
external token from the session state where it was stored during request
30-
processing by TokenCapturingA2aAgentExecutor.
29+
This factory function creates a header provider that filters headers based on
30+
the MCP server's configuration. Only headers listed in propagate_headers will
31+
be included in requests to that server.
32+
33+
The matching is case-insensitive: if the configuration specifies 'Authorization'
34+
and the incoming request has 'authorization', they will match. The output header
35+
will use the case specified in the configuration.
36+
37+
Example:
38+
>>> provider = _create_header_provider(['Authorization', 'X-API-Key'])
39+
>>> # If session has: {'authorization': 'Bearer token', 'x-api-key': 'key123'}
40+
>>> # Output will be: {'Authorization': 'Bearer token', 'X-API-Key': 'key123'}
41+
42+
Note: If multiple headers with different casing match a single configured header
43+
(e.g., both 'authorization' and 'Authorization' in stored headers), only one
44+
will be included. The last match found will be used.
3145
3246
Args:
33-
readonly_context: The ADK ReadonlyContext providing access to the session
47+
propagate_headers: List of header names to propagate to this MCP server
3448
3549
Returns:
36-
A dictionary of headers to include in MCP tool requests.
37-
If a token is stored in the session, includes it in the headers.
50+
A header provider function that can be passed to McpToolset
3851
"""
39-
# Access the session state directly from the readonly context
40-
if readonly_context and readonly_context.state:
41-
external_token = readonly_context.state.get(EXTERNAL_TOKEN_SESSION_KEY)
42-
if external_token:
43-
return {"X-External-Token": external_token}
44-
return {}
52+
53+
def header_provider(readonly_context: ReadonlyContext) -> dict[str, str]:
54+
"""Header provider that filters headers based on server configuration."""
55+
if not readonly_context or not readonly_context.state:
56+
return {}
57+
58+
# Get all stored headers from session
59+
all_headers = readonly_context.state.get(HTTP_HEADERS_SESSION_KEY, {})
60+
if not all_headers:
61+
return {}
62+
63+
# Create a lowercase lookup dictionary for O(n+m) complexity instead of O(n*m)
64+
all_headers_lower = {k.lower(): (k, v) for k, v in all_headers.items()}
65+
66+
# Filter to only include configured headers (case-insensitive matching)
67+
result_headers = {}
68+
for header_name in propagate_headers:
69+
# Try to find the header in the stored headers (case-insensitive)
70+
header_lower = header_name.lower()
71+
if header_lower in all_headers_lower:
72+
original_key, value = all_headers_lower[header_lower]
73+
# Use the original case from the configuration
74+
result_headers[header_name] = value
75+
76+
return result_headers
77+
78+
return header_provider
4579

4680

4781
class AgentFactory:
@@ -128,14 +162,18 @@ def load_tools(self, mcp_tools: list[McpTool]) -> list[ToolUnion]:
128162
tools: list[ToolUnion] = []
129163
for tool in mcp_tools:
130164
logger.info(f"Loading tool {tool.model_dump_json()}")
165+
166+
# Create header provider with configured headers
167+
header_provider = _create_header_provider(tool.propagate_headers)
168+
131169
tools.append(
132170
McpToolset(
133171
connection_params=StreamableHTTPConnectionParams(
134172
url=str(tool.url),
135173
timeout=tool.timeout,
136174
),
137-
# Provide header provider to inject session-stored token into tool requests
138-
header_provider=_get_mcp_headers_from_session,
175+
# Provide header provider to inject session-stored headers into tool requests
176+
header_provider=header_provider,
139177
)
140178
)
141179

adk/agenticlayer/agent_to_a2a.py

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@
3131
from .agent import AgentFactory
3232
from .callback_tracer_plugin import CallbackTracerPlugin
3333
from .config import McpTool, SubAgent
34-
from .constants import EXTERNAL_TOKEN_SESSION_KEY
34+
from .constants import HTTP_HEADERS_SESSION_KEY
3535

3636
logger = logging.getLogger(__name__)
3737

3838

39-
class TokenCapturingA2aAgentExecutor(A2aAgentExecutor):
40-
"""Custom A2A agent executor that captures and stores the X-External-Token header.
39+
class HeaderCapturingA2aAgentExecutor(A2aAgentExecutor):
40+
"""Custom A2A agent executor that captures and stores HTTP headers.
4141
4242
This executor extends the standard A2aAgentExecutor to intercept the request
43-
and store the X-External-Token header in the ADK session state. This allows
44-
MCP tools to access the token via the header_provider hook, using ADK's
45-
built-in session management rather than external context variables.
43+
and store all HTTP headers in the ADK session state. This allows MCP tools
44+
to access headers via the header_provider hook, using ADK's built-in session
45+
management rather than external context variables.
4646
"""
4747

4848
async def _prepare_session(
@@ -51,10 +51,10 @@ async def _prepare_session(
5151
run_request: AgentRunRequest,
5252
runner: Runner,
5353
) -> Session:
54-
"""Prepare the session and store the external token if present.
54+
"""Prepare the session and store HTTP headers if present.
5555
56-
This method extends the parent implementation to capture the X-External-Token
57-
header from the request context and store it in the session state using ADK's
56+
This method extends the parent implementation to capture HTTP headers
57+
from the request context and store them in the session state using ADK's
5858
recommended approach: creating an Event with state_delta and appending it to
5959
the session.
6060
@@ -64,30 +64,24 @@ async def _prepare_session(
6464
runner: The ADK runner instance
6565
6666
Returns:
67-
The prepared session with the external token stored in its state
67+
The prepared session with HTTP headers stored in its state
6868
"""
6969
# Call parent to get or create the session
7070
session: Session = await super()._prepare_session(context, run_request, runner)
7171

72-
# Extract the X-External-Token header from the request context
72+
# Extract HTTP headers from the request context
7373
# The call_context.state contains headers from the original HTTP request
7474
if context.call_context and "headers" in context.call_context.state:
7575
headers = context.call_context.state["headers"]
76-
# Headers might be in different cases, check all variations
77-
external_token = (
78-
headers.get("x-external-token") or headers.get("X-External-Token") or headers.get("X-EXTERNAL-TOKEN")
79-
)
80-
81-
if external_token:
82-
# Store the token in the session state using ADK's recommended method:
83-
# Create an Event with a state_delta and append it to the session.
84-
# This follows ADK's pattern for updating session state as documented at:
85-
# https://google.github.io/adk-docs/sessions/state/#how-state-is-updated-recommended-methods
76+
77+
# Store all headers in session state for per-MCP-server filtering
78+
# This allows each MCP server to receive only the headers it's configured to receive
79+
if headers:
8680
event = Event(
87-
author="system", actions=EventActions(state_delta={EXTERNAL_TOKEN_SESSION_KEY: external_token})
81+
author="system", actions=EventActions(state_delta={HTTP_HEADERS_SESSION_KEY: dict(headers)})
8882
)
8983
await runner.session_service.append_event(session, event)
90-
logger.debug("Stored external token in session %s via state_delta", session.id)
84+
logger.debug("Stored HTTP headers in session %s via state_delta", session.id)
9185

9286
return session
9387

@@ -125,8 +119,8 @@ async def create_runner() -> Runner:
125119
# Create A2A components
126120
task_store = InMemoryTaskStore()
127121

128-
# Use custom executor that captures X-External-Token and stores in session
129-
agent_executor = TokenCapturingA2aAgentExecutor(
122+
# Use custom executor that captures HTTP headers and stores in session
123+
agent_executor = HeaderCapturingA2aAgentExecutor(
130124
runner=create_runner,
131125
)
132126

adk/agenticlayer/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class McpTool(BaseModel):
2424
name: str
2525
url: AnyHttpUrl
2626
timeout: int = 30
27+
propagate_headers: list[str] = []
2728

2829

2930
def parse_sub_agents(sub_agents_config: str) -> list[SubAgent]:

adk/agenticlayer/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""Constants shared across the agenticlayer package."""
22

3-
# Key used to store the external token in the ADK session state
4-
EXTERNAL_TOKEN_SESSION_KEY = "__external_token__" # nosec B105
3+
# Key used to store all HTTP headers in the ADK session state
4+
HTTP_HEADERS_SESSION_KEY = "__http_headers__" # nosec B105

0 commit comments

Comments
 (0)