Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions tests/cli/test_generated_agent_mcp.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions veadk/cli/cli_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions veadk/cli/generated_agent_mcp.py
Original file line number Diff line number Diff line change
@@ -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},
)
Loading