-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Feat: Dynamic authentication handling in MCPToolset from Adk ReadonlyContext.state using Callback #1198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: Dynamic authentication handling in MCPToolset from Adk ReadonlyContext.state using Callback #1198
Changes from all commits
a414d05
ff48e57
67640da
30f6c11
7adcc0f
76cceee
843c3ee
8e80a91
af9899c
5a94096
ec56403
e806278
6fdb83c
b742dad
f96f7b0
c80b5d1
42f0a9b
252419b
8e32c98
8455649
46eeb74
06510cb
2452b3c
d79a824
d56d06a
a6dfb43
891652b
68d2331
692919d
0c6a610
e6e209d
a3ef8df
60324bc
68d5825
d5dc91c
451977f
6094d42
bcb727e
5feb978
6910f3f
ef4aef7
0b1facb
8985a2f
223c78c
70e687b
d735d74
26d6123
f268358
2a3111b
d7a376b
2d39f80
3c945f0
3b4db02
694ae67
dd0d088
9adccd9
39656d9
41bc352
b7840bd
7218f5a
d8dbbe8
067f941
c02f34b
59c22a1
12c3167
72c752d
2e30b18
bd1d3b9
0fa619b
ef4bdd4
fdafdb3
fd86004
1127bfd
a2b7441
d88525c
f49f5dd
6432843
fcff3e1
2fc6f88
75f46ba
8ebe82c
b3dc49a
5d21e59
9249d63
d1cf32e
4d37008
1012ff7
79e2d5f
a917c89
1787d89
c176764
6493df3
82cd7b4
4b91616
9b637f9
ca473bf
4dc1b49
675619d
cb6e798
dee254f
0b11a7f
8da29ec
68a5be2
84e2844
427b08e
b4cfdc4
c69d5cc
fe25aec
26c8bec
95144c5
92e4baa
e8bd6c3
18c0dfd
be590a3
363e3b3
26eafb6
f66ec6e
b6e832a
340c57e
9298f27
aff819d
4638f73
f13c166
23786cb
7f9ad5b
1302b2f
c5bc8d1
3476c33
c6e7790
f6f4752
cf480ad
ae6a9a6
e104c2e
0c0213e
d0dfee4
86d4561
b8ed635
65cea1b
8fd4bdf
2967bae
50f4391
13d79d0
3eac6e9
03f67ae
6a902d3
81bd329
8ba1e1d
c2572e1
08d102b
b147c18
9ddb642
953d536
d94c14e
b28c6e7
75454a7
79a6d7b
2e402cf
a1dc926
486605e
9a4ff1b
fb4c6d5
f22b74c
37032d0
3ffeacd
fa0247b
a0224a1
acf3e25
a8157e3
6acdd47
651906d
b2ccef4
a5861d5
91e782d
0a598d7
09c599c
95829b9
28e2c0a
9c9e38f
63555da
1225976
2eff71f
209788c
70b87f8
c82f799
bf341ce
0499070
18b49a2
316949e
92d4715
853b966
8b99e69
8bb0704
1353e83
09e187e
b233e18
d094f5c
ecc5bae
3f0c319
b3f909f
61abb32
0209735
6988b4c
b9b33fc
ee9f471
c56b18f
b9e9f74
f709cf4
bdb2225
e6676f9
6b9cf3a
400312b
d8c9b33
ccceb13
52322ab
ea55e0f
0d8f0e6
918e603
9866a8b
81a555a
6b181a5
66e9535
fbc4b8f
8bdd39d
5e04d15
114bf05
709cae9
fc24b30
0300929
3c939d5
76df3d1
2ae6c1c
474aea4
7f46154
81aad8a
e9e7996
50d41ce
97b64ab
177377d
63ec4fd
8acf960
0aa070b
55acf83
b048e40
985cc2d
255db3d
7bc83e5
025a965
32ee080
425794e
e9ae377
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,80 @@ | ||||||
# Sample: Passing User Token from Agent State to MCP via ContextToEnvMapperCallback | ||||||
|
||||||
This sample demonstrates how to use the `context_to_env_mapper_callback` feature in ADK to pass a user token from the agent's session state to an MCP process (using stdio transport). This is useful when your MCP server (built by your organization) requires the same user token for internal API calls. | ||||||
|
||||||
## How it works | ||||||
- The agent is initialized with a `MCPToolset` using `StdioServerParameters`. | ||||||
- The `context_to_env_mapper_callback` is set to a function that extracts the `user_token` from the agent's state and maps it to the `USER_TOKEN` environment variable. | ||||||
- When the agent calls the MCP, the token is injected into the MCP process environment, allowing the MCP to use it for internal authentication. | ||||||
|
||||||
## Directory Structure | ||||||
``` | ||||||
contributing/samples/stdio_mcp_user_auth_passing_sample/ | ||||||
├── agent.py # Basic agent setup | ||||||
├── main.py # Complete runnable example | ||||||
└── README.md | ||||||
``` | ||||||
|
||||||
## How to Run | ||||||
|
||||||
### Option 1: Run the complete example | ||||||
```bash | ||||||
cd /home/sanjay-dev/Workspace/adk-python | ||||||
python -m contributing.samples.stdio_mcp_user_auth_passing_sample.main | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The module path in the command doesn't match the actual directory name 'mcp_stdio_user_auth_passing_sample'. It should be 'mcp_stdio_user_auth_passing_sample.main'.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
``` | ||||||
|
||||||
### Option 2: Use the agent in your own code | ||||||
```python | ||||||
from contributing.samples.stdio_mcp_user_auth_passing_sample.agent import create_agent | ||||||
from google.adk.sessions import Session | ||||||
|
||||||
agent = create_agent() | ||||||
session = Session( | ||||||
id="your_session_id", | ||||||
app_name="your_app_name", | ||||||
user_id="your_user_id" | ||||||
) | ||||||
|
||||||
# Set user token in session state | ||||||
session.state['user_token'] = 'YOUR_ACTUAL_TOKEN_HERE' | ||||||
session.state['api_endpoint'] = 'https://your-internal-api.com' | ||||||
|
||||||
# Then use the agent in your workflow... | ||||||
``` | ||||||
|
||||||
## Flow Diagram | ||||||
|
||||||
```mermaid | ||||||
graph TD | ||||||
subgraph "User Application" | ||||||
U[User] | ||||||
end | ||||||
|
||||||
subgraph "Agent Process" | ||||||
A[Agent Instance<br/>per user-app-agentid] | ||||||
S[Session State<br/>user_token, api_endpoint] | ||||||
C[ContextToEnvMapperCallback] | ||||||
end | ||||||
|
||||||
subgraph "MCP Process" | ||||||
M[MCP Server<br/>stdio transport] | ||||||
E[Environment Variables<br/>USER_TOKEN, API_ENDPOINT] | ||||||
API[Internal API Calls] | ||||||
end | ||||||
|
||||||
U -->|Sends request| A | ||||||
A -->|Reads state| S | ||||||
S -->|Extracts tokens| C | ||||||
C -->|Maps to env vars| E | ||||||
A -->|Spawns with env| M | ||||||
M -->|Uses env vars| API | ||||||
API -->|Response| M | ||||||
M -->|Tool result| A | ||||||
A -->|Response| U | ||||||
``` | ||||||
|
||||||
## Context | ||||||
- Each agent instance is initiated per user-app-agentid. | ||||||
- The agent receives a user context (with token) and calls the MCP using stdio transport. | ||||||
- The MCP, built by the same organization, uses the token for internal API calls. | ||||||
- The ADK's context-to-env mapping feature makes this seamless. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
""" | ||
Sample: Using ContextToEnvMapperCallback to pass user token from agent state to MCP via stdio transport. | ||
""" | ||
|
||
import os | ||
import tempfile | ||
from typing import Any | ||
from typing import Dict | ||
|
||
from google.adk.agents.llm_agent import LlmAgent | ||
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams | ||
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset | ||
from mcp import StdioServerParameters | ||
|
||
_allowed_path = os.path.dirname(os.path.abspath(__file__)) | ||
|
||
|
||
def user_token_env_mapper(state: Dict[str, Any]) -> Dict[str, str]: | ||
"""Extracts USER_TOKEN from agent state and maps to MCP env.""" | ||
env = {} | ||
if "user_token" in state: | ||
env["USER_TOKEN"] = state["user_token"] | ||
if "api_endpoint" in state: | ||
env["API_ENDPOINT"] = state["api_endpoint"] | ||
|
||
print(f"Environment variables being passed to MCP: {env}") | ||
return env | ||
|
||
|
||
def create_agent() -> LlmAgent: | ||
"""Create the agent with context to env mapper callback.""" | ||
# Create a temporary directory for the filesystem server | ||
temp_dir = tempfile.mkdtemp() | ||
|
||
return LlmAgent( | ||
model="gemini-2.0-flash", | ||
name="user_token_agent", | ||
instruction=f""" | ||
You are an agent that calls an internal MCP server which requires a user token for internal API calls. | ||
The user token is available in your session state and must be passed to the MCP process as an environment variable. | ||
Test directory: {temp_dir} | ||
""", | ||
tools=[ | ||
MCPToolset( | ||
connection_params=StdioConnectionParams( | ||
server_params=StdioServerParameters( | ||
command="npx", | ||
args=[ | ||
"-y", # Arguments for the command | ||
"@modelcontextprotocol/server-filesystem", | ||
_allowed_path, | ||
], | ||
), | ||
timeout=5, | ||
), | ||
get_env_from_context_fn=user_token_env_mapper, | ||
tool_filter=["read_file", "list_directory"], | ||
) | ||
], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
""" | ||
Sample: Using ContextToEnvMapperCallback to pass user token from agent state to MCP via stdio transport. | ||
""" | ||
|
||
import asyncio | ||
|
||
from google.adk.agents.invocation_context import InvocationContext | ||
from google.adk.agents.readonly_context import ReadonlyContext | ||
from google.adk.sessions import InMemorySessionService | ||
from google.adk.sessions import Session | ||
|
||
from .agent import create_agent | ||
|
||
|
||
async def main(): | ||
"""Example of how to set up and run the agent with user token.""" | ||
print("=== STDIO MCP User Auth Passing Sample ===") | ||
print() | ||
|
||
# Create the agent | ||
agent = create_agent() | ||
print(f"✓ Created agent: {agent.name}") | ||
|
||
# Create session service and session | ||
session_service = InMemorySessionService() | ||
session = Session( | ||
id="sample_session", | ||
app_name="stdio_mcp_user_auth_passing_sample", | ||
user_id="sample_user", | ||
) | ||
print(f"✓ Created session: {session.id}") | ||
|
||
# Set user token in session state | ||
session.state["user_token"] = "sample_user_token_123" | ||
session.state["api_endpoint"] = "https://internal-api.company.com" | ||
print(f"✓ Set session state with user_token: {session.state['user_token']}") | ||
|
||
# Create invocation context | ||
invocation_context = InvocationContext( | ||
invocation_id="sample_invocation", | ||
agent=agent, | ||
session=session, | ||
session_service=session_service, | ||
) | ||
|
||
# Create readonly context | ||
readonly_context = ReadonlyContext(invocation_context) | ||
print(f"✓ Created readonly context") | ||
|
||
print() | ||
print("=== Demonstrating User Auth Token Passing to MCP ===") | ||
print( | ||
"Note: This sample shows how the callback extracts environment variables." | ||
) | ||
print("In a real scenario, these would be passed to an actual MCP server.") | ||
print() | ||
|
||
# Access the MCP toolset to demonstrate the callback | ||
mcp_toolset = agent.tools[0] | ||
mcp_session_manager = mcp_toolset._mcp_session_manager | ||
|
||
# Extract environment variables using the callback (without connecting to MCP) | ||
if mcp_session_manager._context_to_env_mapper_callback: | ||
print("✓ Context-to-env mapper callback is configured") | ||
|
||
# Simulate what happens during MCP session creation | ||
env_vars = mcp_session_manager._extract_env_from_context(readonly_context) | ||
|
||
print(f"✓ Extracted environment variables:") | ||
for key, value in env_vars.items(): | ||
print(f" {key}={value}") | ||
print() | ||
|
||
print( | ||
"✓ These environment variables would be injected into the MCP process" | ||
) | ||
print("✓ The MCP server can then use them for internal API calls") | ||
else: | ||
print("✗ No context-to-env mapper callback configured") | ||
|
||
print() | ||
print("=== Sample completed successfully! ===") | ||
print() | ||
print("Key points demonstrated:") | ||
print("1. Session state holds user tokens and configuration") | ||
print( | ||
"2. Context-to-env mapper callback extracts these as environment" | ||
" variables" | ||
) | ||
print("3. Environment variables would be passed to MCP server processes") | ||
print("4. MCP servers can use these for authenticated API calls") | ||
|
||
|
||
if __name__ == "__main__": | ||
asyncio.run(main()) |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -23,6 +23,7 @@ | |||||||
import logging | ||||||||
import sys | ||||||||
from typing import Any | ||||||||
from typing import Callable | ||||||||
from typing import Dict | ||||||||
from typing import Optional | ||||||||
from typing import TextIO | ||||||||
|
@@ -177,13 +178,43 @@ def __init__( | |||||||
else: | ||||||||
self._connection_params = connection_params | ||||||||
self._errlog = errlog | ||||||||
|
||||||||
# Session pool: maps session keys to (session, exit_stack) tuples | ||||||||
self._sessions: Dict[str, tuple[ClientSession, AsyncExitStack]] = {} | ||||||||
|
||||||||
# Lock to prevent race conditions in session creation | ||||||||
self._session_lock = asyncio.Lock() | ||||||||
|
||||||||
def update_connection_params( | ||||||||
self, | ||||||||
new_connection_params: Union[ | ||||||||
StdioServerParameters, | ||||||||
StdioConnectionParams, | ||||||||
SseConnectionParams, | ||||||||
StreamableHTTPConnectionParams, | ||||||||
], | ||||||||
) -> None: | ||||||||
"""Updates the connection parameters and invalidates existing sessions. | ||||||||
|
||||||||
Args: | ||||||||
new_connection_params: New connection parameters to use. | ||||||||
""" | ||||||||
if isinstance(new_connection_params, StdioServerParameters): | ||||||||
logger.warning( | ||||||||
'StdioServerParameters is not recommended. Please use' | ||||||||
' StdioConnectionParams.' | ||||||||
) | ||||||||
self._connection_params = StdioConnectionParams( | ||||||||
server_params=new_connection_params, | ||||||||
timeout=5, | ||||||||
) | ||||||||
else: | ||||||||
self._connection_params = new_connection_params | ||||||||
|
||||||||
# Clear existing sessions since connection params changed | ||||||||
# Sessions will be recreated on next request | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment mentions clearing sessions but the actual implementation doesn't clear the sessions dictionary. This could lead to stale sessions being reused with outdated connection parameters.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
# Note: We don't close sessions here to avoid blocking, | ||||||||
# they will be cleaned up when detected as disconnected | ||||||||
|
||||||||
|
||||||||
def _generate_session_key( | ||||||||
self, merged_headers: Optional[Dict[str, str]] = None | ||||||||
) -> str: | ||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The directory path in the documentation doesn't match the actual directory name 'mcp_stdio_user_auth_passing_sample'. It should be 'mcp_stdio_user_auth_passing_sample/'.
Copilot uses AI. Check for mistakes.