Skip to content

Commit 202e897

Browse files
authored
Merge branch 'main' into copilot/update-agent-instructions
2 parents c4f7e0f + 742a17b commit 202e897

File tree

14 files changed

+692
-428
lines changed

14 files changed

+692
-428
lines changed

.github/workflows/check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Install uv
1717
uses: 'astral-sh/setup-uv@v7'
1818
with:
19-
version: "0.10.4"
19+
version: "0.10.7"
2020
enable-cache: true
2121

2222
- name: Run checks

.github/workflows/copilot-setup-steps.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- name: Install uv
2222
uses: astral-sh/setup-uv@v7
2323
with:
24-
version: "0.10.4"
24+
version: "0.10.7"
2525
enable-cache: true
2626

2727
- name: Setup Python

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Install uv
2121
uses: 'astral-sh/setup-uv@v7'
2222
with:
23-
version: "0.10.4"
23+
version: "0.10.7"
2424
enable-cache: true
2525

2626
- name: Setup Python

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: 41 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,50 @@
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+
Headers are stored in the session state as flat primitive string keys of the form
34+
``f"{HTTP_HEADERS_SESSION_KEY}.{header_name_lower}"``. The provider looks up each
35+
configured header by its lower-cased key and returns it with the casing specified
36+
in the configuration.
37+
38+
Example:
39+
>>> provider = _create_header_provider(['Authorization', 'X-API-Key'])
40+
>>> # Session state has: {'http_headers.authorization': 'Bearer token', '__http_headers__.x-api-key': 'key123'}
41+
>>> # Output will be: {'Authorization': 'Bearer token', 'X-API-Key': 'key123'}
3142
3243
Args:
33-
readonly_context: The ADK ReadonlyContext providing access to the session
44+
propagate_headers: List of header names to propagate to this MCP server
3445
3546
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.
47+
A header provider function that can be passed to McpToolset
3848
"""
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 {}
49+
50+
def header_provider(readonly_context: ReadonlyContext) -> dict[str, str]:
51+
"""Header provider that reads per-header flat primitive keys from session state."""
52+
if not readonly_context or not readonly_context.state:
53+
return {}
54+
55+
result_headers = {}
56+
for header_name in propagate_headers:
57+
key = f"{HTTP_HEADERS_SESSION_KEY}.{header_name.lower()}"
58+
value = readonly_context.state.get(key)
59+
if value is not None:
60+
result_headers[header_name] = value
61+
62+
return result_headers
63+
64+
return header_provider
4565

4666

4767
class AgentFactory:
@@ -128,14 +148,18 @@ def load_tools(self, mcp_tools: list[McpTool]) -> list[ToolUnion]:
128148
tools: list[ToolUnion] = []
129149
for tool in mcp_tools:
130150
logger.info(f"Loading tool {tool.model_dump_json()}")
151+
152+
# Create header provider with configured headers
153+
header_provider = _create_header_provider(tool.propagate_headers)
154+
131155
tools.append(
132156
McpToolset(
133157
connection_params=StreamableHTTPConnectionParams(
134158
url=str(tool.url),
135159
timeout=tool.timeout,
136160
),
137-
# Provide header provider to inject session-stored token into tool requests
138-
header_provider=_get_mcp_headers_from_session,
161+
# Provide header provider to inject session-stored headers into tool requests
162+
header_provider=header_provider,
139163
)
140164
)
141165

adk/agenticlayer/agent_to_a2a.py

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import contextlib
77
import logging
8-
from typing import AsyncIterator, Awaitable, Callable
8+
from typing import Any, AsyncIterator, Awaitable, Callable
99

1010
from a2a.server.agent_execution.context import RequestContext
1111
from a2a.server.apps import A2AStarletteApplication
@@ -31,63 +31,60 @@
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 a filtered set of HTTP headers in the ADK session state. Only headers
44+
that are configured to be propagated (across all MCP tools) are stored, avoiding
45+
accidental capture of sensitive headers. Each header is stored as a separate flat
46+
string entry (primitive type) to remain compatible with OpenTelemetry.
4647
"""
4748

