Skip to content

Commit c01e538

Browse files
he-yufengwukath
authored andcommitted
fix(mcp): await async MCP header providers
Merge #6105 ## Summary - await async/awaitable MCP header providers in async execution paths. - add unit tests verifying the async header provider functionality. Fixes #6090 Co-authored-by: Kathy Wu <wukathy@google.com> COPYBARA_INTEGRATE_REVIEW=#6105 from he-yufeng:fix/await-async-header-provider 086d352 PiperOrigin-RevId: 941317964
1 parent edb0fd2 commit c01e538

4 files changed

Lines changed: 96 additions & 27 deletions

File tree

src/google/adk/tools/mcp_tool/mcp_tool.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,13 @@
1616

1717
import asyncio
1818
import base64
19+
from collections.abc import Awaitable
1920
import inspect
2021
import logging
2122
from typing import Any
2223
from typing import Callable
23-
from typing import Dict
24-
from typing import List
25-
from typing import Optional
2624
from typing import Protocol
2725
from typing import runtime_checkable
28-
from typing import Union
2926
import warnings
3027

3128
from fastapi.openapi.models import APIKeyIn
@@ -104,9 +101,9 @@ def __call__(
104101
self,
105102
tool_name: str,
106103
*,
107-
callback_context: Optional[CallbackContext] = None,
104+
callback_context: CallbackContext | None = None,
108105
**kwargs: Any,
109-
) -> Optional[ProgressFnT]:
106+
) -> ProgressFnT | None:
110107
"""Create a progress callback for a specific tool.
111108
112109
Args:
@@ -139,15 +136,17 @@ def __init__(
139136
*,
140137
mcp_tool: McpBaseTool,
141138
mcp_session_manager: MCPSessionManager,
142-
auth_scheme: Optional[AuthScheme] = None,
143-
auth_credential: Optional[AuthCredential] = None,
144-
require_confirmation: Union[bool, Callable[..., bool]] = False,
145-
header_provider: Optional[
146-
Callable[[ReadonlyContext], Dict[str, str]]
147-
] = None,
148-
progress_callback: Optional[
149-
Union[ProgressFnT, ProgressCallbackFactory]
150-
] = None,
139+
auth_scheme: AuthScheme | None = None,
140+
auth_credential: AuthCredential | None = None,
141+
require_confirmation: bool | Callable[..., bool] = False,
142+
header_provider: (
143+
Callable[
144+
[ReadonlyContext],
145+
dict[str, str] | Awaitable[dict[str, str]],
146+
]
147+
| None
148+
) = None,
149+
progress_callback: ProgressFnT | ProgressCallbackFactory | None = None,
151150
):
152151
"""Initializes an McpTool.
153152
@@ -225,7 +224,7 @@ def raw_mcp_tool(self) -> McpBaseTool:
225224
return self._mcp_tool
226225

227226
@property
228-
def visibility(self) -> List[str]:
227+
def visibility(self) -> list[str]:
229228
"""Returns the visibility if this MCP tool meta has one."""
230229
meta = getattr(self.raw_mcp_tool, "meta", None)
231230
if not meta or not isinstance(meta, dict):
@@ -238,7 +237,7 @@ def visibility(self) -> List[str]:
238237
return []
239238

240239
@property
241-
def mcp_app_resource_uri(self) -> Optional[str]:
240+
def mcp_app_resource_uri(self) -> str | None:
242241
"""Returns the MCP App UI resource URI if this tool has one.
243242
244243
MCP Apps declare a UI resource via `meta.ui.resourceUri` in the tool
@@ -379,7 +378,7 @@ async def run_async(
379378
@override
380379
async def _run_async_impl(
381380
self, *, args, tool_context: ToolContext, credential: AuthCredential
382-
) -> Dict[str, Any]:
381+
) -> dict[str, Any]:
383382
"""Runs the tool asynchronously.
384383
385384
Args:
@@ -396,8 +395,10 @@ async def _run_async_impl(
396395
dynamic_headers = self._header_provider(
397396
ReadonlyContext(tool_context._invocation_context) # pylint: disable=protected-access
398397
)
398+
if inspect.isawaitable(dynamic_headers):
399+
dynamic_headers = await dynamic_headers
399400

400-
headers: Dict[str, str] = {}
401+
headers: dict[str, str] = {}
401402
if auth_headers:
402403
headers.update(auth_headers)
403404
if dynamic_headers:
@@ -406,7 +407,7 @@ async def _run_async_impl(
406407

407408
# Propagate trace context in the _meta field as sprcified by MCP protocol.
408409
# See https://agentclientprotocol.com/protocol/extensibility#the-meta-field
409-
trace_carrier: Dict[str, str] = {}
410+
trace_carrier: dict[str, str] = {}
410411
propagate.get_global_textmap().inject(carrier=trace_carrier)
411412
meta_trace_context = trace_carrier if trace_carrier else None
412413

@@ -468,15 +469,15 @@ async def _run_async_impl(
468469
)
469470
return result
470471

471-
def _detect_error_in_response(self, response: Any) -> Optional[str]:
472+
def _detect_error_in_response(self, response: Any) -> str | None:
472473
"""Telemetry hook: returns an error type if the response indicates an error."""
473474
if isinstance(response, dict) and response.get("isError"):
474475
return "MCP_TOOL_ERROR"
475476
return None
476477

477478
def _resolve_progress_callback(
478479
self, tool_context: ToolContext
479-
) -> Optional[ProgressFnT]:
480+
) -> ProgressFnT | None:
480481
"""Resolve the progress callback for the current invocation.
481482
482483
If progress_callback is a ProgressCallbackFactory, call it to create
@@ -510,7 +511,7 @@ def _resolve_progress_callback(
510511

511512
async def _get_headers(
512513
self, tool_context: ToolContext, credential: AuthCredential
513-
) -> Optional[dict[str, str]]:
514+
) -> dict[str, str] | None:
514515
"""Extracts authentication headers from credentials.
515516
516517
Args:
@@ -524,7 +525,7 @@ async def _get_headers(
524525
ValueError: If API key authentication is configured for non-header
525526
location.
526527
"""
527-
headers: Optional[dict[str, str]] = None
528+
headers: dict[str, str] | None = None
528529
if credential:
529530
if credential.oauth2:
530531
headers = {"Authorization": f"Bearer {credential.oauth2.access_token}"}

src/google/adk/tools/mcp_tool/mcp_toolset.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import asyncio
1818
import base64
19+
import inspect
1920
import logging
2021
import sys
2122
from typing import Any
@@ -107,9 +108,13 @@ def __init__(
107108
auth_scheme: Optional[AuthScheme] = None,
108109
auth_credential: Optional[AuthCredential] = None,
109110
require_confirmation: Union[bool, Callable[..., bool]] = False,
110-
header_provider: Optional[
111-
Callable[[ReadonlyContext], Dict[str, str]]
112-
] = None,
111+
header_provider: (
112+
Callable[
113+
[ReadonlyContext],
114+
dict[str, str] | Awaitable[dict[str, str]],
115+
]
116+
| None
117+
) = None,
113118
progress_callback: Optional[
114119
Union[ProgressFnT, ProgressCallbackFactory]
115120
] = None,
@@ -293,6 +298,8 @@ async def _execute_with_session(
293298
# Add headers from header_provider if available
294299
if self._header_provider and readonly_context:
295300
provider_headers = self._header_provider(readonly_context)
301+
if inspect.isawaitable(provider_headers):
302+
provider_headers = await provider_headers
296303
if provider_headers:
297304
headers.update(provider_headers)
298305

tests/unittests/tools/mcp_tool/test_mcp_tool.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,41 @@ async def test_run_async_impl_with_header_provider_no_auth(self):
953953
"test_tool", arguments=args, progress_callback=None, meta=None
954954
)
955955

956+
@pytest.mark.asyncio
957+
async def test_run_async_impl_with_async_header_provider_no_auth(self):
958+
"""Test running tool with an async header_provider and no authentication."""
959+
expected_headers = {"X-Tenant-ID": "test-tenant"}
960+
961+
async def header_provider(_context):
962+
return expected_headers
963+
964+
tool = MCPTool(
965+
mcp_tool=self.mock_mcp_tool,
966+
mcp_session_manager=self.mock_session_manager,
967+
header_provider=header_provider,
968+
)
969+
970+
mcp_response = CallToolResult(
971+
content=[TextContent(type="text", text="response text")]
972+
)
973+
self.mock_session.call_tool = AsyncMock(return_value=mcp_response)
974+
975+
tool_context = Mock(spec=ToolContext)
976+
tool_context._invocation_context = Mock()
977+
args = {"param1": "test_value"}
978+
979+
result = await tool._run_async_impl(
980+
args=args, tool_context=tool_context, credential=None
981+
)
982+
983+
assert result == mcp_response.model_dump(exclude_none=True, mode="json")
984+
self.mock_session_manager.create_session.assert_called_once_with(
985+
headers=expected_headers
986+
)
987+
self.mock_session.call_tool.assert_called_once_with(
988+
"test_tool", arguments=args, progress_callback=None, meta=None
989+
)
990+
956991
@pytest.mark.asyncio
957992
async def test_run_async_impl_with_header_provider_and_oauth2(self):
958993
"""Test running tool with header_provider and OAuth2 auth."""

tests/unittests/tools/mcp_tool/test_mcp_toolset.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,32 @@ async def test_get_tools_with_header_provider(self):
303303
headers=expected_headers
304304
)
305305

306+
@pytest.mark.asyncio
307+
async def test_get_tools_with_async_header_provider(self):
308+
"""Test get_tools with an async header_provider."""
309+
mock_tools = [MockMCPTool("tool1"), MockMCPTool("tool2")]
310+
self.mock_session.list_tools = AsyncMock(
311+
return_value=MockListToolsResult(mock_tools)
312+
)
313+
mock_readonly_context = Mock(spec=ReadonlyContext)
314+
expected_headers = {"X-Tenant-ID": "test-tenant"}
315+
316+
async def header_provider(_context):
317+
return expected_headers
318+
319+
toolset = McpToolset(
320+
connection_params=self.mock_stdio_params,
321+
header_provider=header_provider,
322+
)
323+
toolset._mcp_session_manager = self.mock_session_manager
324+
325+
tools = await toolset.get_tools(readonly_context=mock_readonly_context)
326+
327+
assert len(tools) == 2
328+
self.mock_session_manager.create_session.assert_called_once_with(
329+
headers=expected_headers
330+
)
331+
306332
@pytest.mark.asyncio
307333
async def test_close_success(self):
308334
"""Test successful cleanup."""

0 commit comments

Comments
 (0)