mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
b61bfd1aa6
* fix: validate URL scheme after MCP registry template substitution resolve_install_config() substitutes user-provided values into URL templates via string replacement without validating the resulting URL. Add urlparse check after substitution to reject non-HTTP(S) schemes, preventing SSRF-style redirection through crafted template variables. * fix: address review — reject empty hostname and embedded credentials Add hostname presence check and userinfo rejection after URL scheme validation. Prevents URLs like https:///path (no host) and https://user:pass@host (credential leakage in config). Two new tests.
632 lines
23 KiB
Python
632 lines
23 KiB
Python
"""Tests for MCP Registry client module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.mcp_registry import (
|
|
MCPRegistryClient,
|
|
MCPRegistryError,
|
|
RegistryPackage,
|
|
RegistryRemote,
|
|
RegistryRemoteHeader,
|
|
RegistryRemoteVariable,
|
|
RegistryServer,
|
|
resolve_install_config,
|
|
sanitize_registry_name,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures / helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _registry_response(
|
|
servers: list[dict[str, Any]],
|
|
count: int | None = None,
|
|
next_cursor: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a registry API response dict."""
|
|
return {
|
|
"servers": servers,
|
|
"metadata": {
|
|
"count": count if count is not None else len(servers),
|
|
"nextCursor": next_cursor,
|
|
},
|
|
}
|
|
|
|
|
|
def _server_entry(
|
|
name: str = "io.example/test-server",
|
|
description: str = "A test server",
|
|
version: str = "1.0.0",
|
|
remotes: list[dict[str, Any]] | None = None,
|
|
packages: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a single server entry for the registry response."""
|
|
entry: dict[str, Any] = {
|
|
"server": {
|
|
"name": name,
|
|
"description": description,
|
|
"version": version,
|
|
},
|
|
"_meta": {
|
|
"io.modelcontextprotocol.registry/official": {
|
|
"status": "active",
|
|
"publishedAt": "2026-01-01T00:00:00Z",
|
|
"updatedAt": "2026-01-01T00:00:00Z",
|
|
"isLatest": True,
|
|
}
|
|
},
|
|
}
|
|
if remotes is not None:
|
|
entry["server"]["remotes"] = remotes
|
|
if packages is not None:
|
|
entry["packages"] = packages
|
|
return entry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MCPRegistryClient.search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMCPRegistryClientSearch:
|
|
@pytest.mark.anyio
|
|
async def test_basic_search(self) -> None:
|
|
resp_data = _registry_response(
|
|
[
|
|
_server_entry(name="io.example/foo", description="Foo server"),
|
|
]
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(
|
|
client._client, "get", new=AsyncMock(return_value=mock_response)
|
|
) as mock_get:
|
|
result = await client.search(q="foo", limit=5)
|
|
|
|
mock_get.assert_called_once()
|
|
call_kwargs = mock_get.call_args
|
|
assert call_kwargs[1]["params"]["search"] == "foo"
|
|
assert call_kwargs[1]["params"]["limit"] == "5"
|
|
assert call_kwargs[1]["params"]["latest"] == "true"
|
|
|
|
assert len(result.servers) == 1
|
|
assert result.servers[0].name == "io.example/foo"
|
|
assert result.servers[0].description == "Foo server"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_with_cursor(self) -> None:
|
|
resp_data = _registry_response([], next_cursor="abc123")
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(
|
|
client._client, "get", new=AsyncMock(return_value=mock_response)
|
|
) as mock_get:
|
|
result = await client.search(cursor="prev_cursor")
|
|
|
|
assert mock_get.call_args[1]["params"]["cursor"] == "prev_cursor"
|
|
assert result.next_cursor == "abc123"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_parses_remotes(self) -> None:
|
|
resp_data = _registry_response(
|
|
[
|
|
_server_entry(
|
|
remotes=[
|
|
{
|
|
"type": "streamable-http",
|
|
"url": "https://api.example.com/mcp",
|
|
"headers": [
|
|
{
|
|
"name": "Authorization",
|
|
"description": "Bearer token",
|
|
"isRequired": True,
|
|
"isSecret": True,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
),
|
|
]
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(client._client, "get", new=AsyncMock(return_value=mock_response)):
|
|
result = await client.search()
|
|
|
|
srv = result.servers[0]
|
|
assert len(srv.remotes) == 1
|
|
assert srv.remotes[0].type == "streamable-http"
|
|
assert srv.remotes[0].url == "https://api.example.com/mcp"
|
|
assert srv.remotes[0].headers[0].name == "Authorization"
|
|
assert srv.remotes[0].headers[0].is_secret is True
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_parses_packages(self) -> None:
|
|
resp_data = _registry_response(
|
|
[
|
|
_server_entry(
|
|
packages=[
|
|
{
|
|
"registryType": "npm",
|
|
"identifier": "@example/server",
|
|
"version": "2.0.0",
|
|
"transport": {"type": "stdio"},
|
|
"environmentVariables": [
|
|
{
|
|
"name": "API_KEY",
|
|
"description": "Key",
|
|
"isRequired": True,
|
|
"isSecret": True,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
),
|
|
]
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(client._client, "get", new=AsyncMock(return_value=mock_response)):
|
|
result = await client.search()
|
|
|
|
srv = result.servers[0]
|
|
assert len(srv.packages) == 1
|
|
assert srv.packages[0].registry_type == "npm"
|
|
assert srv.packages[0].identifier == "@example/server"
|
|
assert srv.packages[0].version == "2.0.0"
|
|
assert srv.packages[0].environment_variables[0].name == "API_KEY"
|
|
assert srv.packages[0].environment_variables[0].is_secret is True
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_parses_meta(self) -> None:
|
|
resp_data = _registry_response([_server_entry()])
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(client._client, "get", new=AsyncMock(return_value=mock_response)):
|
|
result = await client.search()
|
|
|
|
assert result.servers[0].meta is not None
|
|
assert result.servers[0].meta.status == "active"
|
|
assert result.servers[0].meta.is_latest is True
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_empty_response(self) -> None:
|
|
resp_data = _registry_response([])
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(client._client, "get", new=AsyncMock(return_value=mock_response)):
|
|
result = await client.search(q="nonexistent")
|
|
|
|
assert result.servers == []
|
|
assert result.total_count == 0
|
|
assert result.next_cursor is None
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_http_error(self) -> None:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
mock_response.text = "Internal Server Error"
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(client._client, "get", new=AsyncMock(return_value=mock_response)):
|
|
with pytest.raises(MCPRegistryError, match="500"):
|
|
await client.search()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_limit_clamped(self) -> None:
|
|
resp_data = _registry_response([])
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(
|
|
client._client, "get", new=AsyncMock(return_value=mock_response)
|
|
) as mock_get:
|
|
await client.search(limit=200)
|
|
|
|
assert mock_get.call_args[1]["params"]["limit"] == "100"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_custom_base_url(self) -> None:
|
|
resp_data = _registry_response([])
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient(base_url="https://custom.registry.example.com") as client:
|
|
with patch.object(
|
|
client._client, "get", new=AsyncMock(return_value=mock_response)
|
|
) as mock_get:
|
|
await client.search()
|
|
|
|
call_url = mock_get.call_args[0][0]
|
|
assert call_url.startswith("https://custom.registry.example.com")
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_defensive_parsing(self) -> None:
|
|
"""Handle unexpected shapes gracefully."""
|
|
resp_data = {
|
|
"servers": [
|
|
{"server": {"name": "valid"}, "_meta": {}},
|
|
"not-a-dict", # should be skipped
|
|
{"server": {}, "_meta": {}}, # missing name → empty string
|
|
],
|
|
"metadata": {},
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = resp_data
|
|
|
|
async with MCPRegistryClient() as client:
|
|
with patch.object(client._client, "get", new=AsyncMock(return_value=mock_response)):
|
|
result = await client.search()
|
|
|
|
# Non-dict entries should be filtered out
|
|
assert len(result.servers) == 2
|
|
assert result.servers[0].name == "valid"
|
|
assert result.servers[1].name == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_install_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveInstallConfig:
|
|
def test_remote_basic(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://api.example.com/mcp",
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "remote", 0)
|
|
assert config["transport"] == "streamable-http"
|
|
assert config["url"] == "https://api.example.com/mcp"
|
|
assert config["registry_name"] == "io.example/test"
|
|
assert config["registry_version"] == "1.0.0"
|
|
|
|
def test_remote_with_headers(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://api.example.com/mcp",
|
|
headers=[
|
|
RegistryRemoteHeader(
|
|
name="Authorization", is_required=True, is_secret=True
|
|
),
|
|
],
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "remote", 0)
|
|
assert "Authorization" in config["headers"]
|
|
|
|
def test_remote_with_variable_substitution(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://{region}.api.example.com/mcp",
|
|
variables={
|
|
"region": RegistryRemoteVariable(
|
|
description="Region",
|
|
is_required=True,
|
|
choices=["us-east", "eu-west"],
|
|
),
|
|
},
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "remote", 0, variables={"region": "us-east"})
|
|
assert config["url"] == "https://us-east.api.example.com/mcp"
|
|
|
|
def test_remote_missing_required_variable(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://{tenant}.example.com/mcp",
|
|
variables={
|
|
"tenant": RegistryRemoteVariable(is_required=True),
|
|
},
|
|
)
|
|
],
|
|
)
|
|
with pytest.raises(MCPRegistryError, match="tenant"):
|
|
resolve_install_config(server, "remote", 0)
|
|
|
|
def test_remote_variable_default(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://{region}.example.com/mcp",
|
|
variables={
|
|
"region": RegistryRemoteVariable(is_required=True, default="us-east"),
|
|
},
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "remote", 0)
|
|
assert config["url"] == "https://us-east.example.com/mcp"
|
|
|
|
def test_remote_variable_substitution_invalid_scheme(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="{scheme}://evil.example.com/mcp",
|
|
variables={
|
|
"scheme": RegistryRemoteVariable(is_required=True),
|
|
},
|
|
)
|
|
],
|
|
)
|
|
with pytest.raises(MCPRegistryError, match="Invalid URL scheme"):
|
|
resolve_install_config(server, "remote", 0, variables={"scheme": "file"})
|
|
|
|
def test_remote_variable_substitution_preserves_valid_scheme(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://{host}.example.com/mcp",
|
|
variables={
|
|
"host": RegistryRemoteVariable(is_required=True),
|
|
},
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "remote", 0, variables={"host": "api"})
|
|
assert config["url"] == "https://api.example.com/mcp"
|
|
|
|
def test_remote_variable_substitution_missing_hostname(self) -> None:
|
|
"""URL like https:///mcp has valid scheme but no hostname."""
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https:///mcp",
|
|
)
|
|
],
|
|
)
|
|
with pytest.raises(MCPRegistryError, match="hostname is missing"):
|
|
resolve_install_config(server, "remote", 0)
|
|
|
|
def test_remote_variable_substitution_embedded_credentials(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[
|
|
RegistryRemote(
|
|
type="streamable-http",
|
|
url="https://{creds}@example.com/mcp",
|
|
variables={
|
|
"creds": RegistryRemoteVariable(is_required=True),
|
|
},
|
|
)
|
|
],
|
|
)
|
|
with pytest.raises(MCPRegistryError, match="embedded credentials"):
|
|
resolve_install_config(server, "remote", 0, variables={"creds": "user:pass"})
|
|
|
|
def test_remote_no_remotes(self) -> None:
|
|
server = RegistryServer(name="io.example/test", version="1.0.0")
|
|
with pytest.raises(MCPRegistryError, match="no remote"):
|
|
resolve_install_config(server, "remote", 0)
|
|
|
|
def test_remote_index_out_of_range(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
remotes=[RegistryRemote(type="streamable-http", url="https://example.com")],
|
|
)
|
|
with pytest.raises(IndexError):
|
|
resolve_install_config(server, "remote", 5)
|
|
|
|
def test_package_npm(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
packages=[
|
|
RegistryPackage(
|
|
registry_type="npm",
|
|
identifier="@example/mcp-server",
|
|
version="2.0.0",
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "package", 0)
|
|
assert config["transport"] == "stdio"
|
|
assert config["command"] == "npx"
|
|
assert config["args"] == ["-y", "@example/mcp-server@2.0.0"]
|
|
|
|
def test_package_pypi(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
packages=[
|
|
RegistryPackage(
|
|
registry_type="pypi",
|
|
identifier="mcp-server-example",
|
|
version="1.5.0",
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "package", 0)
|
|
assert config["transport"] == "stdio"
|
|
assert config["command"] == "uvx"
|
|
assert config["args"] == ["mcp-server-example==1.5.0"]
|
|
|
|
def test_package_with_env_vars(self) -> None:
|
|
from turnstone.core.mcp_registry import RegistryEnvVar
|
|
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
packages=[
|
|
RegistryPackage(
|
|
registry_type="npm",
|
|
identifier="@example/server",
|
|
environment_variables=[
|
|
RegistryEnvVar(name="API_KEY", is_required=True, is_secret=True),
|
|
RegistryEnvVar(name="REGION", default="us-east"),
|
|
],
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "package", 0)
|
|
assert config["env"]["API_KEY"] == ""
|
|
assert config["env"]["REGION"] == "us-east"
|
|
|
|
def test_package_unsupported_type(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
packages=[
|
|
RegistryPackage(registry_type="oci", identifier="docker.io/example/server:1.0")
|
|
],
|
|
)
|
|
with pytest.raises(MCPRegistryError, match="Unsupported package type"):
|
|
resolve_install_config(server, "package", 0)
|
|
|
|
def test_package_no_packages(self) -> None:
|
|
server = RegistryServer(name="io.example/test", version="1.0.0")
|
|
with pytest.raises(MCPRegistryError, match="no installable"):
|
|
resolve_install_config(server, "package", 0)
|
|
|
|
def test_invalid_source(self) -> None:
|
|
server = RegistryServer(name="io.example/test", version="1.0.0")
|
|
with pytest.raises(MCPRegistryError, match="Invalid source"):
|
|
resolve_install_config(server, "invalid", 0)
|
|
|
|
def test_registry_meta_included(self) -> None:
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
description="A test server",
|
|
title="Test Server",
|
|
version="1.0.0",
|
|
website_url="https://example.com",
|
|
remotes=[RegistryRemote(type="streamable-http", url="https://example.com/mcp")],
|
|
)
|
|
config = resolve_install_config(server, "remote", 0)
|
|
meta = config["registry_meta"]
|
|
assert meta["description"] == "A test server"
|
|
assert meta["title"] == "Test Server"
|
|
assert meta["website_url"] == "https://example.com"
|
|
|
|
def test_npm_no_duplicate_version(self) -> None:
|
|
"""Don't add @version if identifier already has it."""
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
packages=[
|
|
RegistryPackage(
|
|
registry_type="npm",
|
|
identifier="@example/mcp-server@2.0.0",
|
|
version="2.0.0",
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "package", 0)
|
|
assert config["args"] == ["-y", "@example/mcp-server@2.0.0"]
|
|
|
|
def test_pypi_no_duplicate_version(self) -> None:
|
|
"""Don't add ==version if identifier already has it."""
|
|
server = RegistryServer(
|
|
name="io.example/test",
|
|
version="1.0.0",
|
|
packages=[
|
|
RegistryPackage(
|
|
registry_type="pypi",
|
|
identifier="mcp-example==1.5.0",
|
|
version="1.5.0",
|
|
)
|
|
],
|
|
)
|
|
config = resolve_install_config(server, "package", 0)
|
|
assert config["args"] == ["mcp-example==1.5.0"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sanitize_registry_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSanitizeRegistryName:
|
|
def test_basic_conversion(self) -> None:
|
|
assert sanitize_registry_name("ai.example/mcp-server") == "ai.example.mcp-server"
|
|
|
|
def test_strips_invalid_chars(self) -> None:
|
|
assert sanitize_registry_name("ai.example/mcp server!") == "ai.example.mcpserver"
|
|
|
|
def test_truncates_to_64(self) -> None:
|
|
long_name = "a" * 100
|
|
assert len(sanitize_registry_name(long_name)) <= 64
|
|
|
|
def test_strips_leading_trailing(self) -> None:
|
|
assert sanitize_registry_name(".leading-dot") == "leading-dot"
|
|
assert sanitize_registry_name("trailing-dot.") == "trailing-dot"
|
|
|
|
def test_empty_after_sanitization(self) -> None:
|
|
with pytest.raises(MCPRegistryError):
|
|
sanitize_registry_name("///")
|
|
|
|
def test_reserved_double_underscore(self) -> None:
|
|
with pytest.raises(MCPRegistryError, match="__"):
|
|
sanitize_registry_name("foo__bar")
|