49+
def __init__(self, propagate_headers: set[str], **kwargs: Any) -> None:
50+
super().__init__(**kwargs)
51+
self._propagate_headers_lower = {h.lower() for h in propagate_headers}
52+
4853
async def _prepare_session(
4954
self,
5055
context: RequestContext,
5156
run_request: AgentRunRequest,
5257
runner: Runner,
5358
) -> Session:
54-
"""Prepare the session and store the external token if present.
59+
"""Prepare the session and store filtered HTTP headers as flat primitive keys.
5560
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
58-
recommended approach: creating an Event with state_delta and appending it to
59-
the session.
61+
Only headers listed in the configured propagate_headers set are stored.
62+
Each header is stored as a separate string value under the key
63+
``f"{HTTP_HEADERS_SESSION_KEY}.{header_name_lower}"`` to keep session
64+
state values as primitives (required by OpenTelemetry).
6065
6166
Args:
6267
context: The A2A request context containing the call context with headers
6368
run_request: The agent run request
6469
runner: The ADK runner instance
6570
6671
Returns:
67-
The prepared session with the external token stored in its state
72+
The prepared session with filtered HTTP headers stored in its state
6873
"""
69-
# Call parent to get or create the session
7074
session: Session = await super()._prepare_session(context, run_request, runner)
7175

72-
# Extract the X-External-Token header from the request context
73-
# The call_context.state contains headers from the original HTTP request
74-
if context.call_context and "headers" in context.call_context.state:
76+
if self._propagate_headers_lower and context.call_context and "headers" in context.call_context.state:
7577
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
86-
event = Event(
87-
author="system", actions=EventActions(state_delta={EXTERNAL_TOKEN_SESSION_KEY: external_token})
88-
)
89-
await runner.session_service.append_event(session, event)
90-
logger.debug("Stored external token in session %s via state_delta", session.id)
78+
if headers:
79+
state_delta: dict[str, object] = {}
80+
for key, value in headers.items():
81+
if key.lower() in self._propagate_headers_lower:
82+
state_delta[f"{HTTP_HEADERS_SESSION_KEY}.{key.lower()}"] = value
83+
84+
if state_delta:
85+
event = Event(author="system", actions=EventActions(state_delta=state_delta))
86+
await runner.session_service.append_event(session, event)
87+
logger.debug("Stored %d HTTP headers in session %s via state_delta", len(state_delta), session.id)
9188

9289
return session
9390

@@ -98,12 +95,17 @@ def filter(self, record: logging.LogRecord) -> bool:
9895
return record.getMessage().find(AGENT_CARD_WELL_KNOWN_PATH) == -1
9996

10097

101-
async def create_a2a_app(agent: BaseAgent, rpc_url: str) -> A2AStarletteApplication:
98+
async def create_a2a_app(
99+
agent: BaseAgent, rpc_url: str, propagate_headers: set[str] | None = None
100+
) -> A2AStarletteApplication:
102101
"""Create an A2A Starlette application from an ADK agent.
103102
104103
Args:
105104
agent: The ADK agent to convert
106105
rpc_url: The URL where the agent will be available for A2A communication
106+
propagate_headers: Union of all header names that any MCP tool is configured to
107+
propagate. Only these headers will be captured from incoming requests and
108+
stored in the session state.
107109
Returns:
108110
An A2AStarletteApplication instance
109111
"""
@@ -125,9 +127,10 @@ async def create_runner() -> Runner:
125127
# Create A2A components
126128
task_store = InMemoryTaskStore()
127129

128-
# Use custom executor that captures X-External-Token and stores in session
129-
agent_executor = TokenCapturingA2aAgentExecutor(
130+
# Use custom executor that captures HTTP headers and stores in session
131+
agent_executor = HeaderCapturingA2aAgentExecutor(
130132
runner=create_runner,
133+
propagate_headers=propagate_headers or set(),
131134
)
132135

133136
request_handler = DefaultRequestHandler(agent_executor=agent_executor, task_store=task_store)
@@ -181,14 +184,15 @@ def to_a2a(
181184
"""
182185

183186
agent_factory = agent_factory or AgentFactory()
187+
all_propagate_headers = {h for tool in (tools or []) for h in tool.propagate_headers}
184188

185189
async def a2a_app_creator() -> A2AStarletteApplication:
186190
configured_agent = await agent_factory.load_agent(
187191
agent=agent,
188192
sub_agents=sub_agents or [],
189193
tools=tools or [],
190194
)
191-
return await create_a2a_app(configured_agent, rpc_url)
195+
return await create_a2a_app(configured_agent, rpc_url, propagate_headers=all_propagate_headers)
192196

193197
return to_starlette(a2a_app_creator)
194198

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]:

0 commit comments

Comments
 (0)