test orgs 2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""MCP integration and proxy tests."""
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from wf_mcp import (
|
||||
BrokerConfig,
|
||||
ConnectionConfig,
|
||||
FileStore,
|
||||
WfMcpService,
|
||||
build_service_from_config,
|
||||
create_broker_server,
|
||||
load_broker_config,
|
||||
)
|
||||
|
||||
from .test_support import (
|
||||
FailingDiscoveryAdapter,
|
||||
FakeAdapter,
|
||||
local_temp_root,
|
||||
)
|
||||
|
||||
|
||||
def test_load_broker_config_resolves_relative_store_root() -> None:
|
||||
tmp_path = local_temp_root() / "broker_config_test"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": ".broker-store",
|
||||
"connections": [
|
||||
{
|
||||
"id": "demo.personal",
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
assert config.store_root == (tmp_path / ".broker-store").resolve()
|
||||
assert [connection.id for connection in config.connections] == ["demo.personal"]
|
||||
|
||||
|
||||
def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "broker_server_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
|
||||
server = create_broker_server(service)
|
||||
|
||||
tools = asyncio.run(server.list_tools())
|
||||
resources = asyncio.run(server.list_resources())
|
||||
prompts = asyncio.run(server.list_prompts())
|
||||
|
||||
tool_names = {tool.name for tool in tools}
|
||||
resource_names = {resource.name for resource in resources}
|
||||
prompt_names = {prompt.name for prompt in prompts}
|
||||
|
||||
assert "get_connection_statuses" in tool_names
|
||||
assert "refresh_connection_catalog" in tool_names
|
||||
assert "invoke_broker_method" in tool_names
|
||||
assert "call_broker_tool" in tool_names
|
||||
assert "catalog.all" in resource_names
|
||||
assert "events.all" in resource_names
|
||||
assert "status.all" in resource_names
|
||||
assert "plan_with_catalog" in prompt_names
|
||||
|
||||
|
||||
def test_build_service_from_config_registers_connections() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "broker_config_store",
|
||||
connections=[
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal"),
|
||||
ConnectionConfig(id="demo.work", server="demo", account="work"),
|
||||
],
|
||||
)
|
||||
|
||||
service = build_service_from_config(config)
|
||||
|
||||
ids = [connection.id for connection in service.connections.list_all()]
|
||||
assert ids == ["demo.personal", "demo.work"]
|
||||
|
||||
|
||||
def test_broker_refresh_tool_returns_structured_error() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "broker_fail_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FailingDiscoveryAdapter())
|
||||
|
||||
server = create_broker_server(service)
|
||||
|
||||
_content, structured = asyncio.run(
|
||||
server.call_tool(
|
||||
"refresh_connection_catalog", {"connection_id": "demo.personal"}
|
||||
)
|
||||
)
|
||||
assert structured == {
|
||||
"connection_id": "demo.personal",
|
||||
"refreshed": False,
|
||||
"error_type": "PermissionError",
|
||||
"error": "Access is denied",
|
||||
}
|
||||
|
||||
|
||||
def test_broker_call_tool_returns_structured_result() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "broker_tool_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
server = create_broker_server(service)
|
||||
|
||||
_content, structured = asyncio.run(
|
||||
server.call_tool(
|
||||
"call_broker_tool",
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"tool_name": "echo_tool",
|
||||
"arguments": {"text": "hello"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert structured == {
|
||||
"connection_id": "demo.personal",
|
||||
"tool_name": "echo_tool",
|
||||
"ok": True,
|
||||
"outcome": "ok",
|
||||
"output": {"echoed": "hello"},
|
||||
"meta": {},
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from wf_mcp.cli import build_parser, main
|
||||
from wf_mcp.broker_server import load_broker_config
|
||||
|
||||
from .test_support import local_temp_root
|
||||
|
||||
|
||||
def _write_config(path: Path) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": ".wf_mcp_store",
|
||||
"connections": [
|
||||
{
|
||||
"id": "demo.personal",
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_build_parser_accepts_serve_transport() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
["--config", "wf_mcp.config.json", "serve", "--transport", "streamable_http"]
|
||||
)
|
||||
|
||||
assert args.command == "serve"
|
||||
assert args.transport == "streamable_http"
|
||||
assert args.mode == "proxy"
|
||||
assert args.resources_as_tools is False
|
||||
assert args.prompts_as_tools is False
|
||||
assert args.search_tools is False
|
||||
|
||||
|
||||
def test_build_parser_accepts_proxy_compatibility_flags() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"--config",
|
||||
"wf_mcp.config.json",
|
||||
"serve",
|
||||
"--resources-as-tools",
|
||||
"--prompts-as-tools",
|
||||
"--search-tools",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.command == "serve"
|
||||
assert args.mode == "proxy"
|
||||
assert args.resources_as_tools is True
|
||||
assert args.prompts_as_tools is True
|
||||
assert args.search_tools is True
|
||||
|
||||
|
||||
def test_cli_connections_prints_configured_connections(capsys) -> None:
|
||||
tmp_path = local_temp_root() / "cli_connections_test"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
_write_config(config_path)
|
||||
|
||||
exit_code = main(["--config", str(config_path), "connections"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert exit_code == 0
|
||||
payload = json.loads(captured.out)
|
||||
assert payload[0]["id"] == "demo.personal"
|
||||
|
||||
|
||||
def test_cli_catalog_prints_empty_catalog_when_not_refreshed(
|
||||
capsys,
|
||||
) -> None:
|
||||
tmp_path = local_temp_root() / "cli_catalog_test"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
_write_config(config_path)
|
||||
|
||||
exit_code = main(["--config", str(config_path), "catalog"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert exit_code == 0
|
||||
payload = json.loads(captured.out)
|
||||
assert payload["nodes"] == []
|
||||
assert payload["resources"] == []
|
||||
assert payload["prompts"] == []
|
||||
|
||||
|
||||
def test_cli_status_prints_connection_statuses(capsys) -> None:
|
||||
tmp_path = local_temp_root() / "cli_status_test"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
_write_config(config_path)
|
||||
|
||||
exit_code = main(["--config", str(config_path), "status"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert exit_code == 0
|
||||
payload = json.loads(captured.out)
|
||||
assert payload == [
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
"enabled": True,
|
||||
"has_snapshot": False,
|
||||
"fetched_at_epoch_ms": None,
|
||||
"max_age_seconds": None,
|
||||
"node_count": 0,
|
||||
"resource_count": 0,
|
||||
"prompt_count": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_load_broker_config_normalizes_typed_stdio_metadata() -> None:
|
||||
tmp_path = local_temp_root() / "cli_typed_stdio_config_test"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": ".wf_mcp_store",
|
||||
"connections": [
|
||||
{
|
||||
"id": "demo.personal",
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
"metadata": {
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
"env": {"TOKEN": "secret"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
assert config.store_root == (tmp_path / ".wf_mcp_store").resolve()
|
||||
assert config.connections[0].metadata == {
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
"env": {"TOKEN": "secret"},
|
||||
}
|
||||
|
||||
|
||||
def test_load_broker_config_rejects_bad_metadata_shape() -> None:
|
||||
tmp_path = local_temp_root() / "cli_bad_config_test"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"connections": [
|
||||
{
|
||||
"id": "demo.personal",
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
"metadata": {"transport": "stdio", "args": "server.py"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
load_broker_config(config_path)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_mcp.names import (
|
||||
is_admin_tool_name,
|
||||
namespaced_tool_name,
|
||||
parse_namespaced_tool_name,
|
||||
)
|
||||
|
||||
|
||||
def test_namespaced_tool_names_are_reversible_with_known_connections() -> None:
|
||||
proxy_name = namespaced_tool_name("everything.default", "get-sum")
|
||||
|
||||
parsed = parse_namespaced_tool_name(
|
||||
proxy_name,
|
||||
{"everything.default", "everything"},
|
||||
)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed.proxy_name == "everything.default_get-sum"
|
||||
assert parsed.connection_id == "everything.default"
|
||||
assert parsed.local_name == "get-sum"
|
||||
|
||||
|
||||
def test_namespaced_tool_parser_rejects_unknown_and_admin_names() -> None:
|
||||
assert parse_namespaced_tool_name("missing_echo", {"everything.default"}) is None
|
||||
assert is_admin_tool_name("wf.mcp_list_connections") is True
|
||||
assert is_admin_tool_name("everything.default_echo") is False
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_mcp import ConnectionConfig, FileStore, McpSdkAdapter, WfMcpService
|
||||
|
||||
from .test_support import (
|
||||
everything_server_connection,
|
||||
fixture_server_path,
|
||||
local_temp_root,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_sdk_adapter_lists_and_calls_stdio_tool() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "sdk_adapter_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="personal",
|
||||
metadata={
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
)
|
||||
)
|
||||
service.register_adapter("fixture", McpSdkAdapter())
|
||||
|
||||
try:
|
||||
asyncio.run(service.refresh_connection_catalog("fixture.personal"))
|
||||
except PermissionError as exc:
|
||||
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
|
||||
|
||||
payload = service.get_catalog().as_payload()
|
||||
assert payload["nodes"][0]["qualified_name"] == "fixture.personal.echo_tool"
|
||||
assert payload["resources"] == [
|
||||
{
|
||||
"qualified_name": "fixture.personal.resource.welcome",
|
||||
"connection_id": "fixture.personal",
|
||||
"local_name": "resource.welcome",
|
||||
"uri": "fixture://docs/welcome",
|
||||
"title": "Resource Welcome",
|
||||
"description": "Welcome text resource for fixture tests.",
|
||||
"mime_type": "text/plain",
|
||||
"metadata": payload["resources"][0]["metadata"],
|
||||
}
|
||||
]
|
||||
assert payload["prompts"] == [
|
||||
{
|
||||
"qualified_name": "fixture.personal.prompt.summarize",
|
||||
"connection_id": "fixture.personal",
|
||||
"local_name": "prompt.summarize",
|
||||
"title": "Prompt Summarize",
|
||||
"description": "Summarize an input text for fixture tests.",
|
||||
"arguments": payload["prompts"][0]["arguments"],
|
||||
"metadata": payload["prompts"][0]["metadata"],
|
||||
}
|
||||
]
|
||||
|
||||
resource_result = asyncio.run(
|
||||
service.read_resource("fixture.personal.resource.welcome")
|
||||
)
|
||||
prompt_result = asyncio.run(
|
||||
service.render_prompt(
|
||||
"fixture.personal.prompt.summarize",
|
||||
arguments={"text": "hello"},
|
||||
)
|
||||
)
|
||||
assert (
|
||||
resource_result["contents"][0]["text"] == "Welcome from the fixture MCP server."
|
||||
)
|
||||
assert (
|
||||
prompt_result["messages"][0]["content"]["text"]
|
||||
== "Summarize this text:\n\nhello"
|
||||
)
|
||||
ping_result = asyncio.run(service.invoke_method("fixture.personal", "ping"))
|
||||
assert ping_result == {}
|
||||
|
||||
adapter = McpSdkAdapter()
|
||||
try:
|
||||
result = asyncio.run(
|
||||
adapter.call_tool(
|
||||
connection=service.connections.get("fixture.personal"),
|
||||
auth=None,
|
||||
tool_name="echo_tool",
|
||||
payload={"text": "hello"},
|
||||
)
|
||||
)
|
||||
except PermissionError as exc:
|
||||
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
|
||||
assert result.outcome == "ok"
|
||||
assert result.output == {"echoed": "hello"}
|
||||
|
||||
|
||||
def test_mcp_sdk_adapter_can_probe_everything_server() -> None:
|
||||
connection = everything_server_connection()
|
||||
if connection is None:
|
||||
pytest.skip(
|
||||
"set MCP_EVERYTHING_COMMAND to enable the live everything-server integration test"
|
||||
)
|
||||
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "everything_server_store")
|
||||
)
|
||||
service.register_connection(connection)
|
||||
service.register_adapter("everything", McpSdkAdapter())
|
||||
|
||||
try:
|
||||
asyncio.run(service.refresh_connection_catalog("everything.default"))
|
||||
except PermissionError as exc:
|
||||
pytest.skip(f"live MCP transport is not permitted in this environment: {exc}")
|
||||
|
||||
payload = service.get_catalog().as_payload()
|
||||
assert payload["nodes"], "everything-server should expose at least one tool"
|
||||
assert all(
|
||||
node["qualified_name"].startswith("everything.default.")
|
||||
for node in payload["nodes"]
|
||||
)
|
||||
assert "resources" in payload
|
||||
assert "prompts" in payload
|
||||
@@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
from wf_core import END, RunStatus
|
||||
from wf_mcp import (
|
||||
AuthRecord,
|
||||
ConnectionConfig,
|
||||
FileStore,
|
||||
RawWorkflowPlan,
|
||||
WfMcpService,
|
||||
)
|
||||
from wf_mcp.error_info import error_payload
|
||||
|
||||
from .test_support import (
|
||||
FailingDiscoveryAdapter,
|
||||
FakeAdapter,
|
||||
echo_tool,
|
||||
finalize_tool,
|
||||
local_temp_root,
|
||||
)
|
||||
|
||||
|
||||
def test_service_builds_namespaced_catalog() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "catalog_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool, finalize_tool)
|
||||
|
||||
payload = service.get_catalog().as_payload()
|
||||
names = [node["qualified_name"] for node in payload["nodes"]]
|
||||
|
||||
assert names == [
|
||||
"demo.personal.echo_tool",
|
||||
"demo.personal.finalize_tool",
|
||||
]
|
||||
|
||||
|
||||
def test_service_compiles_and_runs_raw_plan() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "run_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool, finalize_tool)
|
||||
|
||||
plan = RawWorkflowPlan(
|
||||
name="demo_plan",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
state_schema={
|
||||
"fields": {
|
||||
"echoed": {"type": "string"},
|
||||
"result": {"type": "string"},
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
},
|
||||
start="echo",
|
||||
nodes=[
|
||||
{
|
||||
"id": "echo",
|
||||
"type": "node",
|
||||
"node": "demo.personal.echo_tool",
|
||||
"in_map": {"input.text": "text"},
|
||||
"out_map": {"echoed": "state.echoed"},
|
||||
},
|
||||
{
|
||||
"id": "finalize",
|
||||
"type": "node",
|
||||
"node": "demo.personal.finalize_tool",
|
||||
"in_map": {"state.echoed": "echoed"},
|
||||
"out_map": {"result": "state.result"},
|
||||
},
|
||||
],
|
||||
edges=[
|
||||
{"from": "echo", "outcome": "ok", "to": "finalize"},
|
||||
{"from": "finalize", "outcome": "done", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
run = asyncio.run(service.run_workflow_from_plan(plan, {"text": "hello"}))
|
||||
|
||||
assert run.status == RunStatus.COMPLETED
|
||||
assert run.output == {"result": "final:hello"}
|
||||
event_kinds = [event.kind for event in service.list_events()]
|
||||
assert "workflow_run_started" in event_kinds
|
||||
assert "workflow_run_completed" in event_kinds
|
||||
|
||||
|
||||
def test_service_refreshes_catalog_from_adapter() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "adapter_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.save_auth(
|
||||
AuthRecord(
|
||||
connection_id="demo.personal",
|
||||
scheme="token",
|
||||
payload={"token": "abc"},
|
||||
)
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
|
||||
payload = service.get_catalog().as_payload()
|
||||
assert payload["nodes"] == [
|
||||
{
|
||||
"qualified_name": "demo.personal.echo_tool",
|
||||
"connection_id": "demo.personal",
|
||||
"local_name": "echo_tool",
|
||||
"title": "Echo Tool",
|
||||
"description": "Echo text back",
|
||||
"outcomes": ["ok"],
|
||||
"input_schema": {
|
||||
"additionalProperties": True,
|
||||
"properties": {"text": {"title": "Text"}},
|
||||
"required": ["text"],
|
||||
"title": "demo.personal_echo_tool_Input",
|
||||
"type": "object",
|
||||
},
|
||||
"output_schema": {
|
||||
"additionalProperties": True,
|
||||
"properties": {"echoed": {"title": "Echoed"}},
|
||||
"required": ["echoed"],
|
||||
"title": "demo.personal_echo_tool_Output",
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert payload["resources"] == [
|
||||
{
|
||||
"qualified_name": "demo.personal.resource.welcome",
|
||||
"connection_id": "demo.personal",
|
||||
"local_name": "resource.welcome",
|
||||
"title": "Welcome Resource",
|
||||
"uri": "demo://docs/welcome",
|
||||
"description": "Welcome resource",
|
||||
"mime_type": "text/plain",
|
||||
"metadata": {"kind": "static"},
|
||||
}
|
||||
]
|
||||
assert payload["prompts"] == [
|
||||
{
|
||||
"qualified_name": "demo.personal.prompt.summarize",
|
||||
"connection_id": "demo.personal",
|
||||
"local_name": "prompt.summarize",
|
||||
"title": "Summarize Prompt",
|
||||
"description": "Summarize text",
|
||||
"arguments": [
|
||||
{
|
||||
"name": "text",
|
||||
"required": True,
|
||||
"description": "Text to summarize",
|
||||
}
|
||||
],
|
||||
"metadata": {"kind": "template"},
|
||||
}
|
||||
]
|
||||
assert payload["connections"] == [
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"fetched_at_epoch_ms": payload["connections"][0]["fetched_at_epoch_ms"],
|
||||
"max_age_seconds": 300,
|
||||
"metadata": {
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
"auth_scheme": "token",
|
||||
},
|
||||
}
|
||||
]
|
||||
event_kinds = [event.kind for event in service.list_events()]
|
||||
assert "catalog_refresh_started" in event_kinds
|
||||
assert "catalog_refresh_completed" in event_kinds
|
||||
|
||||
|
||||
def test_service_records_tool_call_events() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "event_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
|
||||
plan = RawWorkflowPlan(
|
||||
name="tool_only_plan",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
state_schema={
|
||||
"fields": {
|
||||
"echoed": {"type": "string"},
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
"required": ["echoed"],
|
||||
},
|
||||
start="echo",
|
||||
nodes=[
|
||||
{
|
||||
"id": "echo",
|
||||
"type": "node",
|
||||
"node": "demo.personal.echo_tool",
|
||||
"in_map": {"input.text": "text"},
|
||||
"out_map": {"echoed": "state.echoed"},
|
||||
}
|
||||
],
|
||||
edges=[
|
||||
{"from": "echo", "outcome": "ok", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
run = asyncio.run(service.run_workflow_from_plan(plan, {"text": "hello"}))
|
||||
|
||||
assert run.status == RunStatus.COMPLETED
|
||||
tool_events = [
|
||||
event for event in service.list_events() if "tool_call" in event.kind
|
||||
]
|
||||
assert [event.kind for event in tool_events] == [
|
||||
"tool_call_started",
|
||||
"tool_call_completed",
|
||||
]
|
||||
assert tool_events[0].capability_id == "demo.personal.echo_tool"
|
||||
assert tool_events[1].payload["outcome"] == "ok"
|
||||
|
||||
|
||||
def test_service_can_inspect_resources_and_prompts() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "inspect_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
|
||||
resources = service.list_resources(connection_id="demo.personal")
|
||||
prompts = service.list_prompts(connection_id="demo.personal")
|
||||
|
||||
assert [resource.qualified_name for resource in resources] == [
|
||||
"demo.personal.resource.welcome"
|
||||
]
|
||||
assert [prompt.qualified_name for prompt in prompts] == [
|
||||
"demo.personal.prompt.summarize"
|
||||
]
|
||||
|
||||
resource = service.get_resource("demo.personal.resource.welcome")
|
||||
prompt = service.get_prompt("demo.personal.prompt.summarize")
|
||||
|
||||
assert resource.uri == "demo://docs/welcome"
|
||||
assert prompt.arguments[0]["name"] == "text"
|
||||
|
||||
|
||||
def test_service_reports_connection_statuses() -> None:
|
||||
store = local_temp_root() / "status_store"
|
||||
# clear store before test
|
||||
shutil.rmtree(store)
|
||||
service = WfMcpService(store=FileStore(store))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
before = service.connection_statuses()
|
||||
assert before == [
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
"enabled": True,
|
||||
"has_snapshot": False,
|
||||
"fetched_at_epoch_ms": None,
|
||||
"max_age_seconds": None,
|
||||
"node_count": 0,
|
||||
"resource_count": 0,
|
||||
"prompt_count": 0,
|
||||
}
|
||||
]
|
||||
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
after = service.connection_statuses()
|
||||
assert after[0]["has_snapshot"] is True
|
||||
assert after[0]["node_count"] == 1
|
||||
assert after[0]["resource_count"] == 1
|
||||
assert after[0]["prompt_count"] == 1
|
||||
|
||||
|
||||
def test_service_can_proxy_resource_reads_and_prompt_gets() -> None:
|
||||
store = local_temp_root() / "proxy_store"
|
||||
# new store
|
||||
shutil.rmtree(store)
|
||||
service = WfMcpService(store=FileStore(store))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
|
||||
resource_result = asyncio.run(
|
||||
service.read_resource("demo.personal.resource.welcome")
|
||||
)
|
||||
prompt_result = asyncio.run(
|
||||
service.render_prompt(
|
||||
"demo.personal.prompt.summarize",
|
||||
arguments={"text": "hello world"},
|
||||
)
|
||||
)
|
||||
|
||||
assert (
|
||||
resource_result["contents"][0]["text"]
|
||||
== "Welcome from the fake adapter resource."
|
||||
)
|
||||
assert (
|
||||
prompt_result["messages"][0]["content"]["text"]
|
||||
== "Summarize this text:\n\nhello world"
|
||||
)
|
||||
|
||||
event_kinds = [event.kind for event in service.list_events()]
|
||||
assert "resource_read_started" in event_kinds
|
||||
assert "resource_read_completed" in event_kinds
|
||||
assert "prompt_get_started" in event_kinds
|
||||
assert "prompt_get_completed" in event_kinds
|
||||
|
||||
|
||||
def test_service_can_invoke_raw_method_and_notification() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "raw_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
result = asyncio.run(
|
||||
service.invoke_method("demo.personal", "demo.echo", params={"text": "hello"})
|
||||
)
|
||||
asyncio.run(
|
||||
service.send_notification(
|
||||
"demo.personal",
|
||||
"notifications/progress",
|
||||
params={"progress": 1},
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"echoed": "hello"}
|
||||
event_kinds = [event.kind for event in service.list_events()]
|
||||
assert "raw_method_started" in event_kinds
|
||||
assert "raw_method_completed" in event_kinds
|
||||
assert "raw_notification_started" in event_kinds
|
||||
assert "raw_notification_completed" in event_kinds
|
||||
|
||||
|
||||
def test_service_can_call_upstream_tool_directly() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "direct_tool_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
result = asyncio.run(
|
||||
service.call_tool(
|
||||
"demo.personal",
|
||||
"echo_tool",
|
||||
arguments={"text": "hello"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"outcome": "ok",
|
||||
"output": {"echoed": "hello"},
|
||||
"meta": {},
|
||||
}
|
||||
event_kinds = [event.kind for event in service.list_events()]
|
||||
assert "tool_call_started" in event_kinds
|
||||
assert "tool_call_completed" in event_kinds
|
||||
|
||||
|
||||
def test_service_records_catalog_refresh_failures() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "refresh_fail_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FailingDiscoveryAdapter())
|
||||
|
||||
try:
|
||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||
except PermissionError as exc:
|
||||
assert str(exc) == "Access is denied"
|
||||
else:
|
||||
raise AssertionError("expected refresh to fail")
|
||||
|
||||
failure_events = [
|
||||
event
|
||||
for event in service.list_events()
|
||||
if event.kind == "catalog_refresh_failed"
|
||||
]
|
||||
assert len(failure_events) == 1
|
||||
assert failure_events[0].payload == {
|
||||
"error_type": "PermissionError",
|
||||
"error": "Access is denied",
|
||||
}
|
||||
|
||||
|
||||
def test_error_payload_unwraps_exception_group() -> None:
|
||||
exc = ExceptionGroup("outer", [PermissionError("Access is denied")])
|
||||
assert error_payload(exc) == {
|
||||
"error_type": "PermissionError",
|
||||
"error": "Access is denied",
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_mcp import AuthRecord, FileStore
|
||||
|
||||
from .test_support import local_temp_root
|
||||
|
||||
|
||||
def test_file_store_round_trips_auth() -> None:
|
||||
store = FileStore(local_temp_root() / "auth_store")
|
||||
record = AuthRecord(
|
||||
connection_id="demo.personal",
|
||||
scheme="oauth",
|
||||
payload={"token": "secret"},
|
||||
)
|
||||
|
||||
store.save_auth(record)
|
||||
loaded = store.load_auth("demo.personal")
|
||||
|
||||
assert loaded == record
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wf_authoring import NodeReturn, node
|
||||
from wf_core import RuntimeContext
|
||||
from wf_mcp import (
|
||||
AuthRecord,
|
||||
ConnectionConfig,
|
||||
DiscoveredPrompt,
|
||||
DiscoveredResource,
|
||||
DiscoveredTool,
|
||||
ToolCallResult,
|
||||
)
|
||||
|
||||
|
||||
class EchoInput(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
echoed: str
|
||||
|
||||
|
||||
class FinalizeInput(BaseModel):
|
||||
echoed: str
|
||||
|
||||
|
||||
class FinalizeOutput(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
@node()
|
||||
async def echo_tool(payload: EchoInput, ctx: RuntimeContext) -> EchoOutput:
|
||||
return EchoOutput(echoed=payload.text)
|
||||
|
||||
|
||||
@node(outcomes=("done",))
|
||||
def finalize_tool(
|
||||
payload: FinalizeInput, ctx: RuntimeContext
|
||||
) -> NodeReturn[FinalizeOutput]:
|
||||
return NodeReturn(
|
||||
outcome="done",
|
||||
output=FinalizeOutput(result=f"final:{payload.echoed}"),
|
||||
)
|
||||
|
||||
|
||||
def local_temp_root() -> Path:
|
||||
root = Path("test-artifacts") / "wf_mcp_store"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def fixture_server_path() -> str:
|
||||
return str(Path(__file__).resolve().parents[1] / "fixtures" / "mcp_echo_server.py")
|
||||
|
||||
|
||||
def everything_server_connection() -> ConnectionConfig | None:
|
||||
transport = os.environ.get("MCP_EVERYTHING_TRANSPORT", "stdio")
|
||||
if transport == "stdio":
|
||||
command = os.environ.get("MCP_EVERYTHING_COMMAND")
|
||||
if not command:
|
||||
return None
|
||||
|
||||
raw_args = os.environ.get("MCP_EVERYTHING_ARGS", "")
|
||||
args = [arg for arg in raw_args.split(" ") if arg]
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"transport": transport,
|
||||
"command": command,
|
||||
"args": args,
|
||||
}
|
||||
elif transport == "streamable_http":
|
||||
url = os.environ.get("MCP_EVERYTHING_URL")
|
||||
if not url:
|
||||
raise AssertionError(
|
||||
"MCP_EVERYTHING_URL must be set when MCP_EVERYTHING_TRANSPORT=streamable_http"
|
||||
)
|
||||
metadata = {
|
||||
"transport": "streamable_http",
|
||||
"url": url,
|
||||
}
|
||||
else:
|
||||
return None
|
||||
|
||||
return ConnectionConfig(
|
||||
id="everything.default",
|
||||
server="everything",
|
||||
account="default",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
async def list_tools(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredTool]:
|
||||
return [
|
||||
DiscoveredTool(
|
||||
name="echo_tool",
|
||||
title="Echo Tool",
|
||||
description="Echo text back",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
"required": ["echoed"],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
async def list_resources(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredResource]:
|
||||
return [
|
||||
DiscoveredResource(
|
||||
uri="demo://docs/welcome",
|
||||
name="resource.welcome",
|
||||
title="Welcome Resource",
|
||||
description="Welcome resource",
|
||||
mime_type="text/plain",
|
||||
metadata={"kind": "static"},
|
||||
)
|
||||
]
|
||||
|
||||
async def list_prompts(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredPrompt]:
|
||||
return [
|
||||
DiscoveredPrompt(
|
||||
name="prompt.summarize",
|
||||
title="Summarize Prompt",
|
||||
description="Summarize text",
|
||||
arguments=[
|
||||
{
|
||||
"name": "text",
|
||||
"required": True,
|
||||
"description": "Text to summarize",
|
||||
}
|
||||
],
|
||||
metadata={"kind": "template"},
|
||||
)
|
||||
]
|
||||
|
||||
async def get_connection_metadata(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"server": connection.server,
|
||||
"account": connection.account,
|
||||
"auth_scheme": auth.scheme if auth is not None else None,
|
||||
}
|
||||
|
||||
async def read_resource(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
uri: str,
|
||||
) -> dict[str, Any]:
|
||||
if uri != "demo://docs/welcome":
|
||||
raise KeyError(uri)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": uri,
|
||||
"mimeType": "text/plain",
|
||||
"text": "Welcome from the fake adapter resource.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def get_prompt(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
prompt_name: str,
|
||||
arguments: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if prompt_name != "prompt.summarize":
|
||||
raise KeyError(prompt_name)
|
||||
text = (arguments or {}).get("text", "")
|
||||
return {
|
||||
"description": "Summarize text",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": f"Summarize this text:\n\n{text}",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def invoke_method(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
method: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if method == "ping":
|
||||
return {}
|
||||
if method == "demo.echo":
|
||||
return {"echoed": (params or {}).get("text", "")}
|
||||
raise KeyError(method)
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
method: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
tool_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> ToolCallResult:
|
||||
if tool_name != "echo_tool":
|
||||
raise KeyError(tool_name)
|
||||
return ToolCallResult(
|
||||
outcome="ok",
|
||||
output={"echoed": str(payload["text"])},
|
||||
)
|
||||
|
||||
|
||||
class FailingDiscoveryAdapter(FakeAdapter):
|
||||
async def list_tools(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredTool]:
|
||||
raise PermissionError("Access is denied")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FailingDiscoveryAdapter",
|
||||
"FakeAdapter",
|
||||
"echo_tool",
|
||||
"everything_server_connection",
|
||||
"finalize_tool",
|
||||
"fixture_server_path",
|
||||
"local_temp_root",
|
||||
"sys",
|
||||
]
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_mcp import (
|
||||
BrokerConfig,
|
||||
ConnectionConfig,
|
||||
ProxyConfigError,
|
||||
create_transparent_proxy_client,
|
||||
validate_transparent_proxy_config,
|
||||
)
|
||||
from wf_mcp.broker_server import load_broker_config
|
||||
|
||||
from .test_support import fixture_server_path, local_temp_root
|
||||
|
||||
|
||||
def _structured(result: Any) -> dict[str, Any]:
|
||||
content = result.structured_content
|
||||
assert isinstance(content, dict)
|
||||
return content
|
||||
|
||||
|
||||
def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "transparent_proxy_store",
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="personal",
|
||||
metadata={
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(config)
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
names = [tool.name for tool in tools]
|
||||
assert "wf.mcp_list_connections" in names
|
||||
assert "wf.mcp_get_connection_statuses" in names
|
||||
assert "wf.mcp_list_proxy_tools" in names
|
||||
assert "wf.mcp_get_proxy_tool" in names
|
||||
assert "fixture.personal_echo_tool" in names
|
||||
|
||||
connections_result = await client.call_tool("wf.mcp_list_connections")
|
||||
assert _structured(connections_result) == {
|
||||
"result": [
|
||||
{
|
||||
"id": "fixture.personal",
|
||||
"server": "fixture",
|
||||
"account": "personal",
|
||||
"enabled": True,
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await client.call_tool(
|
||||
"fixture.personal_echo_tool",
|
||||
{"text": "hello"},
|
||||
)
|
||||
assert _structured(result) == {"echoed": "hello"}
|
||||
|
||||
proxy_tools_result = await client.call_tool("wf.mcp_list_proxy_tools")
|
||||
proxy_tools_payload = _structured(proxy_tools_result)
|
||||
proxy_tools = proxy_tools_payload["tools"]
|
||||
assert proxy_tools_payload["nextCursor"] is None
|
||||
assert proxy_tools_payload["total"] == 1
|
||||
assert len(proxy_tools) == 1
|
||||
assert proxy_tools[0]["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert proxy_tools[0]["connection_id"] == "fixture.personal"
|
||||
assert proxy_tools[0]["local_name"] == "echo_tool"
|
||||
assert proxy_tools[0]["enabled"] is True
|
||||
|
||||
proxy_tool_result = await client.call_tool(
|
||||
"wf.mcp_get_proxy_tool",
|
||||
{"proxy_name": "fixture.personal_echo_tool"},
|
||||
)
|
||||
proxy_tool = _structured(proxy_tool_result)
|
||||
assert proxy_tool["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert proxy_tool["connection_id"] == "fixture.personal"
|
||||
assert proxy_tool["local_name"] == "echo_tool"
|
||||
assert proxy_tool["input_schema"]["properties"]["text"]["type"] == "string"
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
|
||||
def test_transparent_proxy_rejects_invalid_connection_config() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "transparent_proxy_invalid_store",
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="personal",
|
||||
metadata={"transport": "stdio"},
|
||||
),
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="work",
|
||||
metadata={"transport": "websocket"},
|
||||
),
|
||||
ConnectionConfig(
|
||||
id="bad_scope.personal",
|
||||
server="bad_scope",
|
||||
account="personal",
|
||||
metadata={"transport": "stdio", "command": sys.executable},
|
||||
),
|
||||
ConnectionConfig(
|
||||
id="fixture.http",
|
||||
server="fixture",
|
||||
account="http",
|
||||
metadata={"transport": "http"},
|
||||
),
|
||||
ConnectionConfig(
|
||||
id="wf.mcp",
|
||||
server="wf",
|
||||
account="mcp",
|
||||
metadata={"transport": "stdio", "command": sys.executable},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyConfigError) as exc_info:
|
||||
validate_transparent_proxy_config(config)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "duplicate connection id 'fixture.personal'" in message
|
||||
assert "fixture.personal: stdio transport requires metadata.command" in message
|
||||
assert "fixture.personal: unsupported MCP transport 'websocket'" in message
|
||||
assert "connection id 'bad_scope.personal' must not contain '_'" in message
|
||||
assert "fixture.http: http transport requires metadata.url" in message
|
||||
assert "connection id 'wf.mcp' is reserved by wf-mcp" in message
|
||||
|
||||
|
||||
def test_transparent_proxy_can_expose_resources_and_prompts_as_tools() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "transparent_proxy_helper_store",
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="personal",
|
||||
metadata={
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(
|
||||
config,
|
||||
resources_as_tools=True,
|
||||
prompts_as_tools=True,
|
||||
)
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
names = [tool.name for tool in tools]
|
||||
assert "list_resources" in names
|
||||
assert "read_resource" in names
|
||||
assert "list_prompts" in names
|
||||
assert "get_prompt" in names
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
|
||||
def test_transparent_proxy_can_collapse_upstream_tools_behind_search() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "transparent_proxy_search_store",
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="personal",
|
||||
metadata={
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(config, search_tools=True)
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
names = [tool.name for tool in tools]
|
||||
assert "search_tools" in names
|
||||
assert "wf.mcp_list_connections" in names
|
||||
assert "wf.mcp_get_connection_statuses" in names
|
||||
assert "wf.mcp_list_proxy_tools" in names
|
||||
assert "fixture.personal_echo_tool" not in names
|
||||
|
||||
search_result = await client.call_tool(
|
||||
"search_tools",
|
||||
{"query": "echo text back"},
|
||||
)
|
||||
assert "fixture.personal_echo_tool" in str(search_result)
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
|
||||
def test_transparent_proxy_proxy_tool_listing_supports_filters_and_cursor() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "transparent_proxy_paged_tools_store",
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="fixture.personal",
|
||||
server="fixture",
|
||||
account="personal",
|
||||
metadata={
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
),
|
||||
ConnectionConfig(
|
||||
id="fixture.work",
|
||||
server="fixture",
|
||||
account="work",
|
||||
metadata={
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(config)
|
||||
async with client:
|
||||
first_page_result = await client.call_tool(
|
||||
"wf.mcp_list_proxy_tools",
|
||||
{"limit": 1},
|
||||
)
|
||||
first_page = _structured(first_page_result)
|
||||
assert len(first_page["tools"]) == 1
|
||||
assert first_page["nextCursor"] is not None
|
||||
assert first_page["total"] == 2
|
||||
|
||||
second_page_result = await client.call_tool(
|
||||
"wf.mcp_list_proxy_tools",
|
||||
{"limit": 1, "cursor": first_page["nextCursor"]},
|
||||
)
|
||||
second_page = _structured(second_page_result)
|
||||
assert len(second_page["tools"]) == 1
|
||||
assert (
|
||||
second_page["tools"][0]["proxy_name"]
|
||||
!= first_page["tools"][0]["proxy_name"]
|
||||
)
|
||||
|
||||
filtered_result = await client.call_tool(
|
||||
"wf.mcp_list_proxy_tools",
|
||||
{
|
||||
"connection_id": "fixture.personal",
|
||||
"query": "echo",
|
||||
"limit": 10,
|
||||
},
|
||||
)
|
||||
filtered = _structured(filtered_result)
|
||||
assert filtered["nextCursor"] is None
|
||||
assert filtered["total"] == 1
|
||||
assert filtered["tools"][0]["proxy_name"] == "fixture.personal_echo_tool"
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
|
||||
def test_transparent_proxy_admin_tools_mutate_config_file() -> None:
|
||||
tmp_path = local_temp_root() / "transparent_proxy_admin_store"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": ".wf_mcp_store",
|
||||
"connections": [
|
||||
{
|
||||
"id": "fixture.personal",
|
||||
"server": "fixture",
|
||||
"account": "personal",
|
||||
"enabled": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(config, config_path=config_path)
|
||||
async with client:
|
||||
add_result = await client.call_tool(
|
||||
"wf.mcp_add_connection",
|
||||
{
|
||||
"connection_id": "fixture.work",
|
||||
"server": "fixture",
|
||||
"account": "work",
|
||||
"enabled": False,
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert _structured(add_result) == {
|
||||
"action": "add_connection",
|
||||
"connection_id": "fixture.work",
|
||||
"ok": True,
|
||||
"requires_reload": True,
|
||||
}
|
||||
|
||||
disable_result = await client.call_tool(
|
||||
"wf.mcp_disable_connection",
|
||||
{"connection_id": "fixture.work"},
|
||||
)
|
||||
assert _structured(disable_result) == {
|
||||
"action": "update_connection",
|
||||
"connection_id": "fixture.work",
|
||||
"ok": True,
|
||||
"requires_reload": True,
|
||||
}
|
||||
|
||||
update_result = await client.call_tool(
|
||||
"wf.mcp_update_connection",
|
||||
{
|
||||
"connection_id": "fixture.work",
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": ["updated.py"],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert _structured(update_result) == {
|
||||
"action": "update_connection",
|
||||
"connection_id": "fixture.work",
|
||||
"ok": True,
|
||||
"requires_reload": True,
|
||||
}
|
||||
|
||||
config_result = await client.call_tool("wf.mcp_get_config")
|
||||
assert "fixture.work" in str(_structured(config_result))
|
||||
|
||||
remove_result = await client.call_tool(
|
||||
"wf.mcp_remove_connection",
|
||||
{"connection_id": "fixture.work"},
|
||||
)
|
||||
assert _structured(remove_result) == {
|
||||
"action": "remove_connection",
|
||||
"connection_id": "fixture.work",
|
||||
"ok": True,
|
||||
"requires_reload": True,
|
||||
}
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
config_after = load_broker_config(config_path)
|
||||
assert [connection.id for connection in config_after.connections] == [
|
||||
"fixture.personal"
|
||||
]
|
||||
|
||||
|
||||
def test_transparent_proxy_admin_reload_remounts_connections() -> None:
|
||||
tmp_path = local_temp_root() / "transparent_proxy_reload_store"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": ".wf_mcp_store",
|
||||
"connections": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(config, config_path=config_path)
|
||||
async with client:
|
||||
initial_tools = await client.list_tools()
|
||||
initial_names = [tool.name for tool in initial_tools]
|
||||
assert "fixture.personal_echo_tool" not in initial_names
|
||||
|
||||
await client.call_tool(
|
||||
"wf.mcp_add_connection",
|
||||
{
|
||||
"connection_id": "fixture.personal",
|
||||
"server": "fixture",
|
||||
"account": "personal",
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": sys.executable,
|
||||
"args": [fixture_server_path()],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
before_reload_tools = await client.list_tools()
|
||||
before_reload_names = [tool.name for tool in before_reload_tools]
|
||||
assert "fixture.personal_echo_tool" not in before_reload_names
|
||||
|
||||
reload_result = await client.call_tool("wf.mcp_reload_config")
|
||||
assert _structured(reload_result) == {
|
||||
"ok": True,
|
||||
"reloaded": True,
|
||||
"mounted_connections": ["fixture.personal"],
|
||||
"connection_count": 1,
|
||||
"enabled_connection_count": 1,
|
||||
}
|
||||
|
||||
after_reload_tools = await client.list_tools()
|
||||
after_reload_names = [tool.name for tool in after_reload_tools]
|
||||
assert "fixture.personal_echo_tool" in after_reload_names
|
||||
|
||||
result = await client.call_tool(
|
||||
"fixture.personal_echo_tool",
|
||||
{"text": "reloaded"},
|
||||
)
|
||||
assert _structured(result) == {"echoed": "reloaded"}
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
Reference in New Issue
Block a user