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
+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 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()]
+113 -44
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,19 +220,21 @@ class WorkflowSurfaceHandlers:
query=query, query=query,
): ):
continue continue
rows.append({ rows.append(
"name": name, {
"source_id": "workflow", "name": name,
"kind": "wrapper_artifact", "source_id": "workflow",
"artifact_id": artifact.id, "kind": "wrapper_artifact",
"version": artifact.version, "artifact_id": artifact.id,
"title": artifact.title, "version": artifact.version,
"description": artifact.description, "title": artifact.title,
"outcomes": list(artifact.outcomes), "description": artifact.description,
"is_async": True, "outcomes": list(artifact.outcomes),
"input_fields": _schema_field_names(artifact.input_schema), "is_async": True,
"output_fields": _schema_field_names(artifact.output_schema), "input_fields": _schema_field_names(artifact.input_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 {
for capability in workflow_artifact.required_capability_map().values() capability.logical_source
}) 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( {
name=capability_name, capability_name: AvailableCapability(
kind="reducer", name=capability_name,
) kind="reducer",
for reducer in source.capabilities.reducers.values() )
if (capability_name := _capability_name(reducer.name)) is not None for reducer in source.capabilities.reducers.values()
}) 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,15 +1124,17 @@ 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"), {
"input_schema": _plan_field(artifact, "input_schema"), "name": _plan_field(artifact, "name"),
"state_schema": _plan_field(artifact, "state_schema"), "input_schema": _plan_field(artifact, "input_schema"),
"output_schema": _plan_field(artifact, "output_schema"), "state_schema": _plan_field(artifact, "state_schema"),
"start": _plan_field(artifact, "start"), "output_schema": _plan_field(artifact, "output_schema"),
"nodes": _plan_field(artifact, "nodes"), "start": _plan_field(artifact, "start"),
"edges": _plan_field(artifact, "edges"), "nodes": _plan_field(artifact, "nodes"),
}) "edges": _plan_field(artifact, "edges"),
}
)
def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any: 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 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_map: DraftPathMap = Field( output: DraftOutputBindings | None = 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,
) )
+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: def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
"name": "echo", {
"input_schema": {}, "name": "echo",
"state_schema": {"fields": {}}, "input_schema": {},
"output_schema": {}, "state_schema": {"fields": {}},
"start": "echo", "output_schema": {},
"steps": {"echo": {"use": "demo.echo"}}, "start": "echo",
"routes": {"echo": {"ok": "__end__"}}, "steps": {"echo": {"use": "demo.echo"}},
}) "routes": {"echo": {"ok": "__end__"}},
}
)
workflow = build_workflow_from_draft(draft) 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__" 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", {
"input_schema": {}, "name": "constant",
"state_schema": {"fields": {"message": {"type": "string"}}}, "input_schema": {},
"output_schema": {}, "state_schema": {"fields": {"message": {"type": "string"}}},
"start": "constant", "output_schema": {},
"steps": { "start": "constant",
"constant": { "steps": {
"use": "wf.std.constant", "constant": {
"with": {"value": "CLICKED"}, "use": "wf.std.constant",
"out": {"value": "state.message"}, "with": {"value": "CLICKED"},
} "out": {"value": "state.message"},
}, }
"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,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: def test_adapter_lowers_when_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
"name": "when_example", {
"input_schema": {}, "name": "when_example",
"state_schema": {"fields": {}}, "input_schema": {},
"output_schema": {}, "state_schema": {"fields": {}},
"start": "decide", "output_schema": {},
"steps": { "start": "decide",
"decide": { "steps": {
"when": { "decide": {
"if": { "when": {
"op": "ge", "if": {
"left": {"path": "state.count"}, "op": "ge",
"right": {"value": 1}, "left": {"path": "state.count"},
}, "right": {"value": 1},
"then": "echo", },
"otherwise": "__end__", "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) workflow = build_workflow_from_draft(draft)
condition = workflow.nodes[0] 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: def test_adapter_lowers_choose_step_through_builder() -> None:
draft = WorkflowDraft.model_validate({ draft = WorkflowDraft.model_validate(
"name": "choose_example", {
"input_schema": {}, "name": "choose_example",
"state_schema": {"fields": {}}, "input_schema": {},
"output_schema": {}, "state_schema": {"fields": {}},
"start": "pick", "output_schema": {},
"steps": { "start": "pick",
"pick": { "steps": {
"choose": { "pick": {
"clauses": [ "choose": {
{ "clauses": [
"if": { {
"op": "gt", "if": {
"left": {"path": "state.score"}, "op": "gt",
"right": {"value": 80}, "left": {"path": "state.score"},
"right": {"value": 80},
},
"then": "high",
}, },
"then": "high", {
}, "if": {
{ "op": "exists",
"if": { "path": "state.fallback",
"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"}, "routes": {
"fallback": {"use": "demo.fallback"}, "high": {"ok": "__end__"},
}, "fallback": {"ok": "__end__"},
"routes": { },
"high": {"ok": "__end__"}, }
"fallback": {"ok": "__end__"}, )
},
})
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition_ids = [ condition_ids = [
@@ -178,31 +218,33 @@ 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", {
"input_schema": {}, "name": "match_example",
"state_schema": {"fields": {}}, "input_schema": {},
"output_schema": {}, "state_schema": {"fields": {}},
"start": "match_status", "output_schema": {},
"steps": { "start": "match_status",
"match_status": { "steps": {
"match": { "match_status": {
"value": "state.status", "match": {
"cases": [ "value": "state.status",
{"equals": "ready", "then": "ready"}, "cases": [
{"equals": "waiting", "then": "waiting"}, {"equals": "ready", "then": "ready"},
], {"equals": "waiting", "then": "waiting"},
"default": "__end__", ],
} "default": "__end__",
}
},
"ready": {"use": "demo.ready"},
"waiting": {"use": "demo.waiting"},
}, },
"ready": {"use": "demo.ready"}, "routes": {
"waiting": {"use": "demo.waiting"}, "ready": {"ok": "__end__"},
}, "waiting": {"ok": "__end__"},
"routes": { },
"ready": {"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"}
+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"] 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,22 +531,24 @@ 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", {
"connections": [ "store_root": ".wf_mcp_store",
{ "connections": [
"id": "fixture.personal", {
"server": "fixture", "id": "fixture.personal",
"account": "personal", "server": "fixture",
"enabled": False, "account": "personal",
"metadata": { "enabled": False,
"transport": "stdio", "metadata": {
"command": sys.executable, "transport": "stdio",
"args": [fixture_server_path()], "command": sys.executable,
}, "args": [fixture_server_path()],
} },
], }
}), ],
}
),
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"