Skip to content

Commit 4b2951c

Browse files
Copilotg3force
andauthored
Pass external API tokens to MCP tools via X-External-Token header using ADK session (#21)
--------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: g3force <779094+g3force@users.noreply.github.com>
1 parent 5b51d48 commit 4b2951c

6 files changed

Lines changed: 319 additions & 5 deletions

File tree

adk/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ SDK for Google ADK that helps to get agents configured in the Agentic Layer quic
99
- Configures A2A protocol for inter-agent communication
1010
- Offers parsing methods for sub agents and tools
1111
- Set log level via env var `LOGLEVEL` (default: `INFO`)
12+
- Automatically passes external API tokens to MCP tools via the `X-External-Token` header
1213

1314
## Usage
1415

@@ -100,3 +101,47 @@ Body logging behavior:
100101

101102
**Note**: Starlette body logging is more limited than HTTPX because it must avoid consuming request/response streams.
102103
Bodies are only captured when already buffered in the ASGI scope.
104+
105+
## External API Token Passing
106+
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.
108+
109+
### How It Works
110+
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
114+
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.
116+
117+
### Usage Example
118+
119+
Simply include the `X-External-Token` header in your A2A requests:
120+
121+
```bash
122+
curl -X POST http://localhost:8000/ \
123+
-H "Content-Type: application/json" \
124+
-H "X-External-Token: your-api-token-here" \
125+
-d '{
126+
"jsonrpc": "2.0",
127+
"id": 1,
128+
"method": "message/send",
129+
"params": {
130+
"message": {
131+
"role": "user",
132+
"parts": [{"kind": "text", "text": "Your message"}],
133+
"messageId": "msg-123",
134+
"contextId": "ctx-123"
135+
}
136+
}
137+
}'
138+
```
139+
140+
The SDK will automatically pass `your-api-token-here` to all MCP tool calls and sub-agent requests made during that session.
141+
142+
### Security Considerations
143+
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
147+
- This is a simple authentication mechanism; for production use, consider implementing more sophisticated authentication and authorization schemes

adk/agenticlayer/agent.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,41 @@
99
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
1010
from google.adk.agents import BaseAgent, LlmAgent
1111
from google.adk.agents.llm_agent import ToolUnion
12+
from google.adk.agents.readonly_context import ReadonlyContext
1213
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
1314
from google.adk.tools.agent_tool import AgentTool
1415
from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams
1516
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
1617
from httpx_retries import Retry, RetryTransport
1718

1819
from agenticlayer.config import InteractionType, McpTool, SubAgent
20+
from agenticlayer.constants import EXTERNAL_TOKEN_SESSION_KEY
1921

2022
logger = logging.getLogger(__name__)
2123

2224

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.
27+
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.
31+
32+
Args:
33+
readonly_context: The ADK ReadonlyContext providing access to the session
34+
35+
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.
38+
"""
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 {}
45+
46+
2347
class AgentFactory:
2448
def __init__(
2549
self,
@@ -110,6 +134,8 @@ def load_tools(self, mcp_tools: list[McpTool]) -> list[ToolUnion]:
110134
url=str(tool.url),
111135
timeout=tool.timeout,
112136
),
137+
# Provide header provider to inject session-stored token into tool requests
138+
header_provider=_get_mcp_headers_from_session,
113139
)
114140
)
115141

adk/agenticlayer/agent_to_a2a.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,96 @@
77
import logging
88
from typing import AsyncIterator, Awaitable, Callable
99

10+
from a2a.server.agent_execution.context import RequestContext
1011
from a2a.server.apps import A2AStarletteApplication
1112
from a2a.server.request_handlers import DefaultRequestHandler
1213
from a2a.server.tasks import InMemoryTaskStore
1314
from a2a.types import AgentCapabilities, AgentCard
1415
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
16+
from google.adk.a2a.converters.request_converter import AgentRunRequest
1517
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
1618
from google.adk.agents import LlmAgent
1719
from google.adk.agents.base_agent import BaseAgent
1820
from google.adk.apps.app import App
1921
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
2022
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
23+
from google.adk.events.event import Event
24+
from google.adk.events.event_actions import EventActions
2125
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
2226
from google.adk.runners import Runner
2327
from google.adk.sessions.in_memory_session_service import InMemorySessionService
28+
from google.adk.sessions.session import Session
2429
from starlette.applications import Starlette
2530

2631
from .agent import AgentFactory
2732
from .callback_tracer_plugin import CallbackTracerPlugin
2833
from .config import McpTool, SubAgent
34+
from .constants import EXTERNAL_TOKEN_SESSION_KEY
2935

3036
logger = logging.getLogger(__name__)
3137

3238

39+
class TokenCapturingA2aAgentExecutor(A2aAgentExecutor):
40+
"""Custom A2A agent executor that captures and stores the X-External-Token header.
41+
42+
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.
46+
"""
47+
48+
async def _prepare_session(
49+
self,
50+
context: RequestContext,
51+
run_request: AgentRunRequest,
52+
runner: Runner,
53+
) -> Session:
54+
"""Prepare the session and store the external token if present.
55+
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.
60+
61+
Args:
62+
context: The A2A request context containing the call context with headers
63+
run_request: The agent run request
64+
runner: The ADK runner instance
65+
66+
Returns:
67+
The prepared session with the external token stored in its state
68+
"""
69+
# Call parent to get or create the session
70+
session: Session = await super()._prepare_session(context, run_request, runner)
71+
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:
75+
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")
79+
or headers.get("X-External-Token")
80+
or headers.get("X-EXTERNAL-TOKEN")
81+
)
82+
83+
if external_token:
84+
# Store the token in the session state using ADK's recommended method:
85+
# Create an Event with a state_delta and append it to the session.
86+
# This follows ADK's pattern for updating session state as documented at:
87+
# https://google.github.io/adk-docs/sessions/state/#how-state-is-updated-recommended-methods
88+
event = Event(
89+
author="system",
90+
actions=EventActions(
91+
state_delta={EXTERNAL_TOKEN_SESSION_KEY: external_token}
92+
)
93+
)
94+
await runner.session_service.append_event(session, event)
95+
logger.debug("Stored external token in session %s via state_delta", session.id)
96+
97+
return session
98+
99+
33100
class HealthCheckFilter(logging.Filter):
34101
def filter(self, record: logging.LogRecord) -> bool:
35102
# Check if the log message contains the well known path of the card, which is used for health checks
@@ -55,15 +122,16 @@ async def create_runner() -> Runner:
55122
plugins=[CallbackTracerPlugin()],
56123
),
57124
artifact_service=InMemoryArtifactService(),
58-
session_service=InMemorySessionService(), # type: ignore
59-
memory_service=InMemoryMemoryService(), # type: ignore
60-
credential_service=InMemoryCredentialService(), # type: ignore
125+
session_service=InMemorySessionService(), # type: ignore[no-untyped-call]
126+
memory_service=InMemoryMemoryService(), # type: ignore[no-untyped-call]
127+
credential_service=InMemoryCredentialService(), # type: ignore[no-untyped-call]
61128
)
62129

63130
# Create A2A components
64131
task_store = InMemoryTaskStore()
65132

66-
agent_executor = A2aAgentExecutor(
133+
# Use custom executor that captures X-External-Token and stores in session
134+
agent_executor = TokenCapturingA2aAgentExecutor(
67135
runner=create_runner,
68136
)
69137

adk/agenticlayer/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants shared across the agenticlayer package."""
2+
3+
# Key used to store the external token in the ADK session state
4+
EXTERNAL_TOKEN_SESSION_KEY = "__external_token__" # nosec B105

adk/tests/test_agent_integration.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from agenticlayer.agent_to_a2a import to_a2a
1111
from agenticlayer.config import InteractionType, McpTool, SubAgent
1212
from asgi_lifespan import LifespanManager
13-
from fastmcp import FastMCP
13+
from fastmcp import Context, FastMCP
1414
from httpx_retries import Retry
1515
from pydantic import AnyHttpUrl
1616
from starlette.testclient import TestClient
@@ -342,3 +342,104 @@ def add(a: int, b: int) -> int:
342342

343343
assert history[4]["role"] == "agent"
344344
assert history[4]["parts"] == [{"kind": "text", "text": "The calculation result is correct!"}]
345+
346+
@pytest.mark.asyncio
347+
async def test_external_token_passed_to_mcp_tools(
348+
self,
349+
app_factory: Any,
350+
agent_factory: Any,
351+
llm_controller: LLMMockController,
352+
respx_mock: respx.MockRouter,
353+
) -> None:
354+
"""Test that X-External-Token header is passed from A2A request to MCP tool calls.
355+
356+
Verifies end-to-end token passing through the agent to MCP servers.
357+
"""
358+
359+
# Given: Mock LLM to call 'echo' tool
360+
llm_controller.respond_with_tool_call(
361+
pattern="", # Match any message
362+
tool_name="echo",
363+
tool_args={"message": "test"},
364+
final_message="Echo completed!",
365+
)
366+
367+
# Given: MCP server with 'echo' tool that can access headers via Context
368+
mcp = FastMCP("TokenVerifier")
369+
received_headers: list[dict[str, str]] = []
370+
received_tokens_in_tool: list[str | None] = []
371+
372+
@mcp.tool()
373+
def echo(message: str, ctx: Context) -> str:
374+
"""Echo a message and verify token is accessible in tool context."""
375+
# Access headers from the MCP request context
376+
# The Context object provides access to the request_context which includes HTTP headers
377+
if ctx.request_context and hasattr(ctx.request_context, "request"):
378+
# Try to get the token from request headers if available
379+
request = ctx.request_context.request
380+
if request and hasattr(request, "headers"):
381+
token = request.headers.get("x-external-token") or request.headers.get("X-External-Token")
382+
received_tokens_in_tool.append(token)
383+
return f"Echoed: {message}"
384+
385+
mcp_server_url = "http://test-mcp-token.local"
386+
mcp_app = mcp.http_app(path="/mcp")
387+
388+
async with LifespanManager(mcp_app) as mcp_manager:
389+
# Create a custom handler that captures headers
390+
async def handler_with_header_capture(request: httpx.Request) -> httpx.Response:
391+
# Capture the headers from the request
392+
received_headers.append(dict(request.headers))
393+
394+
# Forward to the MCP app
395+
transport = httpx.ASGITransport(app=mcp_manager.app)
396+
async with httpx.AsyncClient(transport=transport, base_url=mcp_server_url) as client:
397+
return await client.request(
398+
method=request.method,
399+
url=str(request.url),
400+
headers=request.headers,
401+
content=request.content,
402+
)
403+
404+
# Route MCP requests through our custom handler
405+
respx_mock.route(host="test-mcp-token.local").mock(side_effect=handler_with_header_capture)
406+
407+
# When: Create agent with MCP tool and send request with X-External-Token header
408+
test_agent = agent_factory("test_agent")
409+
tools = [McpTool(name="verifier", url=AnyHttpUrl(f"{mcp_server_url}/mcp"), timeout=30)]
410+
external_token = "secret-api-token-12345" # nosec B105
411+
412+
async with app_factory(test_agent, tools=tools) as app:
413+
client = TestClient(app)
414+
user_message = "Echo test message"
415+
response = client.post(
416+
"",
417+
json=create_send_message_request(user_message),
418+
headers={"X-External-Token": external_token},
419+
)
420+
421+
# Then: Verify response is successful
422+
assert response.status_code == 200
423+
result = verify_jsonrpc_response(response.json())
424+
assert result["status"]["state"] == "completed", "Task should complete successfully"
425+
426+
# Then: Verify X-External-Token header was passed to MCP server
427+
assert len(received_headers) > 0, "MCP server should have received requests"
428+
429+
# Find the tool call request (not the initialization requests)
430+
# Header keys might be lowercase
431+
tool_call_headers = [h for h in received_headers if "x-external-token" in h or "X-External-Token" in h]
432+
assert len(tool_call_headers) > 0, (
433+
f"At least one request should have X-External-Token header. "
434+
f"Received {len(received_headers)} requests total."
435+
)
436+
437+
# Verify the token value
438+
for headers in tool_call_headers:
439+
# Header might be lowercase in the dict
440+
token_value = headers.get("X-External-Token") or headers.get("x-external-token")
441+
assert token_value == external_token, (
442+
f"Expected token '{external_token}', got '{token_value}'"
443+
)
444+
445+

0 commit comments

Comments
 (0)