misc: use structured path for errors
This commit is contained in:
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user