misc: use structured path for errors

This commit is contained in:
lda
2026-05-28 23:25:10 +07:00 Verified
parent ffd352b660
commit 4bad1c4e2c
18 changed files with 617 additions and 35 deletions
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from mcp.types import CallToolResult, TextContent, Tool
from wf_mcp.sdk.converters import tool_result_to_call_result, tool_to_discovered
def test_tool_without_output_schema_exposes_raw_content_schema() -> None:
tool = Tool(
name="echo",
inputSchema={"type": "object", "properties": {}},
)
discovered = tool_to_discovered(tool)
properties = discovered.output_schema["properties"]
assert properties["content"]["type"] == "array"
assert "text" not in properties
def test_tool_with_content_only_output_schema_stays_raw() -> None:
tool = Tool(
name="echo",
inputSchema={"type": "object", "properties": {}},
outputSchema={
"type": "object",
"properties": {
"content": {
"type": "array",
"description": "Raw MCP content blocks.",
}
},
"required": ["content"],
},
)
discovered = tool_to_discovered(tool)
properties = discovered.output_schema["properties"]
assert properties["content"]["type"] == "array"
assert "text" not in properties
assert discovered.output_schema["required"] == ["content"]
def test_tool_result_single_text_content_block_stays_in_content() -> None:
result = CallToolResult(
content=[TextContent(type="text", text="Echo: hello")],
)
converted = tool_result_to_call_result(result)
assert converted.outcome == "ok"
assert "text" not in converted.output
assert converted.output["content"][0]["type"] == "text"
assert converted.output["content"][0]["text"] == "Echo: hello"
def test_tool_result_structured_content_is_not_rewritten() -> None:
result = CallToolResult(
content=[TextContent(type="text", text="ignored")],
structuredContent={"value": "structured"},
)
converted = tool_result_to_call_result(result)
assert converted.output["value"] == "structured"
assert "text" not in converted.output
+112
View File
@@ -8,6 +8,7 @@ from wf_artifacts import FileDraftWorkspaceStore, WorkflowDeployment
from wf_authoring import NodeSpec, build_async_registry, node
from wf_core import END, NodeUse, RunStatus, RuntimeContext
from wf_mcp.broker import WfMcpService
from wf_mcp.capabilities import DiscoveredTool
from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan
from wf_mcp.runtime import ToolExecutor
from wf_mcp.sdk import ToolCallResult
@@ -37,6 +38,46 @@ def pro_dotted_echo_tool(payload: EchoInput) -> EchoOutput:
return EchoOutput(echoed=f"pro:{payload.text}")
class ContentOnlyOutputAdapter(FakeAdapter):
"""Adapter fixture for MCP tools that expose the raw content envelope."""
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": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {"content": {"type": "array"}},
"required": ["content"],
},
)
]
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
message = payload.get("message", "")
return ToolCallResult(
outcome="ok",
output={"content": [{"type": "text", "text": f"Echo: {message}"}]},
)
def _single_echo_plan(plan_name: str, node_name: str) -> RawWorkflowPlan:
return _raw_plan(
name=plan_name,
@@ -855,6 +896,28 @@ def test_service_catalog_preserves_json_schema_description_metadata() -> None:
assert isinstance(node["output_schema"]["properties"], dict)
def test_service_preserves_content_only_tool_output_schema_for_workflows() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "content_only_output_schema_store")
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", ContentOnlyOutputAdapter())
asyncio.run(service.refresh_connection_catalog("demo.personal"))
catalog_node = service.get_catalog().as_payload()["nodes"][0]
source = service.capability_sources["demo.personal"]
spec = source.capabilities.node_specs["demo.personal.echo_tool"]
assert catalog_node["output_schema"]["properties"]["content"]["type"] == "array"
assert "text" not in catalog_node["output_schema"]["properties"]
assert spec.output_schema_contract is not None
assert spec.output_schema_contract["properties"]["content"]["type"] == "array"
assert "text" not in spec.output_schema_contract["properties"]
def test_service_wrapped_tool_adapter_model_validates_simple_schema_types() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "schema_model_store"))
service.register_connection(
@@ -939,6 +1002,55 @@ def test_service_records_tool_call_events() -> None:
assert tool_events[1].payload["outcome"] == "ok"
def test_service_rejects_text_binding_for_raw_mcp_content_contract() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "raw_content_contract"))
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", ContentOnlyOutputAdapter())
asyncio.run(service.refresh_connection_catalog("demo.personal"))
plan = _raw_plan(
name="raw_content_contract",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
state_schema={"properties": {"outline": {"type": "string"}}},
output_schema={
"type": "object",
"properties": {"outline": {"type": "string"}},
"required": ["outline"],
},
output=[
{
"target": {"root": "local", "parts": ["outline"]},
"path": {"root": "state", "parts": ["outline"]},
}
],
start="echo",
nodes=[
{
"id": "echo",
"type": "node",
"node": "demo.personal.echo_tool",
"input": [input_binding("input.text", "message")],
"output": [output_binding("text", "state.outline")],
}
],
edges=[{"from": "echo", "outcome": "ok", "to": END}],
)
workflow = service.compile_plan(plan)
report = workflow.validate_structure()
assert not report.ok
assert any(
"source field 'text' is not declared in node output schema" in issue.message
for issue in report.errors
)
def test_service_can_inspect_resources_and_prompts() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "inspect_store"))
service.register_connection(
+158 -2
View File
@@ -13,11 +13,14 @@ from wf_artifacts import (
)
from wf_authoring import node, reducer
from wf_mcp.broker import WfMcpService
from wf_mcp.models import ConnectionConfig, RawWorkflowPlan
from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan
from wf_mcp.sdk import ToolCallResult
from wf_mcp.storage import FileStore
from wf_mcp.workflow_surface import TraceRange, WorkflowSurfaceHandlers
from wf_mcp.workflow_surface.models import CreateMinimalDraftWorkspaceRequest
from wf_core.models.steps import InputPathBinding, OutputBinding
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_mcp.capabilities import DiscoveredTool
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
@@ -65,6 +68,102 @@ def failing_tool(payload: ChangedEchoInput) -> ChangedEchoOutput:
raise RuntimeError("upstream exploded")
class ContentOnlyOutputAdapter:
"""MCP-like adapter whose tool exposes raw content blocks as output schema."""
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]:
return [
DiscoveredTool(
name="echo",
title="Echo",
description="Echo a message as an MCP text content block.",
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {"content": {"type": "array"}},
"required": ["content"],
},
)
]
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[Any]:
return []
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[Any]:
return []
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]:
return {"server": connection.server}
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
message = payload.get("message", "")
return ToolCallResult(
outcome="ok",
output={"content": [{"type": "text", "text": f"Echo: {message}"}]},
)
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]:
raise KeyError(uri)
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
raise KeyError(prompt_name)
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
raise KeyError(method)
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None:
raise KeyError(method)
@reducer(name="custom.default.multiply")
def multiply(current: int | None, incoming: int) -> int:
return (current or 1) * incoming
@@ -830,7 +929,7 @@ def test_workflow_surface_minimal_draft_honors_explicit_error_message_source() -
output_schema={"type": "object"},
input_map={"input.text": "text"},
output_map={"echoed": "state.echoed"},
error_message_source="state.error_message",
error_message_source=GraphSourcePath.state("error_message"),
)
)
assert service.draft_workspace_store is not None
@@ -844,6 +943,27 @@ def test_workflow_surface_minimal_draft_honors_explicit_error_message_source() -
]
def test_minimal_draft_request_accepts_structural_error_message_source() -> None:
request = CreateMinimalDraftWorkspaceRequest.model_validate(
{
"workspace_id": "echo_draft_structural_error",
"name": "echo",
"capability_name": "demo.personal.mcp_echo_tool",
"input_schema": {"type": "object"},
"state_schema": {"type": "object"},
"output_schema": {"type": "object"},
"error_message_source": {
"root": "state",
"parts": ["error_message"],
},
}
)
assert isinstance(request.error_message_source, GraphSourcePath)
assert request.error_message_source.root == "state"
assert request.error_message_source.parts == ("error_message",)
def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_minimal_canonical_mcp"),
@@ -937,6 +1057,42 @@ def test_workflow_surface_creates_draft_workspace_from_capability_hints() -> Non
]
def test_workflow_surface_does_not_auto_map_raw_mcp_content_blocks() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_content_only_content_hint"
)
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_content_only_content_hint_mcp"),
artifact_store=artifact_store,
)
service.register_connection(
ConnectionConfig(
id="everything.default", server="everything", account="default"
)
)
service.register_adapter("everything", ContentOnlyOutputAdapter())
asyncio.run(service.refresh_connection_catalog("everything.default"))
handlers = WorkflowSurfaceHandlers(service)
inspected = asyncio.run(
handlers.inspect_capability(qualified_name="everything.default.echo")
)
created = asyncio.run(
handlers.create_draft_workspace_from_capability(
workspace_id="content_blocks",
capability_name="everything.default.echo",
name="content_blocks",
)
)
assert inspected["wrapper_hints"]["output_map"] == {}
assert created["wrapper_hints"]["output_map"] == {}
assert inspected["wrapper_hints"]["missing_decisions"][0]["kind"] == (
"review_nested_output"
)
assert "Raw MCP content blocks" in inspected["wrapper_hints"]["notes"][2]
def test_workflow_surface_creates_artifact_from_workspace() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_workspace_artifact"
@@ -154,6 +154,58 @@ def test_wrapper_hints_mark_nested_outputs_as_low_confidence() -> None:
assert dumped["output_map"] == {"results": "state.results"}
def test_wrapper_hints_do_not_auto_map_raw_mcp_content_blocks() -> None:
hints = wrapper_hints_for_capability(
capability_name="everything.default.echo",
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {
"content": {"type": "array"},
},
},
outcomes=["ok", "error"],
)
dumped = hints.model_dump(mode="json")
assert dumped["confidence"] == "low"
assert dumped["output_map"] == {}
assert "content" not in dumped["state_schema"]["properties"]
assert dumped["missing_decisions"][0]["kind"] == "review_nested_output"
assert "Raw MCP content blocks" in dumped["notes"][2]
def test_wrapper_hints_keep_content_only_mcp_output_explicit() -> None:
hints = wrapper_hints_for_capability(
capability_name="everything.default.echo",
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {"content": {"type": "array"}},
"required": ["content"],
},
outcomes=["ok", "error"],
)
dumped = hints.model_dump(mode="json")
assert dumped["confidence"] == "low"
assert dumped["output_map"] == {}
assert "content" not in dumped["state_schema"]["properties"]
assert dumped["output_schema"]["properties"] == {}
assert dumped["missing_decisions"][0]["kind"] == "review_nested_output"
assert "TextContent" in dumped["notes"][2]
def test_wrapper_hints_mark_empty_output_schema_as_low_confidence() -> None:
hints = wrapper_hints_for_capability(
capability_name="demo.personal.no_output",
+62
View File
@@ -29,6 +29,28 @@ class RecordingAdapter:
return ToolCallResult(outcome="ok", output={})
class TextContentAdapter:
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
message = payload.get("message", "")
return ToolCallResult(
outcome="ok",
output={
"content": [
{
"type": "text",
"text": f"Echo: {message}",
}
],
},
)
def test_discovered_tool_wrapper_omits_unset_optional_arguments() -> None:
adapter = RecordingAdapter()
spec = wrap_discovered_tool(
@@ -66,3 +88,43 @@ def test_discovered_tool_wrapper_omits_unset_optional_arguments() -> None:
assert adapter.payloads[0] == {}
assert adapter.payloads[1] == {"target": "main"}
def test_discovered_tool_wrapper_preserves_raw_mcp_content_output() -> None:
spec = wrap_discovered_tool(
connection=ConnectionConfig(
id="everything.default",
server="everything",
account="default",
),
auth=None,
executor=cast(ToolExecutor, TextContentAdapter()),
tool=DiscoveredTool(
name="echo",
title="Echo",
description=None,
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {"content": {"type": "array"}},
},
),
)
handler = build_async_registry(spec)[spec.name]
async def run_call() -> dict[str, Any]:
return await handler(
{"message": "hello"},
RuntimeContext(current_node_id="echo"),
)
result = asyncio.run(run_call())
assert result["outcome"] == "ok"
assert "text" not in result["output"]
assert result["output"]["content"][0]["type"] == "text"
assert result["output"]["content"][0]["text"] == "Echo: hello"