Files
wehub-resource-sync 60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:59 +08:00

583 lines
21 KiB
Python

"""Tests for fastmcp list and fastmcp call CLI commands."""
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import mcp_types
import pytest
from fastmcp import FastMCP
from fastmcp.cli import client as client_module
from fastmcp.cli.client import (
Client,
_build_client,
_build_stdio_from_command,
_format_call_result_text,
_is_http_target,
_sanitize_untrusted_text,
call_command,
coerce_value,
format_tool_signature,
list_command,
parse_tool_arguments,
resolve_server_spec,
)
from fastmcp.client.client import CallToolResult
from fastmcp.client.transports.stdio import StdioTransport
# ---------------------------------------------------------------------------
# coerce_value
# ---------------------------------------------------------------------------
class TestCoerceValue:
def test_integer(self):
assert coerce_value("42", {"type": "integer"}) == 42
def test_integer_negative(self):
assert coerce_value("-7", {"type": "integer"}) == -7
def test_integer_invalid(self):
with pytest.raises(ValueError, match="Expected integer"):
coerce_value("abc", {"type": "integer"})
def test_number(self):
assert coerce_value("3.14", {"type": "number"}) == 3.14
def test_number_integer_value(self):
assert coerce_value("5", {"type": "number"}) == 5.0
def test_number_invalid(self):
with pytest.raises(ValueError, match="Expected number"):
coerce_value("xyz", {"type": "number"})
def test_boolean_true_variants(self):
for val in ("true", "True", "TRUE", "1", "yes"):
assert coerce_value(val, {"type": "boolean"}) is True
def test_boolean_false_variants(self):
for val in ("false", "False", "FALSE", "0", "no"):
assert coerce_value(val, {"type": "boolean"}) is False
def test_boolean_invalid(self):
with pytest.raises(ValueError, match="Expected boolean"):
coerce_value("maybe", {"type": "boolean"})
def test_array(self):
assert coerce_value("[1, 2, 3]", {"type": "array"}) == [1, 2, 3]
def test_array_invalid(self):
with pytest.raises(ValueError, match="Expected JSON array"):
coerce_value("not-json", {"type": "array"})
def test_object(self):
assert coerce_value('{"a": 1}', {"type": "object"}) == {"a": 1}
def test_string(self):
assert coerce_value("hello", {"type": "string"}) == "hello"
def test_string_default(self):
"""Unknown or missing type treats value as string."""
assert coerce_value("hello", {}) == "hello"
def test_string_preserves_numeric_looking_values(self):
assert coerce_value("42", {"type": "string"}) == "42"
# ---------------------------------------------------------------------------
# parse_tool_arguments
# ---------------------------------------------------------------------------
class TestParseToolArguments:
SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"verbose": {"type": "boolean"},
},
"required": ["query"],
}
def test_basic_key_value(self):
result = parse_tool_arguments(("query=hello", "limit=10"), None, self.SCHEMA)
assert result == {"query": "hello", "limit": 10}
def test_input_json_only(self):
result = parse_tool_arguments((), '{"query": "hello", "limit": 5}', self.SCHEMA)
assert result == {"query": "hello", "limit": 5}
def test_key_value_overrides_input_json(self):
result = parse_tool_arguments(
("limit=20",), '{"query": "hello", "limit": 5}', self.SCHEMA
)
assert result == {"query": "hello", "limit": 20}
def test_value_containing_equals(self):
result = parse_tool_arguments(("query=a=b=c",), None, self.SCHEMA)
assert result == {"query": "a=b=c"}
def test_invalid_arg_format_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments(("noequalssign",), None, self.SCHEMA)
def test_invalid_input_json_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments((), "not-valid-json", self.SCHEMA)
def test_input_json_non_object_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments((), "[1,2,3]", self.SCHEMA)
def test_single_json_object_as_positional(self):
result = parse_tool_arguments(
('{"query": "hello", "limit": 5}',), None, self.SCHEMA
)
assert result == {"query": "hello", "limit": 5}
def test_json_positional_ignored_when_input_json_set(self):
"""When --input-json is already provided, a JSON positional arg is not special."""
with pytest.raises(SystemExit):
parse_tool_arguments(('{"limit": 99}',), '{"query": "hello"}', self.SCHEMA)
def test_coercion_error_exits(self):
with pytest.raises(SystemExit):
parse_tool_arguments(("limit=abc",), None, self.SCHEMA)
# ---------------------------------------------------------------------------
# format_tool_signature
# ---------------------------------------------------------------------------
class TestFormatToolSignature:
def _make_tool(
self,
name: str = "my_tool",
properties: dict[str, Any] | None = None,
required: list[str] | None = None,
output_schema: dict[str, Any] | None = None,
description: str | None = None,
) -> mcp_types.Tool:
input_schema: dict[str, Any] = {"type": "object"}
if properties is not None:
input_schema["properties"] = properties
if required is not None:
input_schema["required"] = required
return mcp_types.Tool(
name=name,
description=description,
input_schema=input_schema,
output_schema=output_schema,
)
def test_no_params(self):
tool = self._make_tool()
assert format_tool_signature(tool) == "my_tool()"
def test_required_param(self):
tool = self._make_tool(
properties={"query": {"type": "string"}},
required=["query"],
)
assert format_tool_signature(tool) == "my_tool(query: str)"
def test_optional_param_with_default(self):
tool = self._make_tool(
properties={"limit": {"type": "integer", "default": 10}},
)
assert format_tool_signature(tool) == "my_tool(limit: int = 10)"
def test_optional_param_without_default(self):
tool = self._make_tool(
properties={"limit": {"type": "integer"}},
)
assert format_tool_signature(tool) == "my_tool(limit: int = ...)"
def test_mixed_required_and_optional(self):
tool = self._make_tool(
properties={
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
},
required=["query"],
)
sig = format_tool_signature(tool)
assert sig == "my_tool(query: str, limit: int = 10)"
def test_with_output_schema(self):
tool = self._make_tool(
properties={"q": {"type": "string"}},
required=["q"],
output_schema={"type": "object"},
)
assert format_tool_signature(tool) == "my_tool(q: str) -> dict"
def test_anyof_type(self):
tool = self._make_tool(
properties={"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
required=["value"],
)
assert format_tool_signature(tool) == "my_tool(value: str | int)"
# ---------------------------------------------------------------------------
# resolve_server_spec
# ---------------------------------------------------------------------------
class TestResolveServerSpec:
def test_http_url(self):
assert (
resolve_server_spec("http://localhost:8000/mcp")
== "http://localhost:8000/mcp"
)
def test_https_url(self):
assert (
resolve_server_spec("https://example.com/mcp") == "https://example.com/mcp"
)
def test_python_file_existing(self, tmp_path: Path):
py_file = tmp_path / "server.py"
py_file.write_text("# empty")
result = resolve_server_spec(str(py_file))
assert isinstance(result, StdioTransport)
assert result.command == "fastmcp"
assert result.args == ["run", str(py_file.resolve()), "--no-banner"]
def test_json_mcp_config(self, tmp_path: Path):
config_file = tmp_path / "mcp.json"
config = {"mcpServers": {"test": {"url": "http://localhost:8000"}}}
config_file.write_text(json.dumps(config))
result = resolve_server_spec(str(config_file))
assert isinstance(result, dict)
assert "mcpServers" in result
def test_json_fastmcp_config_exits(self, tmp_path: Path):
config_file = tmp_path / "fastmcp.json"
config_file.write_text(json.dumps({"source": {"type": "file"}}))
with pytest.raises(SystemExit):
resolve_server_spec(str(config_file))
def test_json_not_found_exits(self, tmp_path: Path):
with pytest.raises(SystemExit):
resolve_server_spec(str(tmp_path / "nonexistent.json"))
def test_directory_exits(self, tmp_path: Path):
"""Directories should not be treated as file paths."""
with pytest.raises(SystemExit):
resolve_server_spec(str(tmp_path))
def test_unrecognised_exits(self):
with pytest.raises(SystemExit):
resolve_server_spec("some_random_thing")
def test_command_returns_stdio_transport(self):
result = resolve_server_spec(None, command="npx -y @mcp/server")
assert isinstance(result, StdioTransport)
assert result.command == "npx"
assert result.args == ["-y", "@mcp/server"]
def test_command_single_word(self):
result = resolve_server_spec(None, command="myserver")
assert isinstance(result, StdioTransport)
assert result.command == "myserver"
assert result.args == []
def test_server_spec_and_command_exits(self):
with pytest.raises(SystemExit):
resolve_server_spec("http://localhost:8000", command="npx server")
def test_neither_server_spec_nor_command_exits(self):
with pytest.raises(SystemExit):
resolve_server_spec(None)
def test_transport_sse_rewrites_url(self):
result = resolve_server_spec("http://localhost:8000/mcp", transport="sse")
assert result == "http://localhost:8000/mcp/sse"
def test_transport_sse_no_duplicate_suffix(self):
result = resolve_server_spec("http://localhost:8000/sse", transport="sse")
assert result == "http://localhost:8000/sse"
def test_transport_sse_trailing_slash(self):
result = resolve_server_spec("http://localhost:8000/mcp/", transport="sse")
assert result == "http://localhost:8000/mcp/sse"
def test_transport_http_leaves_url_unchanged(self):
result = resolve_server_spec("http://localhost:8000/mcp", transport="http")
assert result == "http://localhost:8000/mcp"
# ---------------------------------------------------------------------------
# _build_stdio_from_command
# ---------------------------------------------------------------------------
class TestBuildStdioFromCommand:
def test_simple_command(self):
transport = _build_stdio_from_command("uvx my-server")
assert transport.command == "uvx"
assert transport.args == ["my-server"]
def test_quoted_args(self):
transport = _build_stdio_from_command("npx -y '@scope/server'")
assert transport.command == "npx"
assert transport.args == ["-y", "@scope/server"]
def test_empty_command_exits(self):
with pytest.raises(SystemExit):
_build_stdio_from_command("")
def test_invalid_shell_syntax_exits(self):
with pytest.raises(SystemExit):
_build_stdio_from_command("npx 'unterminated")
# ---------------------------------------------------------------------------
# _is_http_target
# ---------------------------------------------------------------------------
class TestIsHttpTarget:
def test_http_url(self):
assert _is_http_target("http://localhost:8000") is True
def test_https_url(self):
assert _is_http_target("https://example.com/mcp") is True
def test_file_path(self):
assert _is_http_target("/path/to/server.py") is False
def test_stdio_transport(self):
assert _is_http_target(StdioTransport(command="npx", args=[])) is False
def test_mcp_config_dict(self):
"""MCPConfig dicts are not HTTP targets — auth is per-server internally."""
assert _is_http_target({"mcpServers": {}}) is False
# ---------------------------------------------------------------------------
# _build_client
# ---------------------------------------------------------------------------
class TestBuildClient:
def test_http_target_gets_oauth_by_default(self):
client = _build_client("http://localhost:8000/mcp")
# OAuth is applied during Client init via _set_auth
assert client.transport.auth is not None
def test_stdio_target_no_auth(self):
transport = StdioTransport(command="npx", args=["-y", "@mcp/server"])
client = _build_client(transport)
# Stdio transports don't support auth — no auth should be set
assert not hasattr(client.transport, "auth") or client.transport.auth is None
def test_explicit_auth_none_disables_oauth(self):
client = _build_client("http://localhost:8000/mcp", auth="none")
# "none" explicitly disables auth, even for HTTP targets
assert client.transport.auth is None
def test_mcp_config_no_auth(self):
"""MCPConfig dicts handle auth per-server; no top-level auth applied."""
client = _build_client({"mcpServers": {"test": {"url": "http://localhost"}}})
# MCPConfigTransport doesn't support _set_auth — no crash means success
assert client.transport is not None
# ---------------------------------------------------------------------------
# Integration tests — invoke actual CLI commands via monkeypatched _build_client
# ---------------------------------------------------------------------------
def _build_test_server() -> FastMCP:
"""Create a minimal FastMCP server for integration tests."""
server = FastMCP("TestServer")
@server.tool
def greet(name: str) -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
@server.tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@server.resource("test://greeting")
def greeting_resource() -> str:
"""A static greeting resource."""
return "Hello from resource!"
@server.prompt
def ask(topic: str) -> str:
"""Ask about a topic."""
return f"Tell me about {topic}"
return server
@pytest.fixture()
def _patch_client():
"""Patch resolve_server_spec and _build_client so CLI commands use the
in-process test server without needing a real transport."""
server = _build_test_server()
def fake_resolve(server_spec: Any, **kwargs: Any) -> str:
return "fake"
def fake_build_client(resolved: Any, **kwargs: Any) -> Client:
return Client(server)
with (
patch.object(client_module, "resolve_server_spec", side_effect=fake_resolve),
patch.object(client_module, "_build_client", side_effect=fake_build_client),
):
yield
class TestListCommandCLI:
@pytest.mark.usefixtures("_patch_client")
async def test_list_tools(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server")
captured = capsys.readouterr()
assert "greet" in captured.out
assert "add" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_list_json(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server", json_output=True)
captured = capsys.readouterr()
data = json.loads(captured.out)
names = {t["name"] for t in data["tools"]}
assert "greet" in names
assert "add" in names
@pytest.mark.usefixtures("_patch_client")
async def test_list_resources(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server", resources=True)
captured = capsys.readouterr()
assert "test://greeting" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_list_prompts(self, capsys: pytest.CaptureFixture[str]):
await list_command("fake://server", prompts=True)
captured = capsys.readouterr()
assert "ask" in captured.out
class TestCallCommandCLI:
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "greet", "name=World")
captured = capsys.readouterr()
assert "Hello, World!" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool_json(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "greet", "name=World", json_output=True)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data["is_error"] is False
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool_not_found(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "nonexistent")
@pytest.mark.usefixtures("_patch_client")
async def test_call_tool_missing_args(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "greet")
@pytest.mark.usefixtures("_patch_client")
async def test_call_resource_by_uri(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "test://greeting")
captured = capsys.readouterr()
assert "Hello from resource!" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_call_resource_json(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "test://greeting", json_output=True)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert isinstance(data, list)
assert data[0]["text"] == "Hello from resource!"
@pytest.mark.usefixtures("_patch_client")
async def test_call_prompt(self, capsys: pytest.CaptureFixture[str]):
await call_command("fake://server", "ask", "topic=Python", prompt=True)
captured = capsys.readouterr()
assert "Python" in captured.out
@pytest.mark.usefixtures("_patch_client")
async def test_call_prompt_json(self, capsys: pytest.CaptureFixture[str]):
await call_command(
"fake://server", "ask", "topic=Python", prompt=True, json_output=True
)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert "messages" in data
@pytest.mark.usefixtures("_patch_client")
async def test_call_prompt_not_found(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "nonexistent", prompt=True)
async def test_call_missing_target(self):
with pytest.raises(SystemExit):
await call_command("fake://server", "")
# ---------------------------------------------------------------------------
# Structured content serialization
# ---------------------------------------------------------------------------
class TestFormatCallResult:
def test_structured_content_uses_dict_not_data(
self, capsys: pytest.CaptureFixture[str]
):
"""structured_content (raw dict) is used for display, not data (which may
be a non-serializable dataclass)."""
result = CallToolResult(
content=[mcp_types.TextContent(type="text", text="ok")],
structured_content={"key": "value"},
meta=None,
data=object(), # non-serializable on purpose
is_error=False,
)
# Should not raise — uses structured_content, not data
_format_call_result_text(result)
captured = capsys.readouterr()
assert "value" in captured.out
def test_escapes_rich_markup_and_control_chars(
self, capsys: pytest.CaptureFixture[str]
):
result = CallToolResult(
content=[mcp_types.TextContent(type="text", text="[red]x[/red]\x1b[2J")],
structured_content=None,
meta=None,
data=None,
is_error=False,
)
_format_call_result_text(result)
captured = capsys.readouterr()
assert "[red]x[/red]" in captured.out
assert "\\x1b" in captured.out
assert "\x1b" not in captured.out
class TestSanitizeUntrustedText:
def test_sanitize_untrusted_text(self):
value = "[bold]hello[/bold]\x07"
sanitized = _sanitize_untrusted_text(value)
assert sanitized == "\\[bold]hello\\[/bold]\\x07"