Skip to content

feat(mcp): Add session state-based JWT token propagation for MCP tools#3675

Open
timof1308 wants to merge 8 commits into
google:mainfrom
timof1308:feature/secure-token-handling
Open

feat(mcp): Add session state-based JWT token propagation for MCP tools#3675
timof1308 wants to merge 8 commits into
google:mainfrom
timof1308:feature/secure-token-handling

Conversation

@timof1308
Copy link
Copy Markdown

@timof1308 timof1308 commented Nov 23, 2025

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Main Tracking Issue:
This PR is a core component of the main MCP support effort tracked in #3449

2. Issues Closed/Addressed:

3. Related Open Issues:

Problem

Currently, MCP tools in ADK lack a secure, standardized way to propagate per-user authentication tokens (like JWTs) from the client to the MCP server. Developers often have to hardcode credentials or modify core FastAPI endpoints to pass these headers, which is not scalable or secure for multi-user environments. Additionally, storing short-lived, sensitive tokens in the persistent session.state is a security risk as they may be logged or stored in the database.

Solution

A mechanism to propagate ephemeral state (request_state) from the RunAgentRequest through to the InvocationContext, plus authentication header support for McpToolset at three levels of flexibility:

  • credential_key: Convenience parameter that reads a token from session state and sends it as Authorization: Bearer <token>. Aligns with credential_key on other ADK toolsets (e.g., OpenAPIToolset)
  • state_header_mapping / state_header_format: Declarative config for mapping arbitrary state keys to HTTP headers with format templates
  • header_provider: Full control via a callable for dynamic/computed headers

All three levels resolve to the same HeaderProvider type internally — no separate code paths. They can be freely combined.

Key changes:

  • request_state: Added to InvocationContext for ephemeral data that overrides session.state but is not persisted
  • ReadonlyContext.state: Merges request_state over session.state via ChainMap (ephemeral takes precedence)
  • _internal.py: RFC 7230-compliant header validation and sanitization utilities
  • Boundary sanitization in both McpToolset._execute_with_session and McpTool._run_async_impl
  • create_session_state_header_provider: Utility to generate header provider functions
  • Config validation on McpToolsetConfig (rejects invalid header names, orphaned format strings, CRLF injection)

Review Fixes (latest commit)

Addressed code review findings:

  • Consolidated header sanitization to boundary methods only (_execute_with_session, _run_async_impl), removing redundant intermediate sanitization
  • Replaced verbose _DANGEROUS_CHARS set literal with concise frozenset comprehension
  • Removed redundant CRLF strip in create_session_state_header_provider (already handled by sanitize_header_value)
  • Switched to lazy %-formatting in logging calls
  • Clarified docstrings for Runner.run() Optional new_message and McpToolset.from_config() header_provider limitation

Testing Plan

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Unit Tests:

  • tests/unittests/tools/mcp_tool/test_jwt_token_propagation.py (37 tests): Header generation, credential_key, state_header_mapping, request_state precedence, RFC 7230 validation, config parsing, CRLF injection protection, combined provider duplicate warnings.
  • tests/unittests/agents/test_readonly_context_state.py (3 tests): ReadonlyContext.state merges ephemeral and persistent state with correct precedence and immutability.
  • tests/unittests/agents/test_readonly_context.py: No regressions.
  • tests/unittests/tools/mcp_tool/test_mcp_toolset.py: No regressions in existing toolset functionality.

Manual End-to-End (E2E) Tests:

Verified against a local FastMCP server with a mock LLM agent:

  • Sent run_agent with request_state={"jwt_token": "test-token-123"}
  • MCP server received header Authorization: Bearer test-token-123
  • Agent successfully retrieved the echoed value, confirming full propagation

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Usage Examples

Option 1: credential_key (simplest — Bearer token from state)

YAML:

tools:
  - name: google.adk.tools.mcp_tool.McpToolset
    args:
      streamable_http_connection_params:
        url: http://api.example.com/mcp
      credential_key: jwt_token

Python:

from google.adk.tools.mcp_tool import McpToolset

toolset = McpToolset(
    connection_params=StreamableHTTPConnectionParams(
        url='http://api.example.com/mcp'
    ),
    credential_key="jwt_token",
)

Option 2: state_header_mapping (custom headers and formats)

YAML:

