diff --git a/tests/cli/test_generated_agent_mcp.py b/tests/cli/test_generated_agent_mcp.py new file mode 100644 index 00000000..6bebe4e9 --- /dev/null +++ b/tests/cli/test_generated_agent_mcp.py @@ -0,0 +1,134 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from veadk.cli.generated_agent_codegen import AgentDraft, McpTool +from veadk.cli.generated_agent_mcp import ( + McpDebugConnectionError, + resolve_debug_mcp_endpoints, +) + + +class _FakeMcpToolset: + attempted_urls: list[str] = [] + working_urls: set[str] = set() + + def __init__(self, *, connection_params) -> None: + self.url = connection_params.url + + async def get_tools(self): + self.attempted_urls.append(self.url) + if self.url not in self.working_urls: + raise ConnectionError("authorization=Bearer secret-token") + return [SimpleNamespace(name="sequentialthinking")] + + async def close(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_debug_mcp_appends_standard_mcp_path(monkeypatch) -> None: + _FakeMcpToolset.attempted_urls = [] + _FakeMcpToolset.working_urls = {"https://mcp.example.com/mcp"} + monkeypatch.setattr( + "veadk.cli.generated_agent_mcp.MCPToolset", + _FakeMcpToolset, + ) + draft = AgentDraft( + name="demo-agent", + description="Demo agent", + instruction="Use the tool.", + mcpTools=[ + McpTool( + name="sequentialthinking", + transport="http", + url="https://mcp.example.com", + authToken="secret-token", + ) + ], + ) + + resolved = await resolve_debug_mcp_endpoints(draft) + + assert _FakeMcpToolset.attempted_urls == ["https://mcp.example.com/mcp"] + assert resolved.mcpTools[0].url == "https://mcp.example.com/mcp" + assert draft.mcpTools[0].url == "https://mcp.example.com" + + +@pytest.mark.asyncio +async def test_debug_mcp_keeps_existing_mcp_path(monkeypatch) -> None: + url = "https://mcp.example.com/gateway/mcp/?region=cn-beijing" + _FakeMcpToolset.attempted_urls = [] + _FakeMcpToolset.working_urls = {url} + monkeypatch.setattr( + "veadk.cli.generated_agent_mcp.MCPToolset", + _FakeMcpToolset, + ) + draft = AgentDraft( + name="demo-agent", + description="Demo agent", + instruction="Use the tool.", + mcpTools=[ + McpTool( + name="sequentialthinking", + transport="http", + url=url, + ) + ], + ) + + resolved = await resolve_debug_mcp_endpoints(draft) + + assert _FakeMcpToolset.attempted_urls == [url] + assert resolved.mcpTools[0].url == url + + +@pytest.mark.asyncio +async def test_debug_mcp_reports_discovery_failure_without_credentials( + monkeypatch, +) -> None: + _FakeMcpToolset.attempted_urls = [] + _FakeMcpToolset.working_urls = set() + monkeypatch.setattr( + "veadk.cli.generated_agent_mcp.MCPToolset", + _FakeMcpToolset, + ) + draft = AgentDraft( + name="demo-agent", + description="Demo agent", + instruction="Use the tool.", + mcpTools=[ + McpTool( + name="sequentialthinking", + transport="http", + url="https://mcp.example.com", + authToken="secret-token", + ) + ], + ) + + with pytest.raises(McpDebugConnectionError) as exc_info: + await resolve_debug_mcp_endpoints(draft) + + message = str(exc_info.value) + assert "sequentialthinking" in message + assert "Streamable HTTP" in message + assert "/mcp" in message + assert "secret-token" not in message + assert "Bearer" not in message diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index 254868d3..41f9dba6 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -1574,6 +1574,10 @@ async def _agentkit_proxy(request: Request, path: str): GeneratedAgentDraftRequest, generate_agent_draft, ) + from veadk.cli.generated_agent_mcp import ( + McpDebugConnectionError, + resolve_debug_mcp_endpoints, + ) from veadk.cli.generated_agent_skills import ( _files_from_zip, materialize_selected_skills, @@ -2012,6 +2016,7 @@ async def _generate_project_and_draft_from_request( generated_agent_test_run_allows_local_resources ), ) + draft = await resolve_debug_mcp_endpoints(draft) else: validate_project_policy(draft) project = generate_project_from_draft(draft) @@ -2025,6 +2030,8 @@ async def _generate_project_and_draft_from_request( raise HTTPException(status_code=422, detail=e.errors()) from e except DebugPolicyError as e: raise _http_policy_error(e) from e + except McpDebugConnectionError as e: + raise HTTPException(status_code=422, detail=str(e)) from None async def _generate_project_from_request( data: dict, diff --git a/veadk/cli/generated_agent_mcp.py b/veadk/cli/generated_agent_mcp.py new file mode 100644 index 00000000..225b8e3b --- /dev/null +++ b/veadk/cli/generated_agent_mcp.py @@ -0,0 +1,93 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +"""MCP endpoint discovery for generated-agent debug runs.""" + +from __future__ import annotations + +from contextlib import suppress +from urllib.parse import urlsplit, urlunsplit + +from google.adk.tools.mcp_tool.mcp_session_manager import ( + StreamableHTTPConnectionParams, +) +from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset + +from veadk.cli.generated_agent_codegen import AgentDraft, McpTool + + +class McpDebugConnectionError(ValueError): + """Raised when a configured MCP server cannot expose tools for debugging.""" + + +def _normalize_mcp_endpoint(url: str) -> str: + parsed = urlsplit(url) + if parsed.path.rstrip("/").endswith("/mcp"): + return url + path = f"{parsed.path.rstrip('/')}/mcp" + return urlunsplit(parsed._replace(path=path)) + + +async def _list_mcp_tools(tool: McpTool, url: str) -> None: + headers = None + if tool.authToken.strip(): + headers = {"Authorization": f"Bearer {tool.authToken.strip()}"} + toolset = MCPToolset( + connection_params=StreamableHTTPConnectionParams( + url=url, + headers=headers, + timeout=10, + ) + ) + try: + tools = await toolset.get_tools() + if not tools: + raise ConnectionError("MCP server returned no tools") + finally: + with suppress(Exception): + await toolset.close() + + +async def _resolve_http_mcp_tool(tool: McpTool) -> McpTool: + url = _normalize_mcp_endpoint(tool.url.strip()) + try: + await _list_mcp_tools(tool, url) + except Exception: + pass + else: + return tool.model_copy(update={"url": url}) + + name = tool.name.strip() or "未命名 MCP" + raise McpDebugConnectionError( + f"MCP 工具 `{name}` 连接失败:无法通过 Streamable HTTP 完成工具发现。" + "请确认 URL 指向实际 MCP endpoint(通常以 /mcp 结尾),并检查 Token。" + ) from None + + +async def resolve_debug_mcp_endpoints(draft: AgentDraft) -> AgentDraft: + """Resolve HTTP MCP endpoints recursively without mutating the input draft.""" + tools: list[McpTool] = [] + for tool in draft.mcpTools: + if tool.transport == "http" and tool.url.strip(): + tools.append(await _resolve_http_mcp_tool(tool)) + else: + tools.append(tool) + + sub_agents = [ + await resolve_debug_mcp_endpoints(sub_agent) for sub_agent in draft.subAgents + ] + return draft.model_copy( + deep=True, + update={"mcpTools": tools, "subAgents": sub_agents}, + )