workflow builder deprecated field stop use in wf_mcp

This commit is contained in:
lda
2026-05-21 05:32:40 +07:00 Verified
parent d089e28c15
commit 8df4c94c4e
13 changed files with 463 additions and 193 deletions
@@ -143,6 +143,22 @@ Use these categories:
Add a small checklist under this task before implementation. Do not blindly replace all strings. Add a small checklist under this task before implementation. Do not blindly replace all strings.
Findings from the first inventory pass:
- `src/wf_artifacts/drafts/adapter.py` is the highest-value runtime hit: it still
calls `WorkflowBuilder.use_ref(..., in_map=..., input_values=..., out_map=...)`
and `WorkflowBuilder.use(..., out_map=...)`, causing deprecation warnings from
MCP draft/workspace tests. This should be changed to canonical binding lists.
- Raw workflow-plan tests in `tests/wf_mcp/test_service.py`,
`tests/wf_mcp/test_broker_server.py`, `tests/wf_mcp/test_workflow_surface.py`,
and `tests/artifacts/test_factory.py` intentionally exercise raw-plan
compatibility. Do not bulk-rewrite those while raw-plan escape hatches remain.
- Draft model tests still use `state_schema.fields` as compatibility input. That
can stay as parse input, but new docs/examples should prefer JSON Schema
`properties`.
- Docs already explain parse-only compatibility in several places, but older
operator/runbook examples still need canonical `input` / `output` examples.
--- ---
## Task 2: Draft Adapter Emits Canonical Builder Bindings ## Task 2: Draft Adapter Emits Canonical Builder Bindings
+1 -1
View File
@@ -504,7 +504,7 @@ Concrete MCP sequence:
2. `wf.workflow.call_capability` with a small payload to verify the selected 2. `wf.workflow.call_capability` with a small payload to verify the selected
capability behaves as expected. capability behaves as expected.
3. `wf.workflow.create_minimal_draft_workspace` with a `request` object that 3. `wf.workflow.create_minimal_draft_workspace` with a `request` object that
contains schemas, `input_map`, and `output_map`. contains schemas plus canonical `input` and `output` binding lists.
4. `wf.workflow.list_draft_workspaces` if the client needs to rediscover 4. `wf.workflow.list_draft_workspaces` if the client needs to rediscover
existing workspace ids. existing workspace ids.
5. `wf.workflow.get_draft_workspace` with `include_draft=true` if the client 5. `wf.workflow.get_draft_workspace` with `include_draft=true` if the client
+12 -6
View File
@@ -364,7 +364,7 @@ Workspace patches are optimistic-concurrency guarded. Pass the current
Workspace mutation tools use a single `request` object in MCP Inspector. That Workspace mutation tools use a single `request` object in MCP Inspector. That
keeps the form grouped and lets the schema describe fields like keeps the form grouped and lets the schema describe fields like
`input_schema`, `output_map`, and `error_message_source`. `input_schema`, canonical `output`, and `error_message_source`.
Use `create_wrapper_from_workspace` when the draft is meant to normalize a raw Use `create_wrapper_from_workspace` when the draft is meant to normalize a raw
capability into a reusable workflow-facing wrapper. It is the same validation capability into a reusable workflow-facing wrapper. It is the same validation
@@ -406,12 +406,18 @@ Minimal example:
}, },
"required": ["echoed"] "required": ["echoed"]
}, },
"input_map": { "input": [
"input.text": "text" {
}, "target": {"root": "local", "parts": ["text"]},
"output_map": { "path": {"root": "input", "parts": ["text"]}
"echoed": "state.echoed"
} }
],
"output": [
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]}
}
]
} }
} }
``` ```
+2 -1
View File
@@ -406,7 +406,8 @@ Patch calls must include the current `revision`; stale revisions return
`create_minimal_draft_workspace` is intentionally only a bootstrapper. It wires `create_minimal_draft_workspace` is intentionally only a bootstrapper. It wires
an `error` outcome for naive MCP wrappers only when `error_message_source` is an `error` outcome for naive MCP wrappers only when `error_message_source` is
provided or a state path can be derived from `output_map`. Provider-specific provided or a state path can be derived from canonical `output` bindings or the
compatibility `output_map`. Provider-specific
error envelopes still belong in saved wrapper artifacts or follow-up patches. error envelopes still belong in saved wrapper artifacts or follow-up patches.
In MCP Inspector, workspace mutation tools accept a single `request` object. In MCP Inspector, workspace mutation tools accept a single `request` object.
+25 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from wf_authoring import WorkflowBuilder from wf_authoring import WorkflowBuilder
from wf_authoring.dsl import PathExpr from wf_authoring.dsl import PathExpr
from wf_core import JoinNode, Workflow from wf_core import JoinNode, Workflow
@@ -42,9 +44,8 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
return builder.use_ref( return builder.use_ref(
step.use, step.use,
id=step_id, id=step_id,
in_map=step.in_, input=_draft_input_bindings(step),
input_values=step.with_, output=_draft_output_bindings(step),
out_map=step.out,
desc=step.desc, desc=step.desc,
) )
if isinstance(step, DraftForeachStep): if isinstance(step, DraftForeachStep):
@@ -88,3 +89,24 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
default=step.match.default, default=step.match.default,
).entry ).entry
raise TypeError(f"unsupported draft step {type(step)!r}") raise TypeError(f"unsupported draft step {type(step)!r}")
def _draft_input_bindings(step: DraftUseStep) -> list[dict[str, Any]]:
"""Translate draft input maps into canonical core input binding structs.
Draft JSON keeps `in` and `with` because they are compact patch targets for
LLM clients. The compiled workflow should not re-emit deprecated builder map
sugar, so this adapter boundary converts them to `NodeUse.input`.
"""
literal_bindings = [
{"target": target, "value": value} for target, value in step.with_.items()
]
path_bindings = [
{"target": target, "path": source} for source, target in step.in_.items()
]
return [*literal_bindings, *path_bindings]
def _draft_output_bindings(step: DraftUseStep) -> list[dict[str, str]]:
"""Translate draft output maps into canonical core output binding structs."""
return [{"source": source, "target": target} for source, target in step.out.items()]
+87 -18
View File
@@ -32,6 +32,12 @@ from wf_platform import (
) )
from wf_authoring import build_async_registry from wf_authoring import build_async_registry
from wf_core import RuntimeContext from wf_core import RuntimeContext
from wf_core.models.steps import (
InputBinding,
InputPathBinding,
InputValueBinding,
OutputBinding,
)
from ..events import make_event from ..events import make_event
from ..models import RawWorkflowPlan from ..models import RawWorkflowPlan
@@ -214,7 +220,8 @@ class WorkflowSurfaceHandlers:
query=query, query=query,
): ):
continue continue
rows.append({ rows.append(
{
"name": name, "name": name,
"source_id": "workflow", "source_id": "workflow",
"kind": "wrapper_artifact", "kind": "wrapper_artifact",
@@ -226,7 +233,8 @@ class WorkflowSurfaceHandlers:
"is_async": True, "is_async": True,
"input_fields": _schema_field_names(artifact.input_schema), "input_fields": _schema_field_names(artifact.input_schema),
"output_fields": _schema_field_names(artifact.output_schema), "output_fields": _schema_field_names(artifact.output_schema),
}) }
)
return rows return rows
def _wrapper_capability_detail( def _wrapper_capability_detail(
@@ -445,10 +453,12 @@ class WorkflowSurfaceHandlers:
}, },
) )
) )
required_sources = sorted({ required_sources = sorted(
{
capability.logical_source capability.logical_source
for capability in workflow_artifact.required_capability_map().values() for capability in workflow_artifact.required_capability_map().values()
}) }
)
return { return {
"artifact_id": workflow_artifact.id, "artifact_id": workflow_artifact.id,
"version": workflow_artifact.version, "version": workflow_artifact.version,
@@ -628,26 +638,34 @@ class WorkflowSurfaceHandlers:
input_schema: dict[str, Any], input_schema: dict[str, Any],
state_schema: dict[str, Any], state_schema: dict[str, Any],
output_schema: dict[str, Any], output_schema: dict[str, Any],
input_map: dict[str, str], input: Sequence[InputBinding] | None = None,
output_map: dict[str, str], 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 | None = None,
title: str | None = None, title: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Bootstrap the smallest patchable draft around one workflow capability.""" """Bootstrap the smallest patchable draft around one workflow capability."""
draft_input, draft_with = _draft_input_maps(
input=input,
input_map=input_map,
)
draft_output = _draft_output_map(output=output, output_map=output_map)
outcomes = self._outcomes_for_capability(capability_name) or ( outcomes = self._outcomes_for_capability(capability_name) or (
DEFAULT_OK_OUTCOME, DEFAULT_OK_OUTCOME,
) )
steps: dict[str, Any] = { steps: dict[str, Any] = {
DEFAULT_CALL_STEP_ID: { DEFAULT_CALL_STEP_ID: {
"use": capability_name, "use": capability_name,
"in": input_map, "in": draft_input,
"out": output_map, "with": draft_with,
"out": draft_output,
} }
} }
routes: dict[str, dict[str, str]] = { routes: dict[str, dict[str, str]] = {
DEFAULT_CALL_STEP_ID: {DEFAULT_OK_OUTCOME: "__end__"} DEFAULT_CALL_STEP_ID: {DEFAULT_OK_OUTCOME: "__end__"}
} }
error_source = error_message_source or _first_state_path(output_map) error_source = error_message_source or _first_state_path(draft_output)
if DEFAULT_ERROR_OUTCOME in outcomes and error_source is not None: if DEFAULT_ERROR_OUTCOME in outcomes and error_source is not None:
# The bootstrapper cannot infer provider-specific error envelopes. # The bootstrapper cannot infer provider-specific error envelopes.
# It only wires an error route when the caller gave, or output_map # It only wires an error route when the caller gave, or output_map
@@ -684,6 +702,8 @@ class WorkflowSurfaceHandlers:
input_schema: dict[str, Any] | None = None, input_schema: dict[str, Any] | None = None,
state_schema: dict[str, Any] | None = None, state_schema: dict[str, Any] | None = None,
output_schema: dict[str, Any] | None = None, output_schema: dict[str, Any] | None = None,
input: Sequence[InputBinding] | None = None,
output: Sequence[OutputBinding] | None = None,
input_map: dict[str, str] | None = None, input_map: dict[str, str] | None = None,
output_map: dict[str, str] | None = None, output_map: dict[str, str] | None = None,
error_message_source: str | None = None, error_message_source: str | None = None,
@@ -698,8 +718,12 @@ class WorkflowSurfaceHandlers:
input_schema=input_schema or hints["input_schema"], input_schema=input_schema or hints["input_schema"],
state_schema=state_schema or hints["state_schema"], state_schema=state_schema or hints["state_schema"],
output_schema=output_schema or hints["output_schema"], output_schema=output_schema or hints["output_schema"],
input_map=input_map or hints["input_map"], input=input,
output_map=output_map or hints["output_map"], output=output,
input_map=None if input is not None else (input_map or hints["input_map"]),
output_map=None
if output is not None
else (output_map or hints["output_map"]),
error_message_source=error_message_source, error_message_source=error_message_source,
title=title, title=title,
) )
@@ -905,14 +929,16 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
if (capability_name := _capability_name(spec.name)) is not None if (capability_name := _capability_name(spec.name)) is not None
if (detail := node_spec_details.get(spec.name)) is not None if (detail := node_spec_details.get(spec.name)) is not None
} }
capabilities.update({ capabilities.update(
{
capability_name: AvailableCapability( capability_name: AvailableCapability(
name=capability_name, name=capability_name,
kind="reducer", kind="reducer",
) )
for reducer in source.capabilities.reducers.values() for reducer in source.capabilities.reducers.values()
if (capability_name := _capability_name(reducer.name)) is not None if (capability_name := _capability_name(reducer.name)) is not None
}) }
)
sources.append( sources.append(
AvailableSource( AvailableSource(
id=source.id, id=source.id,
@@ -976,9 +1002,9 @@ def _observed_node_specs(service: WfMcpService) -> dict[str, NodeSpecInventory]:
observed: dict[str, NodeSpecInventory] = {} observed: dict[str, NodeSpecInventory] = {}
for source in service.capability_sources.values(): for source in service.capability_sources.values():
inventory = source.as_inventory() inventory = source.as_inventory()
observed.update({ observed.update(
detail.name: detail for detail in inventory.capabilities.node_spec_details {detail.name: detail for detail in inventory.capabilities.node_spec_details}
}) )
return observed return observed
@@ -1013,6 +1039,47 @@ def _first_state_path(output_map: dict[str, str]) -> str | None:
return None return None
def _draft_input_maps(
*,
input: Sequence[InputBinding] | None,
input_map: dict[str, str] | None,
) -> tuple[dict[str, str], dict[str, Any]]:
"""Convert canonical MCP input bindings into draft `in` and `with` maps.
Draft workspaces intentionally keep compact maps as patch targets, while
MCP-facing request models prefer the canonical core binding structs. This
helper keeps that translation explicit at the frontend boundary.
"""
if input is not None and input_map is not None:
raise ValueError("cannot mix canonical input bindings with input_map")
if input is None:
return dict(input_map or {}), {}
mapped_inputs: dict[str, str] = {}
literal_inputs: dict[str, Any] = {}
for binding in input:
if isinstance(binding, InputPathBinding):
mapped_inputs[str(binding.path)] = str(binding.target)
elif isinstance(binding, InputValueBinding):
literal_inputs[str(binding.target)] = binding.value
else: # pragma: no cover - defensive against future input binding variants.
raise TypeError(f"unsupported input binding {binding!r}")
return mapped_inputs, literal_inputs
def _draft_output_map(
*,
output: Sequence[OutputBinding] | None,
output_map: dict[str, str] | None,
) -> dict[str, str]:
"""Convert canonical MCP output bindings into the draft `out` map."""
if output is not None and output_map is not None:
raise ValueError("cannot mix canonical output bindings with output_map")
if output is None:
return dict(output_map or {})
return {str(binding.source): str(binding.target) for binding in output}
def _escape_json_pointer(value: str) -> str: def _escape_json_pointer(value: str) -> str:
"""Escape one JSON Pointer path segment for generated JSON Patch helpers.""" """Escape one JSON Pointer path segment for generated JSON Patch helpers."""
return value.replace("~", "~0").replace("/", "~1") return value.replace("~", "~0").replace("/", "~1")
@@ -1057,7 +1124,8 @@ def _artifact_capability_id(artifact: WorkflowArtifact) -> str:
def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan: def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan:
"""Validate the stored plan shape expected by the broker workflow runner.""" """Validate the stored plan shape expected by the broker workflow runner."""
return RawWorkflowPlan.model_validate({ return RawWorkflowPlan.model_validate(
{
"name": _plan_field(artifact, "name"), "name": _plan_field(artifact, "name"),
"input_schema": _plan_field(artifact, "input_schema"), "input_schema": _plan_field(artifact, "input_schema"),
"state_schema": _plan_field(artifact, "state_schema"), "state_schema": _plan_field(artifact, "state_schema"),
@@ -1065,7 +1133,8 @@ def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan:
"start": _plan_field(artifact, "start"), "start": _plan_field(artifact, "start"),
"nodes": _plan_field(artifact, "nodes"), "nodes": _plan_field(artifact, "nodes"),
"edges": _plan_field(artifact, "edges"), "edges": _plan_field(artifact, "edges"),
}) }
)
def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any: def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
+61 -14
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from wf_artifacts import ArtifactKind from wf_artifacts import ArtifactKind
from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN
from wf_core.models.steps import InputBinding, OutputBinding
WorkspaceId = Annotated[ WorkspaceId = Annotated[
str, str,
@@ -27,8 +28,29 @@ DraftPathMap = Annotated[
dict[str, str], dict[str, str],
Field( Field(
description=( description=(
"Map local draft paths to workflow paths, for example " "Compatibility map form for draft paths. Prefer canonical input/output "
"{'input.text': 'text'} or {'echoed': 'state.echoed'}." "binding lists for new MCP/JSON clients."
)
),
]
DraftInputBindings = Annotated[
list[InputBinding],
Field(
description=(
"Canonical node input bindings. Use path bindings such as "
"{'target': {'root': 'local', 'parts': ['text']}, "
"'path': {'root': 'input', 'parts': ['text']}} or value bindings "
"with {'target': ..., 'value': ...}."
)
),
]
DraftOutputBindings = Annotated[
list[OutputBinding],
Field(
description=(
"Canonical node output bindings. Example: {'source': {'root': "
"'local', 'parts': ['echoed']}, 'target': {'root': 'state', "
"'parts': ['echoed']}}."
) )
), ),
] ]
@@ -203,25 +225,40 @@ class CreateMinimalDraftWorkspaceRequest(BaseModel):
) )
state_schema: JsonSchemaObject = Field( state_schema: JsonSchemaObject = Field(
description=( description=(
"Workflow state schema. The current core state schema uses a fields " "Workflow state JSON Schema. Prefer object properties with reducer "
"object; keep it small and explicit." "extension keywords; legacy fields input is compatibility-only."
) )
) )
output_schema: JsonSchemaObject = Field( output_schema: JsonSchemaObject = Field(
description="Public output JSON Schema for the workflow or wrapper being drafted." description="Public output JSON Schema for the workflow or wrapper being drafted."
) )
input_map: DraftPathMap = Field( input: DraftInputBindings | None = Field(
default=None,
description=( description=(
"Map public workflow input paths to local capability input paths. " "Preferred canonical input bindings for the called capability. "
"Example: {'input.text': 'message'} sends workflow input.text to " "Use this instead of input_map for new clients."
"capability field message." ),
) )
) output: DraftOutputBindings | None = Field(
output_map: DraftPathMap = Field( default=None,
description=( description=(
"Map local capability output paths to workflow state paths. Example: " "Preferred canonical output bindings for writes into workflow state. "
"{'echoed': 'state.echoed'} stores capability output echoed in state.echoed." "Use this instead of output_map for new clients."
),
) )
input_map: DraftPathMap | None = Field(
default=None,
description=(
"Deprecated compatibility map from workflow paths to local capability "
"input paths, for example {'input.text': 'message'}."
),
)
output_map: DraftPathMap | None = Field(
default=None,
description=(
"Deprecated compatibility map from local capability output paths to "
"workflow state paths, for example {'echoed': 'state.echoed'}."
),
) )
error_message_source: str | None = Field( error_message_source: str | None = Field(
default=None, default=None,
@@ -260,13 +297,23 @@ class CreateDraftWorkspaceFromCapabilityRequest(BaseModel):
default=None, default=None,
description="Optional override for the hinted public output schema.", description="Optional override for the hinted public output schema.",
) )
input: DraftInputBindings | None = Field(
default=None,
description="Optional canonical override for the hinted workflow input bindings.",
)
output: DraftOutputBindings | None = Field(
default=None,
description="Optional canonical override for the hinted capability output bindings.",
)
input_map: DraftPathMap | None = Field( input_map: DraftPathMap | None = Field(
default=None, default=None,
description="Optional override for the hinted workflow input map.", description="Deprecated compatibility override for the hinted workflow input map.",
) )
output_map: DraftPathMap | None = Field( output_map: DraftPathMap | None = Field(
default=None, default=None,
description="Optional override for the hinted capability output map.", description=(
"Deprecated compatibility override for the hinted capability output map."
),
) )
error_message_source: str | None = Field( error_message_source: str | None = Field(
default=None, default=None,
+14 -3
View File
@@ -340,7 +340,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
@server.tool( @server.tool(
name="wf.workflow.set_step_input_map", name="wf.workflow.set_step_input_map",
title="Set Step Input Map", title="Set Step Input Map",
description="Replace one step input map in a draft workspace.", description=(
"Replace one compatibility step input map in a draft workspace. "
"New one-capability bootstraps should prefer canonical input bindings."
),
) )
async def set_step_input_map( async def set_step_input_map(
request: SetStepInputMapRequest, request: SetStepInputMapRequest,
@@ -357,7 +360,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
@server.tool( @server.tool(
name="wf.workflow.set_step_output_map", name="wf.workflow.set_step_output_map",
title="Set Step Output Map", title="Set Step Output Map",
description="Replace one step output map in a draft workspace.", description=(
"Replace one compatibility step output map in a draft workspace. "
"New one-capability bootstraps should prefer canonical output bindings."
),
) )
async def set_step_output_map( async def set_step_output_map(
request: SetStepOutputMapRequest, request: SetStepOutputMapRequest,
@@ -376,7 +382,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
title="Create Minimal Draft Workspace", title="Create Minimal Draft Workspace",
description=( description=(
"Bootstrap a patchable draft workspace around one inspected capability. " "Bootstrap a patchable draft workspace around one inspected capability. "
"Use this before patch helpers when authoring from MCP clients." "Use canonical input/output binding lists for new MCP clients; "
"input_map/output_map remain compatibility fields."
), ),
) )
async def create_minimal_draft_workspace( async def create_minimal_draft_workspace(
@@ -390,6 +397,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
input_schema=request.input_schema, input_schema=request.input_schema,
state_schema=request.state_schema, state_schema=request.state_schema,
output_schema=request.output_schema, output_schema=request.output_schema,
input=request.input,
output=request.output,
input_map=request.input_map, input_map=request.input_map,
output_map=request.output_map, output_map=request.output_map,
error_message_source=request.error_message_source, error_message_source=request.error_message_source,
@@ -417,6 +426,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
input_schema=request.input_schema, input_schema=request.input_schema,
state_schema=request.state_schema, state_schema=request.state_schema,
output_schema=request.output_schema, output_schema=request.output_schema,
input=request.input,
output=request.output,
input_map=request.input_map, input_map=request.input_map,
output_map=request.output_map, output_map=request.output_map,
error_message_source=request.error_message_source, error_message_source=request.error_message_source,
+3
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel
from wf_authoring import NodeSpec from wf_authoring import NodeSpec
from wf_core import ReducerSpec from wf_core import ReducerSpec
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
from wf_platform.refs import CapabilityRef
SourceKind = Literal["system", "connection"] SourceKind = Literal["system", "connection"]
JsonObject = dict[str, Any] JsonObject = dict[str, Any]
@@ -59,6 +60,7 @@ class ReducerInventory(BaseModel):
"""Serializable public contract for one pure reducer.""" """Serializable public contract for one pure reducer."""
name: str name: str
ref: CapabilityRef
description: str | None = None description: str | None = None
config_schema: JsonObject config_schema: JsonObject
@@ -200,6 +202,7 @@ class CapabilitySource:
reducer_details=tuple( reducer_details=tuple(
ReducerInventory( ReducerInventory(
name=reducer.name, name=reducer.name,
ref=CapabilityRef.parse(reducer.name),
description=reducer.description, description=reducer.description,
config_schema=reducer.config_schema, config_schema=reducer.config_schema,
) )
+52 -10
View File
@@ -10,7 +10,8 @@ from wf_core.models.steps import InputValueBinding
def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None: def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
{
"name": "echo", "name": "echo",
"input_schema": {}, "input_schema": {},
"state_schema": {"fields": {}}, "state_schema": {"fields": {}},
@@ -18,7 +19,8 @@ def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
"start": "echo", "start": "echo",
"steps": {"echo": {"use": "demo.echo"}}, "steps": {"echo": {"use": "demo.echo"}},
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},
}) }
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
@@ -31,8 +33,41 @@ def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
assert workflow.edges[0].to == "__end__" assert workflow.edges[0].to == "__end__"
def test_adapter_lowers_use_steps_to_canonical_bindings() -> None:
draft = WorkflowDraft.model_validate(
{
"name": "echo",
"input_schema": {},
"state_schema": {"fields": {"echoed": {"type": "string"}}},
"output_schema": {},
"start": "echo",
"steps": {
"echo": {
"use": "demo.echo",
"in": {"input.text": "text"},
"out": {"echoed": "state.echoed"},
}
},
"routes": {"echo": {"ok": "__end__"}},
}
)
workflow = build_workflow_from_draft(draft)
node = workflow.nodes[0]
assert isinstance(node, NodeUse)
dumped = node.model_dump(mode="json")
assert "in_map" not in dumped
assert "out_map" not in dumped
assert dumped["input"][0]["target"] == {"root": "local", "parts": ["text"]}
assert dumped["input"][0]["path"] == {"root": "input", "parts": ["text"]}
assert dumped["output"][0]["source"] == {"root": "local", "parts": ["echoed"]}
assert dumped["output"][0]["target"] == {"root": "state", "parts": ["echoed"]}
def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None: def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
{
"name": "constant", "name": "constant",
"input_schema": {}, "input_schema": {},
"state_schema": {"fields": {"message": {"type": "string"}}}, "state_schema": {"fields": {"message": {"type": "string"}}},
@@ -46,7 +81,8 @@ def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None:
} }
}, },
"routes": {"constant": {"ok": "__end__"}}, "routes": {"constant": {"ok": "__end__"}},
}) }
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
node = workflow.nodes[0] node = workflow.nodes[0]
@@ -88,7 +124,8 @@ def test_invalid_literal_input_map_does_not_fall_through_to_join() -> None:
def test_adapter_lowers_when_step_through_builder() -> None: def test_adapter_lowers_when_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
{
"name": "when_example", "name": "when_example",
"input_schema": {}, "input_schema": {},
"state_schema": {"fields": {}}, "state_schema": {"fields": {}},
@@ -109,7 +146,8 @@ def test_adapter_lowers_when_step_through_builder() -> None:
"echo": {"use": "demo.echo"}, "echo": {"use": "demo.echo"},
}, },
"routes": {"echo": {"ok": "__end__"}}, "routes": {"echo": {"ok": "__end__"}},
}) }
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition = workflow.nodes[0] condition = workflow.nodes[0]
@@ -124,7 +162,8 @@ def test_adapter_lowers_when_step_through_builder() -> None:
def test_adapter_lowers_choose_step_through_builder() -> None: def test_adapter_lowers_choose_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
{
"name": "choose_example", "name": "choose_example",
"input_schema": {}, "input_schema": {},
"state_schema": {"fields": {}}, "state_schema": {"fields": {}},
@@ -160,7 +199,8 @@ def test_adapter_lowers_choose_step_through_builder() -> None:
"high": {"ok": "__end__"}, "high": {"ok": "__end__"},
"fallback": {"ok": "__end__"}, "fallback": {"ok": "__end__"},
}, },
}) }
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition_ids = [ condition_ids = [
@@ -178,7 +218,8 @@ def test_adapter_lowers_choose_step_through_builder() -> None:
def test_adapter_lowers_match_step_through_builder() -> None: def test_adapter_lowers_match_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
{
"name": "match_example", "name": "match_example",
"input_schema": {}, "input_schema": {},
"state_schema": {"fields": {}}, "state_schema": {"fields": {}},
@@ -202,7 +243,8 @@ def test_adapter_lowers_match_step_through_builder() -> None:
"ready": {"ok": "__end__"}, "ready": {"ok": "__end__"},
"waiting": {"ok": "__end__"}, "waiting": {"ok": "__end__"},
}, },
}) }
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition_ids = [ condition_ids = [
+5
View File
@@ -64,5 +64,10 @@ def test_source_inventory_exposes_serializable_reducer_details() -> None:
assert isinstance(detail, ReducerInventory) assert isinstance(detail, ReducerInventory)
assert detail.name == "wf.std.max" assert detail.name == "wf.std.max"
assert str(detail.ref.source) == "wf.std"
assert detail.ref.name == "max"
assert detail.description == "Keep the greater value." assert detail.description == "Keep the greater value."
assert detail.config_schema == {"type": "object", "properties": {}} assert detail.config_schema == {"type": "object", "properties": {}}
assert source.as_inventory().model_dump(mode="json")["capabilities"][
"reducer_details"
][0]["ref"] == {"source": "wf.std", "capability_key": "max"}
+9 -2
View File
@@ -138,6 +138,9 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
minimal_request = minimal_workspace_input["properties"]["request"] minimal_request = minimal_workspace_input["properties"]["request"]
assert minimal_request["properties"]["workspace_id"]["pattern"] assert minimal_request["properties"]["workspace_id"]["pattern"]
assert "error_message_source" in minimal_request["properties"] assert "error_message_source" in minimal_request["properties"]
assert "input" in minimal_request["properties"]
assert "output" in minimal_request["properties"]
assert "input_map" in minimal_request["properties"]
assert ( assert (
minimal_request["properties"]["input_schema"]["description"] minimal_request["properties"]["input_schema"]["description"]
== "Public input JSON Schema for the workflow or wrapper being " == "Public input JSON Schema for the workflow or wrapper being "
@@ -149,6 +152,8 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
from_capability_request = from_capability_input["properties"]["request"] from_capability_request = from_capability_input["properties"]["request"]
assert "capability_name" in from_capability_request["properties"] assert "capability_name" in from_capability_request["properties"]
assert "input_schema" in from_capability_request["properties"] assert "input_schema" in from_capability_request["properties"]
assert "input" in from_capability_request["properties"]
assert "output" in from_capability_request["properties"]
assert "output_map" in from_capability_request["properties"] assert "output_map" in from_capability_request["properties"]
from_capability_output = tools_by_name[ from_capability_output = tools_by_name[
"wf.workflow.create_draft_workspace_from_capability" "wf.workflow.create_draft_workspace_from_capability"
@@ -526,7 +531,8 @@ def test_server_reload_syncs_service_connection_source_enabled_state() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
{
"store_root": ".wf_mcp_store", "store_root": ".wf_mcp_store",
"connections": [ "connections": [
{ {
@@ -541,7 +547,8 @@ def test_server_reload_syncs_service_connection_source_enabled_state() -> None:
}, },
} }
], ],
}), }
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
+41
View File
@@ -16,6 +16,8 @@ from wf_mcp.broker import WfMcpService
from wf_mcp.models import ConnectionConfig, RawWorkflowPlan from wf_mcp.models import ConnectionConfig, RawWorkflowPlan
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
from wf_core.models.steps import InputPathBinding, OutputBinding
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_platform import ( from wf_platform import (
CapabilityBuckets, CapabilityBuckets,
CapabilitySource, CapabilitySource,
@@ -680,6 +682,45 @@ def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() ->
assert workspace.draft["steps"]["tool_error"]["in"] == {"state.echoed": "message"} assert workspace.draft["steps"]["tool_error"]["in"] == {"state.echoed": "message"}
def test_workflow_surface_accepts_canonical_bindings_for_minimal_workspace() -> None:
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_minimal_canonical_mcp"),
artifact_store=FileWorkflowArtifactStore(
local_temp_root() / "surface_minimal_canonical"
),
)
handlers = WorkflowSurfaceHandlers(service)
result = asyncio.run(
handlers.create_minimal_draft_workspace(
workspace_id="echo_draft",
name="echo",
capability_name="demo.personal.echo_tool",
input_schema={"type": "object"},
state_schema={"fields": {"echoed": {"type": "string"}}},
output_schema={"type": "object"},
input=[
InputPathBinding(
target=LocalPath(("text",)),
path=GraphSourcePath("input", ("text",)),
)
],
output=[
OutputBinding(
source=LocalPath(("echoed",)),
target=StatePath(("echoed",)),
)
],
)
)
assert service.draft_workspace_store is not None
workspace = service.draft_workspace_store.get_workspace("echo_draft")
assert result["workspace_id"] == "echo_draft"
assert workspace.draft["steps"]["call"]["in"] == {"input.text": "text"}
assert workspace.draft["steps"]["call"]["out"] == {"echoed": "state.echoed"}
def test_workflow_surface_creates_draft_workspace_from_capability_hints() -> None: def test_workflow_surface_creates_draft_workspace_from_capability_hints() -> None:
artifact_store = FileWorkflowArtifactStore( artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_workspace_from_capability" local_temp_root() / "surface_workspace_from_capability"