Skip to content

[FEATURE] _SubagentInterceptor should forward Authorization header from parent session to sub-agent A2A calls #1745

@JiwooL0920

Description

@JiwooL0920

📋 Prerequisites

📝 Feature Summary

When a supervisor agent dispatches to a sub-agent via type: Agent, the _SubagentInterceptor should forward the Authorization header from the parent agent's session.state["headers"], so that the sub-agent's downstream MCP calls can propagate the caller's JWT end-to-end.

❓ Problem Statement / Motivation

When using allowedHeaders: [Authorization] on Agent CRs, the user's JWT propagates correctly from the incoming A2A request to outbound MCP calls — but only for direct agent invocations. In multi-agent setups where a coordinator (supervisor) dispatches to sub-agents via type: Agent, the JWT is silently dropped at the A2A hop.

_SubagentInterceptor (_remote_a2a_tool.py:63-77) currently only forwards two headers:

  • x-kagent-source: agent
  • x-user-id (from context.state)

The Authorization header stored in context.state["headers"] (populated by _agent_executor.py and consumed by create_header_provider in types.py) is not forwarded to the sub-agent.

This means:

Path JWT at MCP backend?
Browser → agent → MCP ✅ Yes (allowedHeaders works)
Browser → coordinator → sub-agent → MCP ❌ No (JWT lost at coordinator → sub-agent hop)

💡 Proposed Solution

The fix requires two changes — the interceptor alone isn't enough because the headers are never passed into the ClientCallContext it receives.

Change 1: Pass session headers into ClientCallContext.state

In _handle_first_call() (line 242) and _handle_resume() (line 384), the ClientCallContext is constructed with only x-user-id:

# Current — headers from session.state are not included
call_context = ClientCallContext(state={_USER_ID_CONTEXT_KEY: tool_context.session.user_id})

The session headers (populated by _agent_executor.py from the incoming request) need to be forwarded:

# Proposed — include session headers so the interceptor can forward them
ctx_state = {_USER_ID_CONTEXT_KEY: tool_context.session.user_id}
session_headers = tool_context.session.state.get("headers", {})
if session_headers:
    ctx_state["headers"] = session_headers
call_context = ClientCallContext(state=ctx_state)

This must be applied in both _handle_first_call() (line 242) and _handle_resume() (line 384).

Change 2: Forward Authorization in _SubagentInterceptor

With headers now available in context.state, the interceptor can forward them:

class _SubagentInterceptor(ClientCallInterceptor):
    async def intercept(self, method_name, request_payload, http_kwargs, agent_card, context):
        headers = dict(http_kwargs.get("headers", {}))
        headers[_SOURCE_HEADER] = _SOURCE_SUBAGENT
        if context:
            if _USER_ID_CONTEXT_KEY in context.state:
                headers["x-user-id"] = context.state[_USER_ID_CONTEXT_KEY]
            # Forward Authorization from the parent agent's incoming request
            # so sub-agents can propagate it to downstream MCP backends
            request_headers = context.state.get("headers", {})
            for key, value in request_headers.items():
                if key.lower() == "authorization":
                    headers[key] = value
                    break
        http_kwargs["headers"] = headers
        return request_payload, http_kwargs

🔄 Alternatives Considered

No response

🎯 Affected Service(s)

None

📚 Additional Context

background of our setup: How coordinator → sub-agent dispatch works

In kagent's multi-agent setup, agents are deployed as individual pods behind agentgateway, which acts as both an A2A (agent-to-agent) and MCP (model-context-protocol) gateway:

┌─────────┐     A2A (8080)    ┌──────────────────┐    A2A (8080)     ┌─────────────┐    MCP (9090)    ┌─────────────┐
│ Browser  │ ──────────────►  │  agentgateway    │ ──────────────►  │  sub-agent   │ ──────────────►  │ MCP backend │
│ (+ JWT)  │                  │  (A2A listener)  │                  │  (kagent)    │                  │ (toolserver)│
└─────────┘                   └──────────────────┘                  └─────────────┘                  └─────────────┘
                                      │                                    │
                                      │ routes by agent name               │ MCP calls via agentgateway
                                      ▼                                    ▼
                              ┌──────────────────┐              ┌──────────────────┐
                              │  coordinator     │              │  agentgateway    │
                              │  agent (kagent)  │              │  (MCP listener)  │
                              └──────────────────┘              └──────────────────┘

The coordinator → sub-agent hop happens when:

  1. A request arrives at a coordinator agent (via agentgateway A2A listener on port 8080)
  2. The coordinator's LLM decides to invoke a tool of type: Agent (a sub-agent reference)
  3. _SubagentInterceptor builds the outbound HTTP request to the sub-agent's A2A endpoint — this is where the Authorization header is dropped
  4. The sub-agent receives the request without the JWT, processes it, and calls MCP backends via agentgateway's MCP listener (port 9090)
  5. The MCP backend never sees the original user's JWT

When agentgateway is configured with JWT authentication on the MCP listener, it can validate/enforce identity on MCP calls — but only if the JWT survives the full chain. The coordinator → sub-agent A2A hop is the gap.

Additional Context

  • create_header_provider() in types.py:36 already reads from session.state["headers"] and forwards Authorization when listed in allowedHeaders — but this only applies to the agent → MCP path, not the coordinator → sub-agent A2A path.
  • When STS is configured, the STS-provided token should still take precedence on the sub-agent side (same security model as create_header_provider).
  • Verified on kagent v0.9.0 with agentgateway v1.1.0. Tested by sending a JWT via curl to a coordinator-agent → sub-agent → MCP chain. agentgateway logs confirm jwt.sub is present on direct calls and absent on coordinator-dispatched calls.

🙋 Are you willing to contribute?

  • I am willing to submit a PR for this feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions