feat: Support MCP prompts

Add support for MCP prompts via the McpInstructionProvider class, which can be specified as an agent's instruction.

Co-authored-by: Kathy Wu <wukathy@google.com>
PiperOrigin-RevId: 828166051
This commit is contained in:
Kathy Wu
2025-11-04 15:48:21 -08:00
committed by Copybara-Service
parent 11571c37ab
commit 88032cf5c5
5 changed files with 351 additions and 9 deletions
+11 -9
View File
@@ -16,25 +16,27 @@
import os
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.mcp_instruction_provider import McpInstructionProvider
from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset
_allowed_path = os.path.dirname(os.path.abspath(__file__))
connection_params = SseConnectionParams(
url='http://localhost:3000/sse',
headers={'Accept': 'text/event-stream'},
)
root_agent = LlmAgent(
model='gemini-2.0-flash',
name='enterprise_assistant',
instruction=f"""\
Help user accessing their file systems.
Allowed directory: {_allowed_path}
""",
instruction=McpInstructionProvider(
connection_params=connection_params,
prompt_name='file_system_prompt',
),
tools=[
MCPToolset(
connection_params=SseConnectionParams(
url='http://localhost:3000/sse',
headers={'Accept': 'text/event-stream'},
),
connection_params=connection_params,
# don't want agent to do write operation
# you can also do below
# tool_filter=lambda tool, ctx=None: tool.name
@@ -45,6 +45,13 @@ def get_cwd() -> str:
return str(Path.cwd())
# Add a prompt for accessing file systems
@mcp.prompt(name="file_system_prompt")
def file_system_prompt() -> str:
return f"""\
Help the user access their file systems."""
# Graceful shutdown handler
async def shutdown(signal, loop):
"""Cleanup tasks tied to the service's shutdown."""
+16
View File
@@ -12,6 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import sys
from .base_agent import BaseAgent
from .invocation_context import InvocationContext
from .live_request_queue import LiveRequest
@@ -35,3 +38,16 @@ __all__ = [
'LiveRequestQueue',
'RunConfig',
]
if sys.version_info < (3, 10):
logger = logging.getLogger('google_adk.' + __name__)
logger.warning(
'MCP requires Python 3.10 or above. Please upgrade your Python'
' version in order to use it.'
)
else:
from .mcp_instruction_provider import McpInstructionProvider
__all__.extend([
'McpInstructionProvider',
])
@@ -0,0 +1,107 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Provides instructions to an agent by fetching prompts from an MCP server."""
from __future__ import annotations
import logging
import sys
from typing import Any
from typing import Dict
from typing import TextIO
from .llm_agent import InstructionProvider
from .readonly_context import ReadonlyContext
# Attempt to import MCP Session Manager from the MCP library, and hints user to
# upgrade their Python version to 3.10 if it fails.
try:
from mcp import types
from ..tools.mcp_tool.mcp_session_manager import MCPSessionManager
except ImportError as e:
if sys.version_info < (3, 10):
raise ImportError(
"MCP Session Manager requires Python 3.10 or above. Please upgrade"
" your Python version."
) from e
else:
raise e
class McpInstructionProvider(InstructionProvider):
"""Fetches agent instructions from an MCP server."""
def __init__(
self,
connection_params: Any,
prompt_name: str,
errlog: TextIO = sys.stderr,
):
"""Initializes the McpInstructionProvider.
Args:
connection_params: Parameters for connecting to the MCP server.
prompt_name: The name of the MCP Prompt to fetch.
errlog: TextIO stream for error logging.
"""
self._connection_params = connection_params
self._errlog = errlog or logging.getLogger(__name__)
self._mcp_session_manager = MCPSessionManager(
connection_params=self._connection_params,
errlog=self._errlog,
)
self.prompt_name = prompt_name
async def __call__(self, context: ReadonlyContext) -> str:
"""Fetches the instruction from the MCP server.
Args:
context: The read-only context of the agent.
Returns:
The instruction string.
"""
session = await self._mcp_session_manager.create_session()
# Fetch prompt definition to get the required argument names
prompt_definitions = await session.list_prompts()
prompt_definition = next(
(p for p in prompt_definitions.prompts if p.name == self.prompt_name),
None,
)
# Fetch arguments from context state if the prompt requires them
prompt_args: Dict[str, Any] = {}
if prompt_definition and prompt_definition.arguments:
arg_names = {arg.name for arg in prompt_definition.arguments}
prompt_args = {
k: v for k, v in (context.state or {}).items() if k in arg_names
}
# Fetch the specific prompt by name with arguments from context state
prompt_result: types.GetPromptResult = await session.get_prompt(
self.prompt_name, arguments=prompt_args
)
if prompt_result and prompt_result.messages:
# Concatenate content of all messages to form the instruction.
instruction = "".join(
message.content.text
for message in prompt_result.messages
if message.content.type == "text"
)
return instruction
else:
raise ValueError(f"Failed to load MCP prompt '{self.prompt_name}'.")
@@ -0,0 +1,210 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for McpInstructionProvider."""
import sys
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from google.adk.agents.readonly_context import ReadonlyContext
import pytest
# Skip all tests in this module if Python version is less than 3.10
pytestmark = pytest.mark.skipif(
sys.version_info < (3, 10),
reason="MCP instruction provider requires Python 3.10+",
)
# Import dependencies with version checking
try:
from google.adk.agents.mcp_instruction_provider import McpInstructionProvider
except ImportError as e:
if sys.version_info < (3, 10):
# Create dummy classes to prevent NameError during test collection
# Tests will be skipped anyway due to pytestmark
class DummyClass:
pass
McpInstructionProvider = DummyClass
else:
raise e
class TestMcpInstructionProvider:
"""Unit tests for McpInstructionProvider."""
def setup_method(self):
"""Sets up the test environment."""
self.connection_params = {"host": "localhost", "port": 8000}
self.prompt_name = "test_prompt"
self.mock_mcp_session_manager_cls = patch(
"google.adk.agents.mcp_instruction_provider.MCPSessionManager"
).start()
self.mock_mcp_session_manager = (
self.mock_mcp_session_manager_cls.return_value
)
self.mock_session = MagicMock()
self.mock_session.list_prompts = AsyncMock()
self.mock_session.get_prompt = AsyncMock()
self.mock_mcp_session_manager.create_session = AsyncMock(
return_value=self.mock_session
)
self.provider = McpInstructionProvider(
self.connection_params, self.prompt_name
)
@pytest.mark.asyncio
async def test_call_success_no_args(self):
"""Tests __call__ with a prompt that has no arguments."""
mock_prompt = MagicMock()
mock_prompt.name = self.prompt_name
mock_prompt.arguments = None
self.mock_session.list_prompts.return_value = MagicMock(
prompts=[mock_prompt]
)
mock_msg1 = MagicMock()
mock_msg1.content.type = "text"
mock_msg1.content.text = "instruction part 1. "
mock_msg2 = MagicMock()
mock_msg2.content.type = "text"
mock_msg2.content.text = "instruction part 2"
self.mock_session.get_prompt.return_value = MagicMock(
messages=[mock_msg1, mock_msg2]
)
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {}
context = ReadonlyContext(mock_invocation_context)
# Call
instruction = await self.provider(context)
# Assert
assert instruction == "instruction part 1. instruction part 2"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@pytest.mark.asyncio
async def test_call_success_with_args(self):
"""Tests __call__ with a prompt that has arguments."""
mock_arg1 = MagicMock()
mock_arg1.name = "arg1"
mock_prompt = MagicMock()
mock_prompt.name = self.prompt_name
mock_prompt.arguments = [mock_arg1]
self.mock_session.list_prompts.return_value = MagicMock(
prompts=[mock_prompt]
)
mock_msg = MagicMock()
mock_msg.content.type = "text"
mock_msg.content.text = "instruction with arg1"
self.mock_session.get_prompt.return_value = MagicMock(messages=[mock_msg])
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {"arg1": "value1", "arg2": "value2"}
context = ReadonlyContext(mock_invocation_context)
instruction = await self.provider(context)
assert instruction == "instruction with arg1"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={"arg1": "value1"}
)
@pytest.mark.asyncio
async def test_call_prompt_not_found_in_list_prompts(self):
"""Tests __call__ when list_prompts doesn't return the prompt."""
self.mock_session.list_prompts.return_value = MagicMock(prompts=[])
mock_msg = MagicMock()
mock_msg.content.type = "text"
mock_msg.content.text = "instruction"
self.mock_session.get_prompt.return_value = MagicMock(messages=[mock_msg])
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {"arg1": "value1"}
context = ReadonlyContext(mock_invocation_context)
instruction = await self.provider(context)
assert instruction == "instruction"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@pytest.mark.asyncio
async def test_call_get_prompt_returns_no_messages(self):
"""Tests __call__ when get_prompt returns no messages."""
# Setup mocks
self.mock_session.list_prompts.return_value = MagicMock(prompts=[])
self.mock_session.get_prompt.return_value = MagicMock(messages=[])
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {}
context = ReadonlyContext(mock_invocation_context)
# Call and assert
with pytest.raises(
ValueError, match="Failed to load MCP prompt 'test_prompt'."
):
await self.provider(context)
# Assert
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)
@pytest.mark.asyncio
async def test_call_ignore_non_text_messages(self):
"""Tests __call__ ignores non-text messages."""
# Setup mocks
mock_prompt = MagicMock()
mock_prompt.name = self.prompt_name
mock_prompt.arguments = None
self.mock_session.list_prompts.return_value = MagicMock(
prompts=[mock_prompt]
)
mock_msg1 = MagicMock()
mock_msg1.content.type = "text"
mock_msg1.content.text = "instruction part 1. "
mock_msg2 = MagicMock()
mock_msg2.content.type = "image"
mock_msg2.content.text = "ignored"
mock_msg3 = MagicMock()
mock_msg3.content.type = "text"
mock_msg3.content.text = "instruction part 2"
self.mock_session.get_prompt.return_value = MagicMock(
messages=[mock_msg1, mock_msg2, mock_msg3]
)
mock_invocation_context = MagicMock()
mock_invocation_context.session.state = {}
context = ReadonlyContext(mock_invocation_context)
# Call
instruction = await self.provider(context)
# Assert
assert instruction == "instruction part 1. instruction part 2"
self.mock_session.get_prompt.assert_called_once_with(
self.prompt_name, arguments={}
)