misc: use structured path for errors
This commit is contained in:
@@ -14,6 +14,7 @@ from ..capabilities import (
|
||||
)
|
||||
from ..connections import qualify_node_name
|
||||
from ..models import CatalogSnapshot
|
||||
from ..sdk.converters import workflow_output_schema_from_mcp_tool_schema
|
||||
|
||||
|
||||
def snapshot_from_specs(
|
||||
@@ -42,7 +43,9 @@ def snapshot_from_specs(
|
||||
description=entry.description,
|
||||
outcomes=entry.outcomes,
|
||||
input_schema=entry.input_schema,
|
||||
output_schema=entry.output_schema,
|
||||
output_schema=workflow_output_schema_from_mcp_tool_schema(
|
||||
entry.output_schema
|
||||
),
|
||||
)
|
||||
for entry in catalog.entries()
|
||||
]
|
||||
@@ -127,7 +130,9 @@ class CombinedCatalog:
|
||||
"description": entry.description,
|
||||
"outcomes": list(entry.outcomes),
|
||||
"input_schema": entry.input_schema,
|
||||
"output_schema": entry.output_schema,
|
||||
"output_schema": workflow_output_schema_from_mcp_tool_schema(
|
||||
entry.output_schema
|
||||
),
|
||||
}
|
||||
for entry in self.entries()
|
||||
],
|
||||
|
||||
@@ -50,6 +50,7 @@ from ...models import (
|
||||
RawWorkflowPlan,
|
||||
)
|
||||
from ...runtime import ToolExecutor
|
||||
from ...sdk.converters import workflow_output_schema_from_mcp_tool_schema
|
||||
from ...sdk import BackendAdapter
|
||||
from ...shared.errors import error_payload
|
||||
from ...shared.names import RESERVED_CONNECTION_IDS
|
||||
@@ -875,7 +876,8 @@ class WfMcpService:
|
||||
"""
|
||||
model_prefix = entry.qualified_name.replace(".", "_").replace("-", "_")
|
||||
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
|
||||
output_model = _model_from_schema(f"{model_prefix}_Output", entry.output_schema)
|
||||
output_schema = workflow_output_schema_from_mcp_tool_schema(entry.output_schema)
|
||||
output_model = _model_from_schema(f"{model_prefix}_Output", output_schema)
|
||||
|
||||
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
|
||||
connection = self.connections.get(entry.connection_id)
|
||||
@@ -901,7 +903,7 @@ class WfMcpService:
|
||||
is_async=True,
|
||||
accepts_context=False,
|
||||
input_schema_contract=entry.input_schema,
|
||||
output_schema_contract=entry.output_schema,
|
||||
output_schema_contract=output_schema,
|
||||
)
|
||||
|
||||
def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mcp.types import CallToolResult as McpCallToolResult
|
||||
from mcp.types import Prompt as McpPrompt
|
||||
from mcp.types import Resource as McpResource
|
||||
@@ -11,10 +13,7 @@ from .base import ToolCallResult
|
||||
|
||||
def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||
"""Convert an MCP SDK tool into the broker discovery model."""
|
||||
output_schema = tool.outputSchema or {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "array"}},
|
||||
}
|
||||
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.outputSchema)
|
||||
display_name = (
|
||||
tool.annotations.title
|
||||
if tool.annotations is not None and tool.annotations.title
|
||||
@@ -31,6 +30,22 @@ def tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||
)
|
||||
|
||||
|
||||
def workflow_output_schema_from_mcp_tool_schema(
|
||||
schema: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the MCP tool output schema without inventing workflow fields.
|
||||
|
||||
MCP tools without structured output expose raw content blocks. Those blocks
|
||||
can be text, images, resource links, or mixed results, so wf_mcp must not
|
||||
pretend there is a stable top-level ``text`` field. Workflow authors should
|
||||
add an explicit wrapper/extraction node for the block shape they expect.
|
||||
"""
|
||||
return schema or {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "array"}},
|
||||
}
|
||||
|
||||
|
||||
def resource_to_discovered(resource: McpResource) -> DiscoveredResource:
|
||||
"""Convert an MCP SDK resource into the broker discovery model."""
|
||||
local_name = resource.name or str(resource.uri)
|
||||
@@ -64,7 +79,7 @@ def tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
|
||||
if result.structuredContent is not None:
|
||||
output = result.structuredContent
|
||||
else:
|
||||
output = {
|
||||
output: dict[str, Any] = {
|
||||
"content": [item.model_dump(by_alias=True) for item in result.content]
|
||||
}
|
||||
return ToolCallResult(
|
||||
|
||||
@@ -13,6 +13,7 @@ from wf_mcp.broker.events import McpEvent, make_event
|
||||
from ..capabilities import DiscoveredTool
|
||||
from ..models import AuthRecord, ConnectionConfig
|
||||
from ..runtime import ToolExecutor
|
||||
from ..sdk.converters import workflow_output_schema_from_mcp_tool_schema
|
||||
|
||||
|
||||
_JSON_TYPE_MAP: dict[str, object] = {
|
||||
@@ -128,9 +129,10 @@ def wrap_discovered_tool(
|
||||
f"{connection.id}_{tool.name}_Input",
|
||||
tool.input_schema,
|
||||
)
|
||||
output_schema = workflow_output_schema_from_mcp_tool_schema(tool.output_schema)
|
||||
output_model = _model_from_schema(
|
||||
f"{connection.id}_{tool.name}_Output",
|
||||
tool.output_schema,
|
||||
output_schema,
|
||||
)
|
||||
|
||||
async def invoke_tool(
|
||||
@@ -180,5 +182,5 @@ def wrap_discovered_tool(
|
||||
description=tool.description,
|
||||
is_async=True,
|
||||
input_schema_contract=tool.input_schema,
|
||||
output_schema_contract=tool.output_schema,
|
||||
output_schema_contract=output_schema,
|
||||
)
|
||||
|
||||
@@ -69,7 +69,10 @@ from .run_lifecycle import (
|
||||
restore_interrupted_run,
|
||||
validate_pinned_resume_environment,
|
||||
)
|
||||
from .wrapper_hints import wrapper_hints_for_capability
|
||||
from .wrapper_hints import (
|
||||
workflow_output_schema_for_authoring,
|
||||
wrapper_hints_for_capability,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wf_core import RunState
|
||||
@@ -138,7 +141,9 @@ class WorkflowSurfaceHandlers:
|
||||
"outcomes": list(detail.outcomes),
|
||||
"is_async": detail.is_async,
|
||||
"input_fields": _schema_field_names(detail.input_schema),
|
||||
"output_fields": _schema_field_names(detail.output_schema),
|
||||
"output_fields": _schema_field_names(
|
||||
workflow_output_schema_for_authoring(detail.output_schema)
|
||||
),
|
||||
}
|
||||
for source in sorted(
|
||||
self.service.capability_sources.values(),
|
||||
@@ -716,7 +721,7 @@ class WorkflowSurfaceHandlers:
|
||||
output: Sequence[OutputBinding] | None = None,
|
||||
input_map: dict[str, str] | None = None,
|
||||
output_map: dict[str, str] | None = None,
|
||||
error_message_source: str | None = None,
|
||||
error_message_source: str | GraphSourcePath | None = None,
|
||||
title: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Bootstrap the smallest patchable draft around one workflow capability."""
|
||||
@@ -787,7 +792,7 @@ class WorkflowSurfaceHandlers:
|
||||
output: Sequence[OutputBinding] | None = None,
|
||||
input_map: dict[str, str] | None = None,
|
||||
output_map: dict[str, str] | None = None,
|
||||
error_message_source: str | None = None,
|
||||
error_message_source: str | GraphSourcePath | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a patchable draft workspace from inspect_capability hints."""
|
||||
capability = await self.inspect_capability(qualified_name=capability_name)
|
||||
@@ -1339,8 +1344,9 @@ def _draft_output_bindings_payload(output_map: dict[str, str]) -> list[dict[str,
|
||||
]
|
||||
|
||||
|
||||
def _graph_path_payload(value: str) -> dict[str, str | list[str]]:
|
||||
return GraphSourcePath._serialize(GraphSourcePath.parse(value))
|
||||
def _graph_path_payload(value: str | GraphSourcePath) -> dict[str, str | list[str]]:
|
||||
path = value if isinstance(value, GraphSourcePath) else GraphSourcePath.parse(value)
|
||||
return GraphSourcePath._serialize(path)
|
||||
|
||||
|
||||
def _local_path_payload(value: str) -> dict[str, str | list[str]]:
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
from wf_artifacts import ArtifactKind
|
||||
from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN
|
||||
from wf_core.models.steps import InputBinding, OutputBinding
|
||||
from wf_core.paths import GraphSourcePath
|
||||
|
||||
WorkspaceId = Annotated[
|
||||
str,
|
||||
@@ -67,6 +68,16 @@ SourceBindings = Annotated[
|
||||
)
|
||||
),
|
||||
]
|
||||
ErrorMessageSource = Annotated[
|
||||
GraphSourcePath | str,
|
||||
Field(
|
||||
description=(
|
||||
"State path for runtime_error.message. Prefer structural paths such "
|
||||
"as {'root': 'state', 'parts': ['error_message']}; strings like "
|
||||
"state.error_message remain compatibility input."
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class CallCapabilityResult(BaseModel):
|
||||
@@ -276,7 +287,7 @@ class CreateMinimalDraftWorkspaceRequest(BaseModel):
|
||||
"workflow state paths, for example {'echoed': 'state.echoed'}."
|
||||
),
|
||||
)
|
||||
error_message_source: str | None = Field(
|
||||
error_message_source: ErrorMessageSource | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional state path used as runtime_error.message when the capability "
|
||||
@@ -332,7 +343,7 @@ class CreateDraftWorkspaceFromCapabilityRequest(BaseModel):
|
||||
"Deprecated compatibility override for the hinted capability output map."
|
||||
),
|
||||
)
|
||||
error_message_source: str | None = Field(
|
||||
error_message_source: ErrorMessageSource | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional state path used as runtime_error.message when the capability "
|
||||
|
||||
@@ -103,22 +103,26 @@ def wrapper_hints_for_capability(
|
||||
create routes by itself.
|
||||
"""
|
||||
input_properties = _object_properties(input_schema)
|
||||
output_properties = _object_properties(output_schema)
|
||||
hint_output_schema = workflow_output_schema_for_authoring(output_schema)
|
||||
output_properties = _object_properties(hint_output_schema)
|
||||
input_map = {f"input.{name}": name for name in sorted(input_properties)}
|
||||
output_map = {name: f"state.{name}" for name in sorted(output_properties)}
|
||||
output_map_properties = _default_output_map_properties(
|
||||
output_schema, output_properties
|
||||
)
|
||||
output_map = {name: f"state.{name}" for name in sorted(output_map_properties)}
|
||||
state_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
name: schema for name, schema in sorted(output_properties.items())
|
||||
name: schema for name, schema in sorted(output_map_properties.items())
|
||||
},
|
||||
}
|
||||
wrapper_output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
name: schema for name, schema in sorted(output_properties.items())
|
||||
name: schema for name, schema in sorted(output_map_properties.items())
|
||||
},
|
||||
}
|
||||
missing_decisions = _missing_decisions_for_output(output_schema)
|
||||
missing_decisions = _missing_decisions_for_output(hint_output_schema)
|
||||
outcome_candidates = _boolean_outcome_candidates(output_properties)
|
||||
if outcome_candidates:
|
||||
missing_decisions.append(
|
||||
@@ -132,10 +136,23 @@ def wrapper_hints_for_capability(
|
||||
)
|
||||
confidence = _confidence_for_hint(
|
||||
input_schema=input_schema,
|
||||
output_schema=output_schema,
|
||||
output_schema=hint_output_schema,
|
||||
missing_decisions=missing_decisions,
|
||||
outcome_candidates=outcome_candidates,
|
||||
)
|
||||
notes = [
|
||||
"Hints are scaffolding, not semantic guarantees.",
|
||||
(
|
||||
"Declared outcomes are preserved; output-field outcome "
|
||||
"inference is not automatic."
|
||||
),
|
||||
]
|
||||
if _has_raw_mcp_content(output_schema):
|
||||
notes.append(
|
||||
"Raw MCP content blocks are not workflow-shaped. Use an explicit "
|
||||
"wrapper or extraction node to handle TextContent, ResourceLink, "
|
||||
"images, or mixed content before writing to typed state."
|
||||
)
|
||||
return WrapperAuthoringHints(
|
||||
capability_name=capability_name,
|
||||
confidence=confidence,
|
||||
@@ -149,16 +166,20 @@ def wrapper_hints_for_capability(
|
||||
output_map=output_map,
|
||||
outcome_candidates=outcome_candidates,
|
||||
missing_decisions=missing_decisions,
|
||||
notes=[
|
||||
"Hints are scaffolding, not semantic guarantees.",
|
||||
(
|
||||
"Declared outcomes are preserved; output-field outcome "
|
||||
"inference is not automatic."
|
||||
),
|
||||
],
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def workflow_output_schema_for_authoring(output_schema: JsonObject) -> JsonObject:
|
||||
"""Return the workflow-author-facing output schema for one capability.
|
||||
|
||||
Raw MCP ``content`` blocks stay raw. A wrapper may choose to extract
|
||||
``content[0].text`` or handle resources/images, but the authoring surface
|
||||
must not invent that decision as a top-level schema field.
|
||||
"""
|
||||
return {"type": "object", "properties": _object_properties(output_schema)}
|
||||
|
||||
|
||||
def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
|
||||
"""Return object properties that are themselves JSON Schema objects."""
|
||||
properties = schema.get("properties")
|
||||
@@ -171,6 +192,32 @@ def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
|
||||
}
|
||||
|
||||
|
||||
def _has_raw_mcp_content(schema: JsonObject) -> bool:
|
||||
"""Return true when schema exposes MCP's raw content-block envelope."""
|
||||
properties = _object_properties(schema)
|
||||
content_schema = properties.get("content")
|
||||
return isinstance(content_schema, dict) and content_schema.get("type") == "array"
|
||||
|
||||
|
||||
def _default_output_map_properties(
|
||||
raw_output_schema: JsonObject,
|
||||
output_properties: dict[str, JsonObject],
|
||||
) -> dict[str, JsonObject]:
|
||||
"""Return fields safe enough to wire by default in generated hints.
|
||||
|
||||
Raw MCP ``content`` is a protocol envelope, not a workflow value. Keeping it
|
||||
out of the default map prevents the scaffold from writing text/image/resource
|
||||
blocks into typed state without an explicit extraction wrapper.
|
||||
"""
|
||||
if _has_raw_mcp_content(raw_output_schema):
|
||||
return {
|
||||
name: schema
|
||||
for name, schema in output_properties.items()
|
||||
if name != "content"
|
||||
}
|
||||
return output_properties
|
||||
|
||||
|
||||
def _missing_decisions_for_output(output_schema: JsonObject) -> list[MissingDecision]:
|
||||
"""Return explicit decisions required by output schema shape."""
|
||||
properties = _object_properties(output_schema)
|
||||
|
||||
Reference in New Issue
Block a user