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.
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
+1 -1
View File
@@ -504,7 +504,7 @@ Concrete MCP sequence:
2. `wf.workflow.call_capability` with a small payload to verify the selected
capability behaves as expected.
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
existing workspace ids.
5. `wf.workflow.get_draft_workspace` with `include_draft=true` if the client
+13 -7
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
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
capability into a reusable workflow-facing wrapper. It is the same validation
@@ -406,12 +406,18 @@ Minimal example:
},
"required": ["echoed"]
},
"input_map": {
"input.text": "text"
},
"output_map": {
"echoed": "state.echoed"
}
"input": [
{
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]}
}
],
"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
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.
In MCP Inspector, workspace mutation tools accept a single `request` object.
+25 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from wf_authoring import WorkflowBuilder
from wf_authoring.dsl import PathExpr
from wf_core import JoinNode, Workflow
@@ -42,9 +44,8 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
return builder.use_ref(
step.use,
id=step_id,
in_map=step.in_,
input_values=step.with_,
out_map=step.out,
input=_draft_input_bindings(step),
output=_draft_output_bindings(step),
desc=step.desc,
)
if isinstance(step, DraftForeachStep):
@@ -88,3 +89,24 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
default=step.match.default,
).entry
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()]
+113 -44
View File
@@ -32,6 +32,12 @@ from wf_platform import (
)
from wf_authoring import build_async_registry
from wf_core import RuntimeContext
from wf_core.models.steps import (
InputBinding,
InputPathBinding,
InputValueBinding,
OutputBinding,
)
from ..events import make_event
from ..models import RawWorkflowPlan
@@ -214,19 +220,21 @@ class WorkflowSurfaceHandlers:
query=query,
):
continue
rows.append({
"name": name,
"source_id": "workflow",
"kind": "wrapper_artifact",
"artifact_id": artifact.id,
"version": artifact.version,
"title": artifact.title,
"description": artifact.description,
"outcomes": list(artifact.outcomes),
"is_async": True,
"input_fields": _schema_field_names(artifact.input_schema),
"output_fields": _schema_field_names(artifact.output_schema),
})
rows.append(
{
"name": name,
"source_id": "workflow",
"kind": "wrapper_artifact",
"artifact_id": artifact.id,
"version": artifact.version,
"title": artifact.title,
"description": artifact.description,
"outcomes": list(artifact.outcomes),
"is_async": True,
"input_fields": _schema_field_names(artifact.input_schema),
"output_fields": _schema_field_names(artifact.output_schema),
}
)
return rows
def _wrapper_capability_detail(
@@ -445,10 +453,12 @@ class WorkflowSurfaceHandlers:
},
)
)
required_sources = sorted({
capability.logical_source
for capability in workflow_artifact.required_capability_map().values()
})
required_sources = sorted(
{
capability.logical_source
for capability in workflow_artifact.required_capability_map().values()
}
)
return {
"artifact_id": workflow_artifact.id,
"version": workflow_artifact.version,
@@ -628,26 +638,34 @@ class WorkflowSurfaceHandlers:
input_schema: dict[str, Any],
state_schema: dict[str, Any],
output_schema: dict[str, Any],
input_map: dict[str, str],
output_map: dict[str, str],
input: Sequence[InputBinding] | None = None,
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,
title: str | None = None,
) -> dict[str, Any]:
"""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 (
DEFAULT_OK_OUTCOME,
)
steps: dict[str, Any] = {
DEFAULT_CALL_STEP_ID: {
"use": capability_name,
"in": input_map,
"out": output_map,
"in": draft_input,
"with": draft_with,
"out": draft_output,
}
}
routes: dict[str, dict[str, str]] = {
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:
# The bootstrapper cannot infer provider-specific error envelopes.
# 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,
state_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,
output_map: dict[str, str] | None = None,
error_message_source: str | None = None,
@@ -698,8 +718,12 @@ class WorkflowSurfaceHandlers:
input_schema=input_schema or hints["input_schema"],
state_schema=state_schema or hints["state_schema"],
output_schema=output_schema or hints["output_schema"],
input_map=input_map or hints["input_map"],
output_map=output_map or hints["output_map"],
input=input,
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,
title=title,
)
@@ -905,14 +929,16 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
if (capability_name := _capability_name(spec.name)) is not None
if (detail := node_spec_details.get(spec.name)) is not None
}
capabilities.update({
capability_name: AvailableCapability(
name=capability_name,
kind="reducer",
)
for reducer in source.capabilities.reducers.values()
if (capability_name := _capability_name(reducer.name)) is not None
})
capabilities.update(
{
capability_name: AvailableCapability(
name=capability_name,
kind="reducer",
)
for reducer in source.capabilities.reducers.values()
if (capability_name := _capability_name(reducer.name)) is not None
}
)
sources.append(
AvailableSource(
id=source.id,
@@ -976,9 +1002,9 @@ def _observed_node_specs(service: WfMcpService) -> dict[str, NodeSpecInventory]:
observed: dict[str, NodeSpecInventory] = {}
for source in service.capability_sources.values():
inventory = source.as_inventory()
observed.update({
detail.name: detail for detail in inventory.capabilities.node_spec_details
})
observed.update(
{detail.name: detail for detail in inventory.capabilities.node_spec_details}
)
return observed
@@ -1013,6 +1039,47 @@ def _first_state_path(output_map: dict[str, str]) -> str | 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:
"""Escape one JSON Pointer path segment for generated JSON Patch helpers."""
return value.replace("~", "~0").replace("/", "~1")
@@ -1057,15 +1124,17 @@ def _artifact_capability_id(artifact: WorkflowArtifact) -> str:
def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan:
"""Validate the stored plan shape expected by the broker workflow runner."""
return RawWorkflowPlan.model_validate({
"name": _plan_field(artifact, "name"),
"input_schema": _plan_field(artifact, "input_schema"),
"state_schema": _plan_field(artifact, "state_schema"),
"output_schema": _plan_field(artifact, "output_schema"),
"start": _plan_field(artifact, "start"),
"nodes": _plan_field(artifact, "nodes"),
"edges": _plan_field(artifact, "edges"),
})
return RawWorkflowPlan.model_validate(
{
"name": _plan_field(artifact, "name"),
"input_schema": _plan_field(artifact, "input_schema"),
"state_schema": _plan_field(artifact, "state_schema"),
"output_schema": _plan_field(artifact, "output_schema"),
"start": _plan_field(artifact, "start"),
"nodes": _plan_field(artifact, "nodes"),
"edges": _plan_field(artifact, "edges"),
}
)
def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
+62 -15
View File
@@ -6,6 +6,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
WorkspaceId = Annotated[
str,
@@ -27,8 +28,29 @@ DraftPathMap = Annotated[
dict[str, str],
Field(
description=(
"Map local draft paths to workflow paths, for example "
"{'input.text': 'text'} or {'echoed': 'state.echoed'}."
"Compatibility map form for draft paths. Prefer canonical input/output "
"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(
description=(
"Workflow state schema. The current core state schema uses a fields "
"object; keep it small and explicit."
"Workflow state JSON Schema. Prefer object properties with reducer "
"extension keywords; legacy fields input is compatibility-only."
)
)
output_schema: JsonSchemaObject = Field(
description="Public output JSON Schema for the workflow or wrapper being drafted."
)
input_map: DraftPathMap = Field(
input: DraftInputBindings | None = Field(
default=None,
description=(
"Map public workflow input paths to local capability input paths. "
"Example: {'input.text': 'message'} sends workflow input.text to "
"capability field message."
)
"Preferred canonical input bindings for the called capability. "
"Use this instead of input_map for new clients."
),
)
output_map: DraftPathMap = Field(
output: DraftOutputBindings | None = Field(
default=None,
description=(
"Map local capability output paths to workflow state paths. Example: "
"{'echoed': 'state.echoed'} stores capability output echoed in state.echoed."
)
"Preferred canonical output bindings for writes into workflow state. "
"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(
default=None,
@@ -260,13 +297,23 @@ class CreateDraftWorkspaceFromCapabilityRequest(BaseModel):
default=None,
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(
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(
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(
default=None,
+14 -3
View File
@@ -340,7 +340,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
@server.tool(
name="wf.workflow.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(
request: SetStepInputMapRequest,
@@ -357,7 +360,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
@server.tool(
name="wf.workflow.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(
request: SetStepOutputMapRequest,
@@ -376,7 +382,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
title="Create Minimal Draft Workspace",
description=(
"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(
@@ -390,6 +397,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
input_schema=request.input_schema,
state_schema=request.state_schema,
output_schema=request.output_schema,
input=request.input,
output=request.output,
input_map=request.input_map,
output_map=request.output_map,
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,
state_schema=request.state_schema,
output_schema=request.output_schema,
input=request.input,
output=request.output,
input_map=request.input_map,
output_map=request.output_map,
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_core import ReducerSpec
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_platform.refs import CapabilityRef
SourceKind = Literal["system", "connection"]
JsonObject = dict[str, Any]
@@ -59,6 +60,7 @@ class ReducerInventory(BaseModel):
"""Serializable public contract for one pure reducer."""
name: str
ref: CapabilityRef
description: str | None = None
config_schema: JsonObject
@@ -200,6 +202,7 @@ class CapabilitySource:
reducer_details=tuple(
ReducerInventory(
name=reducer.name,
ref=CapabilityRef.parse(reducer.name),
description=reducer.description,
config_schema=reducer.config_schema,
)
+145 -103
View File
@@ -10,15 +10,17 @@ from wf_core.models.steps import InputValueBinding
def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
draft = WorkflowDraft.model_validate({
"name": "echo",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "echo",
"steps": {"echo": {"use": "demo.echo"}},
"routes": {"echo": {"ok": "__end__"}},
})
draft = WorkflowDraft.model_validate(
{
"name": "echo",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "echo",
"steps": {"echo": {"use": "demo.echo"}},
"routes": {"echo": {"ok": "__end__"}},
}
)
workflow = build_workflow_from_draft(draft)
@@ -31,22 +33,56 @@ def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
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:
draft = WorkflowDraft.model_validate({
"name": "constant",
"input_schema": {},
"state_schema": {"fields": {"message": {"type": "string"}}},
"output_schema": {},
"start": "constant",
"steps": {
"constant": {
"use": "wf.std.constant",
"with": {"value": "CLICKED"},
"out": {"value": "state.message"},
}
},
"routes": {"constant": {"ok": "__end__"}},
})
draft = WorkflowDraft.model_validate(
{
"name": "constant",
"input_schema": {},
"state_schema": {"fields": {"message": {"type": "string"}}},
"output_schema": {},
"start": "constant",
"steps": {
"constant": {
"use": "wf.std.constant",
"with": {"value": "CLICKED"},
"out": {"value": "state.message"},
}
},
"routes": {"constant": {"ok": "__end__"}},
}
)
workflow = build_workflow_from_draft(draft)
node = workflow.nodes[0]
@@ -88,28 +124,30 @@ def test_invalid_literal_input_map_does_not_fall_through_to_join() -> None:
def test_adapter_lowers_when_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({
"name": "when_example",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "decide",
"steps": {
"decide": {
"when": {
"if": {
"op": "ge",
"left": {"path": "state.count"},
"right": {"value": 1},
},
"then": "echo",
"otherwise": "__end__",
}
draft = WorkflowDraft.model_validate(
{
"name": "when_example",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "decide",
"steps": {
"decide": {
"when": {
"if": {
"op": "ge",
"left": {"path": "state.count"},
"right": {"value": 1},
},
"then": "echo",
"otherwise": "__end__",
}
},
"echo": {"use": "demo.echo"},
},
"echo": {"use": "demo.echo"},
},
"routes": {"echo": {"ok": "__end__"}},
})
"routes": {"echo": {"ok": "__end__"}},
}
)
workflow = build_workflow_from_draft(draft)
condition = workflow.nodes[0]
@@ -124,43 +162,45 @@ def test_adapter_lowers_when_step_through_builder() -> None:
def test_adapter_lowers_choose_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({
"name": "choose_example",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "pick",
"steps": {
"pick": {
"choose": {
"clauses": [
{
"if": {
"op": "gt",
"left": {"path": "state.score"},
"right": {"value": 80},
draft = WorkflowDraft.model_validate(
{
"name": "choose_example",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "pick",
"steps": {
"pick": {
"choose": {
"clauses": [
{
"if": {
"op": "gt",
"left": {"path": "state.score"},
"right": {"value": 80},
},
"then": "high",
},
"then": "high",
},
{
"if": {
"op": "exists",
"path": "state.fallback",
{
"if": {
"op": "exists",
"path": "state.fallback",
},
"then": "fallback",
},
"then": "fallback",
},
],
"default": "__end__",
}
],
"default": "__end__",
}
},
"high": {"use": "demo.high"},
"fallback": {"use": "demo.fallback"},
},
"high": {"use": "demo.high"},
"fallback": {"use": "demo.fallback"},
},
"routes": {
"high": {"ok": "__end__"},
"fallback": {"ok": "__end__"},
},
})
"routes": {
"high": {"ok": "__end__"},
"fallback": {"ok": "__end__"},
},
}
)
workflow = build_workflow_from_draft(draft)
condition_ids = [
@@ -178,31 +218,33 @@ def test_adapter_lowers_choose_step_through_builder() -> None:
def test_adapter_lowers_match_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({
"name": "match_example",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "match_status",
"steps": {
"match_status": {
"match": {
"value": "state.status",
"cases": [
{"equals": "ready", "then": "ready"},
{"equals": "waiting", "then": "waiting"},
],
"default": "__end__",
}
draft = WorkflowDraft.model_validate(
{
"name": "match_example",
"input_schema": {},
"state_schema": {"fields": {}},
"output_schema": {},
"start": "match_status",
"steps": {
"match_status": {
"match": {
"value": "state.status",
"cases": [
{"equals": "ready", "then": "ready"},
{"equals": "waiting", "then": "waiting"},
],
"default": "__end__",
}
},
"ready": {"use": "demo.ready"},
"waiting": {"use": "demo.waiting"},
},
"ready": {"use": "demo.ready"},
"waiting": {"use": "demo.waiting"},
},
"routes": {
"ready": {"ok": "__end__"},
"waiting": {"ok": "__end__"},
},
})
"routes": {
"ready": {"ok": "__end__"},
"waiting": {"ok": "__end__"},
},
}
)
workflow = build_workflow_from_draft(draft)
condition_ids = [
+5
View File
@@ -64,5 +64,10 @@ def test_source_inventory_exposes_serializable_reducer_details() -> None:
assert isinstance(detail, ReducerInventory)
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.config_schema == {"type": "object", "properties": {}}
assert source.as_inventory().model_dump(mode="json")["capabilities"][
"reducer_details"
][0]["ref"] == {"source": "wf.std", "capability_key": "max"}
+23 -16
View File
@@ -138,6 +138,9 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
minimal_request = minimal_workspace_input["properties"]["request"]
assert minimal_request["properties"]["workspace_id"]["pattern"]
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 (
minimal_request["properties"]["input_schema"]["description"]
== "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"]
assert "capability_name" 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"]
from_capability_output = tools_by_name[
"wf.workflow.create_draft_workspace_from_capability"
@@ -526,22 +531,24 @@ def test_server_reload_syncs_service_connection_source_enabled_state() -> None:
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps({
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "fixture.personal",
"server": "fixture",
"account": "personal",
"enabled": False,
"metadata": {
"transport": "stdio",
"command": sys.executable,
"args": [fixture_server_path()],
},
}
],
}),
json.dumps(
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "fixture.personal",
"server": "fixture",
"account": "personal",
"enabled": False,
"metadata": {
"transport": "stdio",
"command": sys.executable,
"args": [fixture_server_path()],
},
}
],
}
),
encoding="utf-8",
)
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.storage import FileStore
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 (
CapabilityBuckets,
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"}
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:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_workspace_from_capability"