tools:
  - name: google.adk.tools.mcp_tool.McpToolset
    args:
      streamable_http_connection_params:
        url: http://api.example.com/mcp
      state_header_mapping:
        jwt_token: Authorization
        tenant_id: X-Tenant-ID
      state_header_format:
        Authorization: "Bearer {value}"

Python:

from google.adk.tools.mcp_tool import create_session_state_header_provider, McpToolset

toolset = McpToolset(
    connection_params=StreamableHTTPConnectionParams(
        url='http://api.example.com/mcp'
    ),
    header_provider=create_session_state_header_provider(
        state_key="jwt_token",
        header_name="Authorization",
        header_format="Bearer {value}"
    )
)

Client-side: Passing the token

# Ephemeral (recommended) — token NOT persisted to session history
requests.post(
    "/run",
    json={
        "app_name": "my_app",
        "user_id": "user123",
        "session_id": "session_id",
        "new_message": {"parts": [{"text": "query"}]},
        "request_state": {
            "jwt_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
        }
    }
)

# Persistent — token saved to session history
requests.post(
    "/run",
    json={
        "app_name": "my_app",
        "user_id": "user123",
        "session_id": "session_id",
        "new_message": {"parts": [{"text": "query"}]},
        "state_delta": {
            "jwt_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
        }
    }
)

@adk-bot adk-bot added the mcp [Component] Issues about MCP support label Nov 23, 2025
@timof1308 timof1308 marked this pull request as ready for review November 23, 2025 13:35
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a secure and flexible mechanism for propagating ephemeral data, like JWT tokens, from the client to MCP tools. The use of a non-persistent request_state that overrides the session state is a great design for handling sensitive, short-lived data. The addition of header_provider and declarative configuration options (state_header_mapping, state_header_format) in McpToolset makes this feature powerful and easy to use. The code is well-structured and thoroughly tested. My review includes a couple of suggestions to enhance the robustness of the new header provider logic by adding warnings for when non-primitive data types are used from the state, which could prevent silent misconfigurations.

Comment thread src/google/adk/tools/mcp_tool/mcp_toolset.py Outdated
Comment thread src/google/adk/tools/mcp_tool/mcp_toolset.py Outdated
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a secure mechanism for propagating ephemeral, request-specific data like JWT tokens to MCP tools. The addition of request_state to the InvocationContext and its integration into the ReadonlyContext is a clean solution to avoid persisting sensitive data. The new configuration options state_header_mapping and state_header_format in McpToolset provide a flexible, declarative way to manage headers. The changes are well-tested with new unit tests that cover the new functionality thoroughly. I have one suggestion to improve maintainability by reducing code duplication.

Comment thread src/google/adk/tools/mcp_tool/mcp_toolset.py Outdated
@chenvaltzer-boop
Copy link
Copy Markdown

+1

@rohityan rohityan self-assigned this Nov 25, 2025
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from c6d2245 to d211763 Compare November 27, 2025 11:37
@timof1308
Copy link
Copy Markdown
Author

All bot review comments have been addressed through the following commits:

  • f5ca9e2 - Added strict mode for type validation
  • e23bd01 - Added state_header_strict config option
  • 124cc14 - Added RFC 7230 compliant validation and sanitization

@rohityan rohityan added the request clarification [Status] The maintainer need clarification or more information from the author label Nov 30, 2025
@rohityan
Copy link
Copy Markdown
Collaborator

Hi @timof1308 , Thank you for your work on this pull request. We appreciate the effort you've invested.
Before we can proceed with the review can you please fix the failing unit tests and lint errors

@timof1308
Copy link
Copy Markdown
Author

thank you @ryanaiagent
I have fixed the imports and the failing unit tests

the CI is now pending on the latest commit (d0c7829). Once the workflow finishes, all tests should pass. Let me know if anything else is needed!

@hagaic-ship-it
Copy link
Copy Markdown

+1

1 similar comment
@eranc-google
Copy link
Copy Markdown

+1

@rohityan
Copy link
Copy Markdown
Collaborator

rohityan commented Dec 4, 2025

Hi @timof1308 ,This PR now has merge conflicts that require changes from your end. Could you please rebase your branch with the latest main branch to address these? Once this is complete, please let us know so we can proceed with the review.

@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from d0c7829 to f5e20b0 Compare December 4, 2025 06:58
@timof1308
Copy link
Copy Markdown
Author

hi @ryanaiagent, rebased with upstream main and reapplied ./autoformat.sh

