184 lines
6.8 KiB
Python
184 lines
6.8 KiB
Python
"""Tests for resolve_tool_transforms in mcp_proxy.service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from mcp_proxy.service import McpConfig, McpService
|
|
|
|
|
|
def _make_cfg(tmp_path, tools: list[str], toolsets: dict, name: str | None = None) -> McpService:
|
|
"""Write mcp.json + mcp-tools.generated.json and return an McpService."""
|
|
mcp_json: dict = {
|
|
"run": {"command": "python", "args": ["-m", "fake"]},
|
|
"toolsets": toolsets,
|
|
}
|
|
if name is not None:
|
|
mcp_json["name"] = name
|
|
(tmp_path / "mcp.json").write_text(json.dumps(mcp_json))
|
|
tools_data = {
|
|
"schema_version": "v1",
|
|
"tools": [{"name": t, "description": "", "inputSchema": {}} for t in tools],
|
|
"toolsets": toolsets,
|
|
}
|
|
(tmp_path / "mcp-tools.generated.json").write_text(json.dumps(tools_data))
|
|
return McpService(McpConfig.from_mcp_json(tmp_path, mcp_json))
|
|
|
|
|
|
def _enabled_tools(transforms: dict) -> dict[str, str]:
|
|
"""Return {original_name: new_name} for enabled tools only."""
|
|
return {name: t.name for name, t in transforms.items() if t.enabled and t.name}
|
|
|
|
|
|
def _disabled_tools(transforms: dict) -> set[str]:
|
|
"""Return set of original names for disabled tools."""
|
|
return {name for name, t in transforms.items() if not t.enabled}
|
|
|
|
|
|
TOOLS = ["echo", "readFile", "writeFile", "bash"]
|
|
TOOLSETS = {
|
|
"read": ["echo", "readFile"],
|
|
"write": ["writeFile", "bash"],
|
|
}
|
|
|
|
|
|
def test_empty_tool_sets_exposes_all_tools(tmp_path):
|
|
"""An empty filter (or unset env var) means 'expose every introspected tool'."""
|
|
cfg = _make_cfg(tmp_path, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms([])
|
|
assert set(_enabled_tools(result).keys()) == set(TOOLS)
|
|
assert _disabled_tools(result) == set()
|
|
|
|
|
|
def test_namespaced_read_filters_correctly(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_read"])
|
|
assert set(_enabled_tools(result).keys()) == {"echo", "readFile"}
|
|
assert _disabled_tools(result) == {"writeFile", "bash"}
|
|
|
|
|
|
def test_namespaced_write_filters_correctly(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_write"])
|
|
assert set(_enabled_tools(result).keys()) == {"writeFile", "bash"}
|
|
assert _disabled_tools(result) == {"echo", "readFile"}
|
|
|
|
|
|
def test_multiple_namespaced_toolsets_are_unioned(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_read", "slack_write"])
|
|
assert set(_enabled_tools(result).keys()) == set(TOOLS)
|
|
assert _disabled_tools(result) == set()
|
|
|
|
|
|
def test_tools_are_namespaced(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_read"])
|
|
for original, new_name in _enabled_tools(result).items():
|
|
assert new_name == f"slack__{original}"
|
|
|
|
|
|
def test_unnamespaced_package_exposes_bare_tool_names(tmp_path):
|
|
"""When mcp.json sets namespaced=false, tools are exposed without a prefix."""
|
|
mcp_json = {
|
|
"run": {"command": "python", "args": ["-m", "fake"]},
|
|
"namespaced": False,
|
|
"toolsets": TOOLSETS,
|
|
}
|
|
(tmp_path / "mcp.json").write_text(json.dumps(mcp_json))
|
|
tools_data = {
|
|
"schema_version": "v1",
|
|
"tools": [{"name": t, "description": "", "inputSchema": {}} for t in TOOLS],
|
|
"toolsets": TOOLSETS,
|
|
}
|
|
(tmp_path / "mcp-tools.generated.json").write_text(json.dumps(tools_data))
|
|
cfg = McpService(McpConfig.from_mcp_json(tmp_path, mcp_json))
|
|
result = cfg.resolve_tool_transforms([])
|
|
for original, new_name in _enabled_tools(result).items():
|
|
assert new_name == original
|
|
|
|
|
|
def test_no_toolsets_in_mcp_json_exposes_all(tmp_path):
|
|
"""When mcp.json has no toolsets key, all tools are exposed regardless of filter."""
|
|
cfg = _make_cfg(tmp_path, TOOLS, {})
|
|
result = cfg.resolve_tool_transforms(["whatever"])
|
|
assert set(_enabled_tools(result).keys()) == set(TOOLS)
|
|
assert _disabled_tools(result) == set()
|
|
|
|
|
|
def test_unknown_toolset_disables_all(tmp_path):
|
|
"""A namespaced toolset that doesn't exist on this package disables every tool."""
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_nonexistent"])
|
|
assert _enabled_tools(result) == {}
|
|
assert _disabled_tools(result) == set(TOOLS)
|
|
|
|
|
|
def test_other_packages_namespace_does_not_match(tmp_path):
|
|
"""A toolset namespaced to a different package is ignored — matches no tools."""
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, TOOLS, TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["jira_read"])
|
|
assert _enabled_tools(result) == {}
|
|
assert _disabled_tools(result) == set(TOOLS)
|
|
|
|
|
|
def test_missing_generated_json_raises(tmp_path):
|
|
mcp_json = {"run": {"command": "python", "args": []}, "toolsets": TOOLSETS}
|
|
(tmp_path / "mcp.json").write_text(json.dumps(mcp_json))
|
|
cfg = McpService(McpConfig.from_mcp_json(tmp_path, mcp_json))
|
|
with pytest.raises(FileNotFoundError, match=r"mcp-tools\.generated\.json not found"):
|
|
cfg.resolve_tool_transforms(["slack_read"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State toolsets are reachable when explicitly requested. The "all" / "_all"
|
|
# special case (with its hidden-by-default semantics for state/grading tools)
|
|
# was removed when the auto-aggregated cross-package toolsets were dropped.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
STATEFUL_TOOLS = ["echo", "readFile", "export_state", "import_state"]
|
|
STATEFUL_TOOLSETS = {
|
|
"read": ["echo", "readFile"],
|
|
"state": ["export_state", "import_state"],
|
|
}
|
|
|
|
|
|
def test_namespaced_state_request_exposes_state(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, STATEFUL_TOOLS, STATEFUL_TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_state"])
|
|
assert set(_enabled_tools(result).keys()) == {"export_state", "import_state"}
|
|
|
|
|
|
def test_namespaced_read_does_not_expose_state(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, STATEFUL_TOOLS, STATEFUL_TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_read"])
|
|
assert set(_enabled_tools(result).keys()) == {"echo", "readFile"}
|
|
assert _disabled_tools(result) == {"export_state", "import_state"}
|
|
|
|
|
|
def test_combined_namespaced_toolsets_union(tmp_path):
|
|
pkg = tmp_path / "slack"
|
|
pkg.mkdir()
|
|
cfg = _make_cfg(pkg, STATEFUL_TOOLS, STATEFUL_TOOLSETS)
|
|
result = cfg.resolve_tool_transforms(["slack_read", "slack_state"])
|
|
assert set(_enabled_tools(result).keys()) == set(STATEFUL_TOOLS)
|