|
10 | 10 | from agenticlayer.agent_to_a2a import to_a2a |
11 | 11 | from agenticlayer.config import InteractionType, McpTool, SubAgent |
12 | 12 | from asgi_lifespan import LifespanManager |
13 | | -from fastmcp import FastMCP |
| 13 | +from fastmcp import Context, FastMCP |
14 | 14 | from httpx_retries import Retry |
15 | 15 | from pydantic import AnyHttpUrl |
16 | 16 | from starlette.testclient import TestClient |
@@ -364,15 +364,22 @@ async def test_external_token_passed_to_mcp_tools( |
364 | 364 | final_message="Echo completed!", |
365 | 365 | ) |
366 | 366 |
|
367 | | - # Given: MCP server with 'echo' tool that verifies the token header |
| 367 | + # Given: MCP server with 'echo' tool that can access headers via Context |
368 | 368 | mcp = FastMCP("TokenVerifier") |
369 | 369 | received_headers: list[dict[str, str]] = [] |
| 370 | + received_tokens_in_tool: list[str | None] = [] |
370 | 371 |
|
371 | 372 | @mcp.tool() |
372 | | - def echo(message: str) -> str: |
373 | | - """Echo a message and capture headers.""" |
374 | | - # Capture headers from the current request context |
375 | | - # Note: In real MCP, headers would be available via context |
| 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) |
376 | 383 | return f"Echoed: {message}" |
377 | 384 |
|
378 | 385 | mcp_server_url = "http://test-mcp-token.local" |
@@ -435,3 +442,111 @@ async def handler_with_header_capture(request: httpx.Request) -> httpx.Response: |
435 | 442 | f"Expected token '{external_token}', got '{token_value}'" |
436 | 443 | ) |
437 | 444 |
|
| 445 | + @pytest.mark.asyncio |
| 446 | + @pytest.mark.skip(reason="Sub-agent token propagation not yet implemented - requires ADK support for custom headers in A2A requests") |
| 447 | + async def test_external_token_passed_to_sub_agents( |
| 448 | + self, |
| 449 | + app_factory: Any, |
| 450 | + agent_factory: Any, |
| 451 | + llm_controller: LLMMockController, |
| 452 | + respx_mock: respx.MockRouter, |
| 453 | + ) -> None: |
| 454 | + """Test that X-External-Token header is passed from A2A request to sub-agent calls. |
| 455 | +
|
| 456 | + Verifies end-to-end token passing through the agent to sub-agents. |
| 457 | + """ |
| 458 | + |
| 459 | + # Given: Mock LLM to call sub-agent |
| 460 | + sub_agent_message = "Hello from main agent" |
| 461 | + main_agent_final = "Sub-agent responded successfully!" |
| 462 | + |
| 463 | + llm_controller.respond_with_tool_call( |
| 464 | + pattern="", |
| 465 | + tool_name="sub_agent", |
| 466 | + tool_args={"request": sub_agent_message}, |
| 467 | + final_message=main_agent_final, |
| 468 | + ) |
| 469 | + |
| 470 | + # Given: Sub-agent running as ASGI app that tracks received headers |
| 471 | + sub_agent_received_headers: list[dict[str, str]] = [] |
| 472 | + sub_agent_url = "http://sub-agent.test" |
| 473 | + sub_agent = agent_factory("sub_agent") |
| 474 | + |
| 475 | + # Create sub-agent app |
| 476 | + sub_agent_app = to_a2a( |
| 477 | + agent=sub_agent, |
| 478 | + rpc_url=sub_agent_url, |
| 479 | + agent_factory=AgentFactory(retry=Retry(total=2)), |
| 480 | + ) |
| 481 | + |
| 482 | + async with LifespanManager(sub_agent_app) as sub_manager: |
| 483 | + # Create wrapper that captures headers before forwarding to sub-agent |
| 484 | + async def header_capturing_handler(request: httpx.Request) -> httpx.Response: |
| 485 | + # Capture headers from all requests to sub-agent |
| 486 | + sub_agent_received_headers.append(dict(request.headers)) |
| 487 | + |
| 488 | + # Forward to actual sub-agent ASGI app |
| 489 | + transport = httpx.ASGITransport(app=sub_manager.app) |
| 490 | + async with httpx.AsyncClient(transport=transport, base_url=sub_agent_url) as client: |
| 491 | + return await client.request( |
| 492 | + method=request.method, |
| 493 | + url=str(request.url), |
| 494 | + headers=request.headers, |
| 495 | + content=request.content, |
| 496 | + ) |
| 497 | + |
| 498 | + # Route sub-agent requests through our header-capturing handler |
| 499 | + respx_mock.route(host="sub-agent.test").mock(side_effect=header_capturing_handler) |
| 500 | + |
| 501 | + # When: Create main agent with sub-agent and send request with X-External-Token header |
| 502 | + main_agent = agent_factory("main_agent") |
| 503 | + sub_agents = [ |
| 504 | + SubAgent( |
| 505 | + name="sub_agent", |
| 506 | + url=AnyHttpUrl(f"{sub_agent_url}/.well-known/agent-card.json"), |
| 507 | + interaction_type=InteractionType.TOOL_CALL, |
| 508 | + ) |
| 509 | + ] |
| 510 | + external_token = "sub-agent-token-67890" # nosec B105 |
| 511 | + |
| 512 | + # Create httpx client for respx interception and main agent app |
| 513 | + async with httpx.AsyncClient() as test_client: |
| 514 | + main_app = to_a2a( |
| 515 | + agent=main_agent, |
| 516 | + rpc_url="http://localhost:80/", |
| 517 | + sub_agents=sub_agents, |
| 518 | + agent_factory=AgentFactory(retry=Retry(total=2), httpx_client=test_client), |
| 519 | + ) |
| 520 | + |
| 521 | + async with LifespanManager(main_app) as main_manager: |
| 522 | + client = TestClient(main_manager.app) |
| 523 | + response = client.post( |
| 524 | + "", |
| 525 | + json=create_send_message_request("Call the sub-agent"), |
| 526 | + headers={"X-External-Token": external_token}, |
| 527 | + ) |
| 528 | + |
| 529 | + # Then: Verify response is successful |
| 530 | + assert response.status_code == 200 |
| 531 | + result = verify_jsonrpc_response(response.json()) |
| 532 | + assert result["status"]["state"] == "completed", "Task should complete successfully" |
| 533 | + |
| 534 | + # Then: Verify X-External-Token header was passed to sub-agent |
| 535 | + assert len(sub_agent_received_headers) > 0, "Sub-agent should have received requests" |
| 536 | + |
| 537 | + # Find requests with the token header (could be in agent card fetch or actual call) |
| 538 | + token_headers = [h for h in sub_agent_received_headers if "x-external-token" in h or "X-External-Token" in h] |
| 539 | + assert len(token_headers) > 0, ( |
| 540 | + f"At least one sub-agent request should have X-External-Token header. " |
| 541 | + f"Received {len(sub_agent_received_headers)} sub-agent requests total." |
| 542 | + ) |
| 543 | + |
| 544 | + # Verify the token value |
| 545 | + for headers in token_headers: |
| 546 | + # Header might be lowercase in the dict |
| 547 | + token_value = headers.get("X-External-Token") or headers.get("x-external-token") |
| 548 | + assert token_value == external_token, ( |
| 549 | + f"Expected token '{external_token}', got '{token_value}'" |
| 550 | + ) |
| 551 | + |
| 552 | + |
0 commit comments