@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from 02be37c to 6de52f9 Compare December 8, 2025 08:21
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 02be37c to 087ee78 Compare December 16, 2025 10:05
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 94038a3 to fc287ca Compare January 5, 2026 07:16
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 381f70f to 6e513d2 Compare January 15, 2026 06:35
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 3 times, most recently from e24335e to d7f1652 Compare January 19, 2026 13:46
@rohityan
Copy link
Copy Markdown
Collaborator

Hi @timof1308 , can you fix the failing unit tests

@timof1308
Copy link
Copy Markdown
Author

hi @ryanaiagent, I've pushed a fix for the unit tests

The failures in test_readonly_context.py and test_mcp_instruction_provider.py were regressions caused by my changes (missing request_state in the test mocks), which are now resolved and should pass now

however I noticed other failures in the logs (related to pubsub and litellm), but those appear to be present in the upstream adk-python repo

@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 5bf9574 to 038a129 Compare January 21, 2026 07:33
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from 289687e to 5c7d479 Compare January 26, 2026 20:17
@timof1308
Copy link
Copy Markdown
Author

@ryanaiagent file formatting has been applied

@rohityan
Copy link
Copy Markdown
Collaborator

Hi @timof1308 , Your PR has been received by the team and is currently under review. We will provide feedback as soon as we have an update to share.

@rohityan
Copy link
Copy Markdown
Collaborator

Hi @GWeale , can you please review this.

@rohityan rohityan added needs review [Status] The PR/issue is awaiting review from the maintainer and removed request clarification [Status] The maintainer need clarification or more information from the author labels Jan 27, 2026
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from bc4e197 to 6052bca Compare March 16, 2026 09:34
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 7e127b5 to 849b25c Compare March 22, 2026 10:48
@timof1308
Copy link
Copy Markdown
Author

@rohityan any update on this PR?

@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from 849b25c to 68180ed Compare April 3, 2026 10:17
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 78337dc to bd15172 Compare April 14, 2026 07:08
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from 36258ca to 66208c0 Compare April 22, 2026 17:13
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch 2 times, most recently from dcfc5b8 to 4a330e8 Compare May 1, 2026 17:14
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from 4a330e8 to a6b1a39 Compare May 12, 2026 16:35
timof1308 added 8 commits May 17, 2026 12:21
Add a non-persisted request_state dict to InvocationContext that is
threaded through Runner.run_async and the AdkWebServer. ReadonlyContext.state
now returns a ChainMap merging request_state over session.state, so
ephemeral keys (e.g. tokens) take precedence without being persisted.
Introduce create_session_state_header_provider and create_combined_header_provider
for extracting session state values into HTTP headers with automatic sanitization.
Add credential_key shorthand for Bearer token propagation, state_header_mapping
config for arbitrary state-to-header mappings, and strict mode for type validation.
Header names and values are validated per RFC 7230 to prevent injection attacks.
Add comprehensive tests for request_state merging in ReadonlyContext,
header provider creation, state_header_mapping config, credential_key
shorthand, RFC 7230 header validation, CRLF injection prevention, and
strict mode. Update existing mocks to include request_state.
Upstream merged credential_key for AuthConfig in McpToolset (commit 282db87).
Remove the PR's credential_key Bearer token shorthand to avoid the duplicate
parameter conflict. The same use case is covered by state_header_mapping
with state_header_format.
…tate.py

The header-check CI was failing because this new test file was missing
the required copyright/license header.
- Add CRLF/TAB to _DANGEROUS_CHARS and sanitize auth headers
- Standardize header merge order (header_provider takes precedence)
- Add model validator for state_header_mapping/state_header_format
- Warn on duplicate header names in combined providers
- Add request_state/state_delta/invocation_id to sync Runner.run()
- Use TYPE_CHECKING guard in types.py
- Move sanitization from _get_auth_headers to _execute_with_session boundary
- Replace verbose _DANGEROUS_CHARS literal with frozenset comprehension
- Remove redundant CRLF strip already handled by sanitize_header_value
- Use lazy %-formatting instead of f-strings in logging
- Clarify docstrings for run() Optional new_message and from_config()
@timof1308 timof1308 force-pushed the feature/secure-token-handling branch from c57a3a6 to e7f43fb Compare May 17, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mcp [Component] Issues about MCP support needs review [Status] The PR/issue is awaiting review from the maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP Tools: No mechanism to pass user JWT token through ADK session.state to MCP server context

7 participants