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
299 lines
10 KiB
Python
299 lines
10 KiB
Python
from pathlib import Path
|
|
from unittest.mock import patch
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
import pytest
|
|
|
|
from fastmcp.cli.install.goose import (
|
|
_build_uvx_command,
|
|
_slugify,
|
|
generate_goose_deeplink,
|
|
goose_command,
|
|
install_goose,
|
|
)
|
|
|
|
|
|
class TestSlugify:
|
|
def test_simple_name(self):
|
|
assert _slugify("My Server") == "my-server"
|
|
|
|
def test_special_characters(self):
|
|
assert _slugify("my_server (v2.0)") == "my-server-v2-0"
|
|
|
|
def test_already_slugified(self):
|
|
assert _slugify("my-server") == "my-server"
|
|
|
|
def test_empty_string(self):
|
|
assert _slugify("") == "fastmcp-server"
|
|
|
|
def test_only_special_chars(self):
|
|
assert _slugify("!!!") == "fastmcp-server"
|
|
|
|
def test_consecutive_hyphens_collapsed(self):
|
|
assert _slugify("a---b") == "a-b"
|
|
|
|
def test_leading_trailing_stripped(self):
|
|
assert _slugify("--hello--") == "hello"
|
|
|
|
|
|
class TestBuildUvxCommand:
|
|
def test_basic(self):
|
|
cmd = _build_uvx_command("server.py")
|
|
assert cmd == ["uvx", "fastmcp", "run", "server.py"]
|
|
|
|
def test_with_python_version(self):
|
|
cmd = _build_uvx_command("server.py", python_version="3.11")
|
|
assert cmd == [
|
|
"uvx",
|
|
"--python",
|
|
"3.11",
|
|
"fastmcp",
|
|
"run",
|
|
"server.py",
|
|
]
|
|
|
|
def test_with_packages(self):
|
|
cmd = _build_uvx_command("server.py", with_packages=["numpy", "pandas"])
|
|
assert "--with" in cmd
|
|
assert "numpy" in cmd
|
|
assert "pandas" in cmd
|
|
|
|
def test_fastmcp_not_in_with(self):
|
|
cmd = _build_uvx_command("server.py", with_packages=["fastmcp", "numpy"])
|
|
# fastmcp is the command itself, so it shouldn't appear in --with
|
|
with_indices = [i for i, v in enumerate(cmd) if v == "--with"]
|
|
with_values = [cmd[i + 1] for i in with_indices]
|
|
assert "fastmcp" not in with_values
|
|
|
|
def test_packages_sorted_and_deduplicated(self):
|
|
cmd = _build_uvx_command(
|
|
"server.py", with_packages=["pandas", "numpy", "pandas"]
|
|
)
|
|
with_indices = [i for i, v in enumerate(cmd) if v == "--with"]
|
|
with_values = [cmd[i + 1] for i in with_indices]
|
|
assert with_values == ["numpy", "pandas"]
|
|
|
|
def test_server_spec_with_object(self):
|
|
cmd = _build_uvx_command("server.py:app")
|
|
assert cmd[-1] == "server.py:app"
|
|
|
|
|
|
class TestGooseDeeplinkGeneration:
|
|
def test_basic_deeplink(self):
|
|
deeplink = generate_goose_deeplink(
|
|
name="test-server",
|
|
command="uvx",
|
|
args=["fastmcp", "run", "server.py"],
|
|
)
|
|
assert deeplink.startswith("goose://extension?")
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert params["cmd"] == ["uvx"]
|
|
assert params["name"] == ["test-server"]
|
|
assert params["id"] == ["test-server"]
|
|
|
|
def test_special_characters_in_name(self):
|
|
deeplink = generate_goose_deeplink(
|
|
name="my server (test)",
|
|
command="uvx",
|
|
args=["fastmcp", "run", "server.py"],
|
|
)
|
|
assert "name=my%20server%20%28test%29" in deeplink
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert params["id"] == ["my-server-test"]
|
|
|
|
def test_url_injection_protection(self):
|
|
deeplink = generate_goose_deeplink(
|
|
name="test&evil=true",
|
|
command="uvx",
|
|
args=["fastmcp", "run", "server.py"],
|
|
)
|
|
assert "name=test%26evil%3Dtrue" in deeplink
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert params["name"] == ["test&evil=true"]
|
|
|
|
def test_dangerous_characters_encoded(self):
|
|
dangerous_names = [
|
|
("test|calc", "test%7Ccalc"),
|
|
("test;calc", "test%3Bcalc"),
|
|
("test<calc", "test%3Ccalc"),
|
|
("test>calc", "test%3Ecalc"),
|
|
("test`calc", "test%60calc"),
|
|
("test$calc", "test%24calc"),
|
|
("test'calc", "test%27calc"),
|
|
('test"calc', "test%22calc"),
|
|
("test calc", "test%20calc"),
|
|
("test#anchor", "test%23anchor"),
|
|
("test?query=val", "test%3Fquery%3Dval"),
|
|
]
|
|
for dangerous_name, expected_encoded in dangerous_names:
|
|
deeplink = generate_goose_deeplink(
|
|
name=dangerous_name, command="uvx", args=["fastmcp", "run", "server.py"]
|
|
)
|
|
assert f"name={expected_encoded}" in deeplink, (
|
|
f"Failed to encode {dangerous_name}"
|
|
)
|
|
|
|
def test_custom_description(self):
|
|
deeplink = generate_goose_deeplink(
|
|
name="my-server",
|
|
command="uvx",
|
|
args=["fastmcp", "run", "server.py"],
|
|
description="My custom MCP server",
|
|
)
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert params["description"] == ["My custom MCP server"]
|
|
|
|
def test_args_with_special_characters(self):
|
|
deeplink = generate_goose_deeplink(
|
|
name="test",
|
|
command="uvx",
|
|
args=[
|
|
"--with",
|
|
"numpy>=1.20",
|
|
"fastmcp",
|
|
"run",
|
|
"server.py:MyApp",
|
|
],
|
|
)
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert "numpy>=1.20" in params["arg"]
|
|
assert "server.py:MyApp" in params["arg"]
|
|
|
|
def test_empty_args(self):
|
|
deeplink = generate_goose_deeplink(name="simple", command="python", args=[])
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert "arg" not in params
|
|
assert params["cmd"] == ["python"]
|
|
|
|
def test_command_with_path(self):
|
|
deeplink = generate_goose_deeplink(
|
|
name="test",
|
|
command="/usr/local/bin/uvx",
|
|
args=["fastmcp", "run", "server.py"],
|
|
)
|
|
parsed = urlparse(deeplink)
|
|
params = parse_qs(parsed.query)
|
|
assert params["cmd"] == ["/usr/local/bin/uvx"]
|
|
|
|
|
|
class TestInstallGoose:
|
|
@patch("fastmcp.cli.install.goose.open_deeplink")
|
|
@patch("fastmcp.cli.install.goose.print")
|
|
def test_success(self, mock_print, mock_open):
|
|
mock_open.return_value = True
|
|
result = install_goose(
|
|
file=Path("/path/to/server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
)
|
|
assert result is True
|
|
mock_open.assert_called_once()
|
|
call_url = mock_open.call_args[0][0]
|
|
assert call_url.startswith("goose://extension?")
|
|
assert mock_open.call_args[1] == {"expected_scheme": "goose"}
|
|
|
|
@patch("fastmcp.cli.install.goose.open_deeplink")
|
|
@patch("fastmcp.cli.install.goose.print")
|
|
def test_success_uses_uvx(self, mock_print, mock_open):
|
|
mock_open.return_value = True
|
|
install_goose(
|
|
file=Path("/path/to/server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
)
|
|
call_url = mock_open.call_args[0][0]
|
|
parsed = urlparse(call_url)
|
|
params = parse_qs(parsed.query)
|
|
assert params["cmd"] == ["uvx"]
|
|
assert "fastmcp" in params["arg"]
|
|
|
|
@patch("fastmcp.cli.install.goose.open_deeplink")
|
|
@patch("fastmcp.cli.install.goose.print")
|
|
def test_failure(self, mock_print, mock_open):
|
|
mock_open.return_value = False
|
|
result = install_goose(
|
|
file=Path("/path/to/server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
)
|
|
assert result is False
|
|
|
|
@patch("fastmcp.cli.install.goose.open_deeplink")
|
|
@patch("fastmcp.cli.install.goose.print")
|
|
def test_with_server_object(self, mock_print, mock_open):
|
|
mock_open.return_value = True
|
|
install_goose(
|
|
file=Path("/path/to/server.py"),
|
|
server_object="app",
|
|
name="test-server",
|
|
)
|
|
call_url = mock_open.call_args[0][0]
|
|
parsed = urlparse(call_url)
|
|
params = parse_qs(parsed.query)
|
|
args = params["arg"]
|
|
assert any("server.py:app" in unquote(a) for a in args)
|
|
|
|
@patch("fastmcp.cli.install.goose.open_deeplink")
|
|
@patch("fastmcp.cli.install.goose.print")
|
|
def test_with_packages(self, mock_print, mock_open):
|
|
mock_open.return_value = True
|
|
install_goose(
|
|
file=Path("/path/to/server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
with_packages=["numpy", "pandas"],
|
|
)
|
|
call_url = mock_open.call_args[0][0]
|
|
parsed = urlparse(call_url)
|
|
params = parse_qs(parsed.query)
|
|
args = params["arg"]
|
|
assert "numpy" in args
|
|
assert "pandas" in args
|
|
|
|
@patch("fastmcp.cli.install.goose.open_deeplink")
|
|
@patch("fastmcp.cli.install.goose.print")
|
|
def test_fallback_message_on_failure(self, mock_print, mock_open):
|
|
mock_open.return_value = False
|
|
install_goose(
|
|
file=Path("/path/to/server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
)
|
|
fallback_calls = [
|
|
call
|
|
for call in mock_print.call_args_list
|
|
if "copy this link" in str(call).lower() or "goose://" in str(call)
|
|
]
|
|
assert len(fallback_calls) > 0
|
|
|
|
|
|
class TestGooseCommand:
|
|
@patch("fastmcp.cli.install.goose.install_goose")
|
|
@patch("fastmcp.cli.install.goose.process_common_args")
|
|
async def test_basic(self, mock_process, mock_install):
|
|
mock_process.return_value = (Path("server.py"), None, "test-server", [], {})
|
|
mock_install.return_value = True
|
|
await goose_command("server.py")
|
|
mock_install.assert_called_once_with(
|
|
file=Path("server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
with_packages=[],
|
|
python_version=None,
|
|
)
|
|
|
|
@patch("fastmcp.cli.install.goose.install_goose")
|
|
@patch("fastmcp.cli.install.goose.process_common_args")
|
|
async def test_failure_exits(self, mock_process, mock_install):
|
|
mock_process.return_value = (Path("server.py"), None, "test-server", [], {})
|
|
mock_install.return_value = False
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
await goose_command("server.py")
|
|
assert exc_info.value.code == 1
|