478 lines
18 KiB
Python
478 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.client.transports.memory import FastMCPTransport
|
|
|
|
from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore
|
|
from wf_core.models.steps import InputPathBinding, InputValueBinding, OutputBinding
|
|
from wf_mcp.broker import WfMcpService
|
|
from wf_mcp.models import BrokerConfig
|
|
from wf_mcp.server import create_server_client
|
|
from wf_mcp.storage import FileStore
|
|
from wf_mcp.workflow_surface.tools import register_workflow_tools
|
|
|
|
from .conftest import assert_safe_tool_maps, server_config, structured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
|
|
config = server_config()
|
|
|
|
client = create_server_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 "call_tool" in names
|
|
assert "wf.admin.list_sources" in names
|
|
assert "wf.admin.list_connections" in names
|
|
assert "wf.admin.get_connection_statuses" in names
|
|
assert "wf.admin.reload_config" in names
|
|
assert "wf.admin.list_proxy_tools" in names
|
|
assert "wf.admin.get_proxy_tool" in names
|
|
assert "wf.workflow.list_artifacts" in names
|
|
assert "wf.workflow.list_capabilities" in names
|
|
assert "wf.workflow.inspect_capability" in names
|
|
assert "wf.workflow.call_capability" in names
|
|
assert "wf.workflow.list_draft_workspaces" in names
|
|
assert "wf.workflow.create_draft_workspace" in names
|
|
assert "wf.workflow.get_draft_workspace" in names
|
|
assert "wf.workflow.delete_draft_workspace" in names
|
|
assert "wf.workflow.patch_draft_workspace" in names
|
|
assert "wf.workflow.validate_draft_workspace" in names
|
|
assert "wf.workflow.set_draft_name" in names
|
|
assert "wf.workflow.set_draft_route" in names
|
|
assert "wf.workflow.set_step_input_bindings" in names
|
|
assert "wf.workflow.set_step_output_bindings" in names
|
|
assert "wf.workflow.set_step_input_map" in names
|
|
assert "wf.workflow.set_step_output_map" in names
|
|
assert "wf.workflow.set_workflow_output_bindings" in names
|
|
assert "wf.workflow.set_workflow_output_map" in names
|
|
assert "wf.workflow.bind" in names
|
|
assert "wf.workflow.update_capability_step" in names
|
|
assert "wf.workflow.remove_draft_route" in names
|
|
assert "wf.workflow.remove_draft_step" in names
|
|
assert "wf.workflow.remove_draft_binding" in names
|
|
assert "wf.workflow.create_minimal_draft_workspace" in names
|
|
assert "wf.workflow.create_artifact_from_workspace" in names
|
|
assert "wf.workflow.create_wrapper_from_workspace" in names
|
|
assert "wf.workflow.inspect_artifact" in names
|
|
assert "wf.workflow.list_deployments" in names
|
|
assert "wf.workflow.inspect_deployment" in names
|
|
assert "wf.workflow.save_deployment" in names
|
|
assert "wf.workflow.delete_deployment" in names
|
|
assert "wf.workflow.validate_deployment" in names
|
|
assert "wf.workflow.run_deployment" in names
|
|
|
|
assert "wf.workflow.validate_draft" not in names
|
|
assert "wf.workflow.compile_draft" not in names
|
|
assert "wf.workflow.create_artifact_from_plan" not in names
|
|
assert "wf.workflow.create_artifact_from_draft" not in names
|
|
assert "wf.workflow.patch_draft" not in names
|
|
assert "wf.admin.call_tool" not in names
|
|
assert "fixture.personal.echo_tool" not in names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registered_output_bindings_tool_delegates_typed_bindings_once(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class RecordingWorkflowHandler:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def set_step_output_bindings(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.calls.append(kwargs)
|
|
return {
|
|
"workspace_id": kwargs["workspace_id"],
|
|
"revision": kwargs["revision"] + 1,
|
|
"status": "valid",
|
|
"diagnostics": [],
|
|
"summary": {},
|
|
}
|
|
|
|
recorder = RecordingWorkflowHandler()
|
|
monkeypatch.setattr(
|
|
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
|
lambda _context, **_kwargs: recorder,
|
|
)
|
|
service = WfMcpService(
|
|
store=FileStore(tmp_path / "tool_invocation_store"),
|
|
artifact_store=FileWorkflowArtifactStore(
|
|
tmp_path / "tool_invocation_artifacts"
|
|
),
|
|
draft_workspace_store=FileDraftWorkspaceStore(
|
|
tmp_path / "tool_invocation_drafts"
|
|
),
|
|
)
|
|
server = FastMCP("task-3-tool-test")
|
|
register_workflow_tools(server, service)
|
|
|
|
async with Client(FastMCPTransport(server)) as client:
|
|
result = await client.call_tool(
|
|
"wf.workflow.set_step_output_bindings",
|
|
{
|
|
"request": {
|
|
"workspace_id": "draft-output",
|
|
"revision": 4,
|
|
"step_id": "analyze",
|
|
"bindings": [
|
|
{"source": "report.title", "target": "state.report.title"},
|
|
{"source": "report.title", "target": "state.audit.title"},
|
|
],
|
|
}
|
|
},
|
|
)
|
|
|
|
assert structured(result)["revision"] == 5
|
|
assert len(recorder.calls) == 1
|
|
call = recorder.calls[0]
|
|
assert call["workspace_id"] == "draft-output"
|
|
assert call["revision"] == 4
|
|
assert call["step_id"] == "analyze"
|
|
bindings = call["bindings"]
|
|
assert all(isinstance(binding, OutputBinding) for binding in bindings)
|
|
assert [(str(binding.source), str(binding.target)) for binding in bindings] == [
|
|
("report.title", "state.report.title"),
|
|
("report.title", "state.audit.title"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registered_workflow_output_bindings_tool_preserves_union_order(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class RecordingWorkflowHandler:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def set_workflow_output_bindings(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.calls.append(kwargs)
|
|
return {
|
|
"workspace_id": kwargs["workspace_id"],
|
|
"revision": kwargs["revision"] + 1,
|
|
"status": "valid",
|
|
"diagnostics": [],
|
|
"summary": {},
|
|
}
|
|
|
|
recorder = RecordingWorkflowHandler()
|
|
monkeypatch.setattr(
|
|
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
|
lambda _context, **_kwargs: recorder,
|
|
)
|
|
service = WfMcpService(
|
|
store=FileStore(tmp_path / "workflow_output_tool_store"),
|
|
artifact_store=FileWorkflowArtifactStore(
|
|
tmp_path / "workflow_output_tool_artifacts"
|
|
),
|
|
draft_workspace_store=FileDraftWorkspaceStore(
|
|
tmp_path / "workflow_output_tool_drafts"
|
|
),
|
|
)
|
|
server = FastMCP("workflow-output-bindings-test")
|
|
register_workflow_tools(server, service)
|
|
|
|
async with Client(FastMCPTransport(server)) as client:
|
|
result = await client.call_tool(
|
|
"wf.workflow.set_workflow_output_bindings",
|
|
{
|
|
"request": {
|
|
"workspace_id": "draft-output",
|
|
"revision": 4,
|
|
"bindings": [
|
|
{
|
|
"path": "state.report.title",
|
|
"target": "report.title",
|
|
},
|
|
{"value": "markdown", "target": "format"},
|
|
],
|
|
}
|
|
},
|
|
)
|
|
|
|
assert structured(result)["revision"] == 5
|
|
call = recorder.calls[0]
|
|
assert isinstance(call["bindings"][0], InputPathBinding)
|
|
assert isinstance(call["bindings"][1], InputValueBinding)
|
|
assert [binding.model_dump(mode="json") for binding in call["bindings"]] == [
|
|
{"path": "state.report.title", "target": "report.title"},
|
|
{"value": "markdown", "target": "format"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registered_capability_tools_delegate_presence_aware_requests(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class RecordingWorkflowHandler:
|
|
def __init__(self) -> None:
|
|
self.update_calls: list[dict[str, Any]] = []
|
|
self.add_calls: list[dict[str, Any]] = []
|
|
|
|
async def update_capability_step(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.update_calls.append(kwargs)
|
|
return {
|
|
"workspace_id": kwargs["workspace_id"],
|
|
"revision": kwargs["revision"] + 1,
|
|
"status": "valid",
|
|
"diagnostics": [],
|
|
"summary": {},
|
|
}
|
|
|
|
async def add_step_from_capability(self, **kwargs: Any) -> dict[str, Any]:
|
|
self.add_calls.append(kwargs)
|
|
return {
|
|
"workspace_id": kwargs["workspace_id"],
|
|
"revision": kwargs["revision"] + 1,
|
|
"status": "valid",
|
|
"diagnostics": [],
|
|
"summary": {},
|
|
}
|
|
|
|
recorder = RecordingWorkflowHandler()
|
|
monkeypatch.setattr(
|
|
"wf_mcp.workflow_surface.tools.WorkflowApi",
|
|
lambda _context, **_kwargs: recorder,
|
|
)
|
|
service = WfMcpService(
|
|
store=FileStore(tmp_path / "capability_tool_store"),
|
|
artifact_store=FileWorkflowArtifactStore(
|
|
tmp_path / "capability_tool_artifacts"
|
|
),
|
|
draft_workspace_store=FileDraftWorkspaceStore(
|
|
tmp_path / "capability_tool_drafts"
|
|
),
|
|
)
|
|
server = FastMCP("capability-update-tool-test")
|
|
register_workflow_tools(server, service)
|
|
|
|
async with Client(FastMCPTransport(server)) as client:
|
|
updated = await client.call_tool(
|
|
"wf.workflow.update_capability_step",
|
|
{
|
|
"request": {
|
|
"workspace_id": "report",
|
|
"revision": 4,
|
|
"step_id": "publish",
|
|
"update": {
|
|
"desc": None,
|
|
"input": [
|
|
{
|
|
"value": "markdown",
|
|
"target": "request.format",
|
|
}
|
|
],
|
|
},
|
|
}
|
|
},
|
|
)
|
|
added = await client.call_tool(
|
|
"wf.workflow.add_step_from_capability",
|
|
{
|
|
"request": {
|
|
"workspace_id": "report",
|
|
"revision": 5,
|
|
"step_id": "archive",
|
|
"capability_name": "local.report.archive",
|
|
"input_bindings": [
|
|
{
|
|
"path": "state.report.title",
|
|
"target": "request.title",
|
|
},
|
|
{"value": "pdf", "target": "request.format"},
|
|
],
|
|
"desc": "Archive report",
|
|
"retry": 0,
|
|
"timeout_seconds": 20,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert structured(updated)["revision"] == 5
|
|
assert structured(added)["revision"] == 6
|
|
assert len(recorder.update_calls) == 1
|
|
update = recorder.update_calls[0]["update"]
|
|
assert update.model_fields_set == {"desc", "input"}
|
|
assert isinstance(update.input[0], InputValueBinding)
|
|
assert len(recorder.add_calls) == 1
|
|
add_call = recorder.add_calls[0]
|
|
assert add_call["input_map"] is None
|
|
assert [
|
|
binding.model_dump(mode="json") for binding in add_call["input_bindings"]
|
|
] == [
|
|
{"path": "state.report.title", "target": "request.title"},
|
|
{"value": "pdf", "target": "request.format"},
|
|
]
|
|
assert add_call["desc"] == "Archive report"
|
|
assert add_call["retry"] == 0
|
|
assert add_call["timeout_seconds"] == 20
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_search_mode_can_use_safe_tool_names() -> None:
|
|
config = server_config()
|
|
|
|
client = create_server_client(
|
|
config,
|
|
search_tools=True,
|
|
safe_tool_names=True,
|
|
)
|
|
async with client:
|
|
tools = await client.list_tools()
|
|
names = [tool.name for tool in tools]
|
|
|
|
assert "search_tools" in names
|
|
assert "call_tool" in names
|
|
assert "wf_admin_list_sources" in names
|
|
assert "wf_workflow_call_capability" in names
|
|
assert "wf.admin.list_sources" not in names
|
|
|
|
result = await assert_safe_tool_maps(
|
|
client,
|
|
original_name="wf.admin.list_sources",
|
|
safe_name="wf_admin_list_sources",
|
|
)
|
|
source_ids = {source["id"] for source in result["sources"]}
|
|
assert "wf.std" in source_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None:
|
|
config = server_config()
|
|
|
|
client = create_server_client(config, safe_tool_names=True)
|
|
async with client:
|
|
artifacts = await assert_safe_tool_maps(
|
|
client,
|
|
original_name="wf.workflow.list_artifacts",
|
|
safe_name="wf_workflow_list_artifacts",
|
|
)
|
|
echo = await assert_safe_tool_maps(
|
|
client,
|
|
original_name="fixture.personal.echo_tool",
|
|
safe_name="fixture_personal_echo_tool",
|
|
arguments={"text": "hello"},
|
|
)
|
|
|
|
assert artifacts["nodes"] == []
|
|
assert artifacts["total"] == 0
|
|
assert echo["echoed"] == "hello"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_tools_have_human_metadata(tmp_path: Path) -> None:
|
|
config = BrokerConfig(
|
|
store_root=tmp_path / "unified_metadata_store",
|
|
connections=[],
|
|
)
|
|
|
|
client = create_server_client(config, admin_tools=False)
|
|
async with client:
|
|
tools = await client.list_tools()
|
|
by_name = {tool.name: tool for tool in tools}
|
|
list_artifacts = by_name["wf.workflow.list_artifacts"]
|
|
validate_deployment = by_name["wf.workflow.validate_deployment"]
|
|
run_deployment = by_name["wf.workflow.run_deployment"]
|
|
inspect_run = by_name["wf.workflow.inspect_run"]
|
|
read_run_trace = by_name["wf.workflow.read_run_trace"]
|
|
|
|
assert list_artifacts.title == "List Workflow Artifacts"
|
|
assert "saved workflow artifacts" in (list_artifacts.description or "")
|
|
assert "query" in list_artifacts.input_schema["properties"]
|
|
assert "kind" in list_artifacts.input_schema["properties"]
|
|
assert "cursor" in list_artifacts.input_schema["properties"]
|
|
assert "limit" in list_artifacts.input_schema["properties"]
|
|
live_check_schema = validate_deployment.input_schema["properties"]["live_check"]
|
|
assert "upstream" in live_check_schema.get("description", "")
|
|
assert run_deployment.title == "Run Workflow Deployment"
|
|
assert "deployment_id" in (run_deployment.description or "")
|
|
assert "trace_range" in run_deployment.input_schema["properties"]
|
|
trace_range_schema = run_deployment.input_schema["properties"]["trace_range"]
|
|
assert "Debug traces" in trace_range_schema.get("description", "")
|
|
assert "null" in [option.get("type") for option in trace_range_schema["anyOf"]]
|
|
assert inspect_run.title == "Inspect Workflow Run"
|
|
assert "trace" in (inspect_run.description or "").lower()
|
|
read_trace_schema = read_run_trace.input_schema["properties"]["trace_range"]
|
|
assert "Debug traces" in read_trace_schema.get("description", "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_artifact_from_plan_exposes_plan_as_plain_object(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config = BrokerConfig(
|
|
store_root=tmp_path / "unified_create_artifact_schema_store",
|
|
connections=[],
|
|
)
|
|
|
|
client = create_server_client(config, admin_tools=False)
|
|
async with client:
|
|
tools = await client.list_tools()
|
|
by_name = {tool.name: tool for tool in tools}
|
|
schema = by_name["wf.workflow.create_artifact_from_plan"].input_schema
|
|
plan_schema = schema["properties"]["plan"]
|
|
|
|
assert plan_schema["type"] == "object"
|
|
assert plan_schema.get("additionalProperties") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_tools_expose_plain_object_and_patch_array_schemas(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config = BrokerConfig(
|
|
store_root=tmp_path / "unified_draft_schema_store",
|
|
connections=[],
|
|
)
|
|
|
|
client = create_server_client(config, admin_tools=False)
|
|
async with client:
|
|
tools = await client.list_tools()
|
|
by_name = {tool.name: tool for tool in tools}
|
|
|
|
validate_schema = by_name["wf.workflow.validate_draft"].input_schema
|
|
validate_draft_schema = validate_schema["properties"]["draft"]
|
|
create_schema = by_name["wf.workflow.create_artifact_from_draft"].input_schema
|
|
create_draft_schema = create_schema["properties"]["draft"]
|
|
patch_schema = by_name["wf.workflow.patch_draft"].input_schema
|
|
patch_draft_schema = patch_schema["properties"]["draft"]
|
|
patch_patch_schema = patch_schema["properties"]["patch"]
|
|
|
|
assert validate_draft_schema["type"] == "object"
|
|
assert validate_draft_schema.get("additionalProperties") is True
|
|
assert create_draft_schema["type"] == "object"
|
|
assert create_draft_schema.get("additionalProperties") is True
|
|
assert patch_draft_schema["type"] == "object"
|
|
assert patch_patch_schema["type"] == "array"
|
|
assert "$defs" not in patch_patch_schema
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_tools_have_human_metadata(tmp_path: Path) -> None:
|
|
config = BrokerConfig(
|
|
store_root=tmp_path / "unified_admin_metadata_store",
|
|
connections=[],
|
|
)
|
|
|
|
client = create_server_client(config)
|
|
async with client:
|
|
tools = await client.list_tools()
|
|
by_name = {tool.name: tool for tool in tools}
|
|
list_connections = by_name["wf.admin.list_connections"]
|
|
reload_config = by_name["wf.admin.reload_config"]
|
|
|
|
assert list_connections.title == "List Connections"
|
|
assert "configured MCP connections" in (list_connections.description or "")
|
|
assert reload_config.title == "Reload Config"
|
|
assert "remount" in (reload_config.description or "")
|