Skip to content

Commit fe497a4

Browse files
wukathcopybara-github
authored andcommitted
feat: Migrate McpToolset to AsyncAuthorizedSession for mTLS support
Use Google's official async client to support mTLS for GCP API calls. This resolves the issue where we were failing mTLS policy due to unbound tokens. Note: This CL also removes the legacy bound-token patch from mcp_tool.py and mcp_toolset.py, which constitutes a behavior change beyond the mTLS feature. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 931326860
1 parent f9dd9ae commit fe497a4

8 files changed

Lines changed: 778 additions & 48 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# MCP SSE Agent with mTLS
2+
3+
This sample demonstrates how to configure an ADK agent to connect to an MCP server using **mutual TLS (mTLS)** over SSE (HTTPS).
4+
5+
## Prerequisites
6+
7+
To test mTLS locally, you need to generate local certificates (CA, Server, and Client) and configure your environment to trust them.
8+
9+
### 1. Generate Certificates
10+
11+
Run the helper script in this directory to generate a local CA and sign the server and client certificates:
12+
13+
```bash
14+
./generate_mtls_certs.sh
15+
```
16+
17+
This will generate:
18+
19+
- `ca.crt`, `ca.key` (Local CA)
20+
- `server.crt`, `server.key` (Server certificate/key)
21+
- `client.crt`, `client.key` (Client certificate/key)
22+
- `certificate_config.json` (Workload certificate configuration for `google-auth`)
23+
24+
______________________________________________________________________
25+
26+
## Running the Sample
27+
28+
### Step 1: Start the MCP Server
29+
30+
Start the server in this directory. We configure it to trust our local CA so it can verify the client certificate:
31+
32+
```bash
33+
# Point to the certificate config
34+
export GOOGLE_API_CERTIFICATE_CONFIG=$(pwd)/certificate_config.json
35+
36+
# Tell the server to trust our test CA for client verification
37+
export SSL_CA_CERTS=$(pwd)/ca.crt
38+
39+
# Run the server
40+
python filesystem_server.py
41+
```
42+
43+
*(The server will run on `https://localhost:3000`)*
44+
45+
### Step 2: Run the ADK Agent (Client)
46+
47+
In a second terminal, navigate to the open-source workspace root and run the client.
48+
49+
```bash
50+
cd third_party/py/google/adk/open_source_workspace
51+
source .venv/bin/activate
52+
53+
# 1. Combine system CAs with our test CA so the client trusts the server cert
54+
cat /usr/lib/ssl/cert.pem contributing/samples/mcp/mcp_sse_mtls_agent/ca.crt > combined_ca.pem
55+
export SSL_CERT_FILE=$(pwd)/combined_ca.pem
56+
57+
# 2. Point google-auth to our simulated workload config
58+
export GOOGLE_API_CERTIFICATE_CONFIG=$(pwd)/contributing/samples/mcp/mcp_sse_mtls_agent/certificate_config.json
59+
60+
# 3. Enable client certificate usage
61+
export GOOGLE_API_USE_CLIENT_CERTIFICATE=true
62+
63+
# 4. Set your LLM credentials (e.g. source your env file)
64+
source test/.env
65+
66+
# 5. Run the agent
67+
adk run contributing/samples/mcp/mcp_sse_mtls_agent
68+
```
69+
70+
______________________________________________________________________
71+
72+
## How it works
73+
74+
1. **Client Certificate (mTLS):** The `google-auth` library (used by ADK) reads `GOOGLE_API_CERTIFICATE_CONFIG` to load the client certificate (`client.crt`) and key (`client.key`) as a simulated Workload Certificate.
75+
1. **Server Verification:** The server loads the CA (`ca.crt`) via `SSL_CA_CERTS` and requires the client to present a certificate signed by this CA (`ssl_cert_reqs=ssl.CERT_REQUIRED`).
76+
1. **Client Verification:** The client trusts the server certificate (`server.crt`) because it is signed by the same CA, which we added to `SSL_CERT_FILE`.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import os
17+
18+
from google.adk.agents.llm_agent import LlmAgent
19+
from google.adk.agents.mcp_instruction_provider import McpInstructionProvider
20+
from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
21+
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset
22+
23+
connection_params = SseConnectionParams(
24+
url=os.environ.get('MCP_SERVER_URL', 'https://localhost:3000/sse'),
25+
headers={'Accept': 'text/event-stream'},
26+
)
27+
28+
root_agent = LlmAgent(
29+
name='enterprise_assistant',
30+
model='gemini-2.5-flash',
31+
instruction=McpInstructionProvider(
32+
connection_params=connection_params,
33+
prompt_name='file_system_prompt',
34+
),
35+
tools=[
36+
MCPToolset(
37+
connection_params=connection_params,
38+
tool_filter=[
39+
'read_file',
40+
'list_directory',
41+
'get_cwd',
42+
],
43+
)
44+
],
45+
)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import asyncio
17+
import os
18+
import pathlib
19+
import ssl
20+
import sys
21+
import tempfile
22+
23+
import google.auth.transport.mtls as google_mtls
24+
from mcp.server.fastmcp import FastMCP
25+
import uvicorn
26+
27+
# Create an MCP server with a name
28+
mcp = FastMCP("Filesystem Server (mTLS)", host="localhost", port=3000)
29+
30+
31+
# Add a tool to read file contents
32+
@mcp.tool(description="Read contents of a file")
33+
def read_file(filepath: str) -> str:
34+
"""Read and return the contents of a file."""
35+
with open(filepath, "r") as f:
36+
return f.read()
37+
38+
39+
# Add a tool to list directory contents
40+
@mcp.tool(description="List contents of a directory")
41+
def list_directory(dirpath: str) -> list:
42+
"""List all files and directories in the given directory."""
43+
return os.listdir(dirpath)
44+
45+
46+
# Add a tool to get current working directory
47+
@mcp.tool(description="Get current working directory")
48+
def get_cwd() -> str:
49+
"""Return the current working directory."""
50+
return str(pathlib.Path.cwd())
51+
52+
53+
# Add a prompt for accessing file systems
54+
@mcp.prompt()
55+
def file_system_prompt() -> str:
56+
"""Prompt helper for accessing file systems."""
57+
return (
58+
"You are a helpful assistant with access to the local filesystem. You can"
59+
" read files and list directories to help the user with their request."
60+
)
61+
62+
63+
# Graceful shutdown handler
64+
async def shutdown(signal, loop):
65+
"""Cleanup tasks on shutdown."""
66+
print(f"\nReceived exit signal {signal.name}...")
67+
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
68+
for task in tasks:
69+
task.cancel()
70+
print(f"Cancelling {len(tasks)} outstanding tasks")
71+
await asyncio.gather(*tasks, return_exceptions=True)
72+
loop.stop()
73+
74+
75+
# Main entry point with mTLS enabled
76+
if __name__ == "__main__":
77+
cert_dir = os.path.dirname(os.path.abspath(__file__))
78+
keyfile = os.path.join(cert_dir, "server.key")
79+
certfile = os.path.join(cert_dir, "server.crt")
80+
81+
if not (os.path.exists(keyfile) and os.path.exists(certfile)):
82+
print(f"Error: mTLS cert files not found in {cert_dir}")
83+
print("Please generate them using the helper script:")
84+
print(f" ./generate_mtls_certs.sh")
85+
sys.exit(1)
86+
87+
# Configure SSL context for mTLS
88+
print("Configuring SSL context for mTLS...")
89+
90+
# Allow explicit CA certs override (useful for testing with custom CA signed certs)
91+
ca_certs = os.environ.get("SSL_CA_CERTS")
92+
temp_ca_file = None
93+
94+
if ca_certs:
95+
print(f" Using explicit SSL_CA_CERTS: {ca_certs}")
96+
else:
97+
has_cert_source = google_mtls.has_default_client_cert_source()
98+
print(f" has_default_client_cert_source: {has_cert_source}")
99+
print(f" default cafile: {ssl.get_default_verify_paths().cafile}")
100+
101+
if has_cert_source:
102+
try:
103+
callback = google_mtls.default_client_cert_source()
104+
client_cert_bytes, _ = callback()
105+
temp_ca_file = tempfile.NamedTemporaryFile(delete=False, suffix=".crt")
106+
temp_ca_file.write(client_cert_bytes)
107+
temp_ca_file.close()
108+
ca_certs = temp_ca_file.name
109+
print(f" Loaded client cert to trust: {ca_certs}")
110+
except Exception as e:
111+
print(f" Warning: Failed to load default client cert: {e}")
112+
ca_certs = ssl.get_default_verify_paths().cafile
113+
else:
114+
print(" No default client cert source found. Using system CAs.")
115+
ca_certs = ssl.get_default_verify_paths().cafile
116+
117+
print(f" Using ca_certs for client verification: {ca_certs}")
118+
119+
app = mcp.sse_app()
120+
121+
config = uvicorn.Config(
122+
app,
123+
host=mcp.settings.host,
124+
port=mcp.settings.port,
125+
log_level=mcp.settings.log_level.lower(),
126+
ssl_keyfile=keyfile,
127+
ssl_certfile=certfile,
128+
ssl_cert_reqs=int(ssl.CERT_REQUIRED),
129+
ssl_ca_certs=ca_certs,
130+
)
131+
server = uvicorn.Server(config)
132+
133+
print(
134+
"Starting MCP server with mTLS on"
135+
f" https://{mcp.settings.host}:{mcp.settings.port}"
136+
)
137+
try:
138+
asyncio.run(server.serve())
139+
except KeyboardInterrupt:
140+
print("\nServer shutting down gracefully...")
141+
except Exception as e:
142+
print(f"Unexpected error: {e}")
143+
sys.exit(1)
144+
finally:
145+
if temp_ca_file:
146+
try:
147+
os.unlink(temp_ca_file.name)
148+
print(f"Cleaned up temp CA file: {temp_ca_file.name}")
149+
except OSError:
150+
pass
151+
print("Thank you for using the Filesystem MCP Server!")
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Directory where this script is located
5+
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
6+
cd "$DIR"
7+
8+
echo "Generating certificates in $DIR..."
9+
10+
# 1. Create CA
11+
openssl req -x509 -new -nodes -newkey rsa:2048 -keyout ca.key -sha256 -days 365 -out ca.crt -subj '/CN=TestCA'
12+
13+
# 2. Create Server Cert
14+
openssl req -new -nodes -newkey rsa:2048 -keyout server.key -out server.csr -subj '/CN=localhost'
15+
# Sign with CA
16+
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -sha256
17+
18+
# 3. Create Client Cert
19+
openssl req -new -nodes -newkey rsa:2048 -keyout client.key -out client.csr -subj '/CN=TestClient'
20+
# Sign with CA
21+
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365 -sha256
22+
23+
# Clean up CSRs and serial file
24+
rm -f server.csr client.csr ca.srl
25+
26+
# 4. Create certificate_config.json
27+
cat <<EOF > certificate_config.json
28+
{
29+
"cert_configs": {
30+
"workload": {
31+
"cert_path": "$DIR/client.crt",
32+
"key_path": "$DIR/client.key"
33+
}
34+
}
35+
}
36+
EOF
37+
38+
echo "Done! Generated:"
39+
echo " - ca.crt, ca.key (CA)"
40+
echo " - server.crt, server.key (Server cert)"
41+
echo " - client.crt, client.key (Client cert)"
42+
echo " - certificate_config.json (Workload config for google-auth)"

0 commit comments

Comments
 (0)