draft -> wrapper

This commit is contained in:
lda
2026-05-19 06:59:25 +07:00 Verified
parent 6ad3d20975
commit 01897cc21b
10 changed files with 293 additions and 16 deletions
+6
View File
@@ -513,6 +513,8 @@ Concrete MCP sequence:
7. `wf.workflow.validate_draft_workspace` if capabilities changed or you want 7. `wf.workflow.validate_draft_workspace` if capabilities changed or you want
to refresh diagnostics without editing the draft. to refresh diagnostics without editing the draft.
8. `wf.workflow.create_artifact_from_workspace` after validation is clean. 8. `wf.workflow.create_artifact_from_workspace` after validation is clean.
Use `wf.workflow.create_wrapper_from_workspace` instead when the workspace
is a reusable wrapper around a raw capability.
9. `wf.workflow.save_deployment`, then `validate_deployment`, then 9. `wf.workflow.save_deployment`, then `validate_deployment`, then
`run_deployment`. `run_deployment`.
10. `wf.workflow.delete_draft_workspace` when the mutable authoring session is no 10. `wf.workflow.delete_draft_workspace` when the mutable authoring session is no
@@ -535,3 +537,7 @@ Concrete MCP sequence:
} }
} }
``` ```
`create_wrapper_from_workspace` accepts the same request shape except there is
no `kind` field. It always saves `kind="wrapper"` and the result is discoverable
as a workflow capability named `workflow.<artifact_id>.v<version>`.
+6
View File
@@ -355,6 +355,7 @@ resending the full draft each turn.
| Refresh validation without changing revision | `wf.workflow.validate_draft_workspace` | | Refresh validation without changing revision | `wf.workflow.validate_draft_workspace` |
| Change common draft fields without JSON Patch | `wf.workflow.set_draft_name`, `wf.workflow.set_draft_route`, `wf.workflow.set_step_input_map`, `wf.workflow.set_step_output_map` | | Change common draft fields without JSON Patch | `wf.workflow.set_draft_name`, `wf.workflow.set_draft_route`, `wf.workflow.set_step_input_map`, `wf.workflow.set_step_output_map` |
| Save final workspace as artifact | `wf.workflow.create_artifact_from_workspace` | | Save final workspace as artifact | `wf.workflow.create_artifact_from_workspace` |
| Save final workspace as callable wrapper | `wf.workflow.create_wrapper_from_workspace` |
| Clean up a draft workspace | `wf.workflow.delete_draft_workspace` | | Clean up a draft workspace | `wf.workflow.delete_draft_workspace` |
Workspace patches are optimistic-concurrency guarded. Pass the current Workspace patches are optimistic-concurrency guarded. Pass the current
@@ -365,6 +366,11 @@ 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`, `output_map`, 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
path as `create_artifact_from_workspace`, but the saved artifact kind is fixed
to `wrapper`.
Minimal example: Minimal example:
```json ```json
+7 -3
View File
@@ -262,6 +262,7 @@ authoring loop:
- `wf.workflow.list_capabilities` - `wf.workflow.list_capabilities`
- lists compact paged enabled planner-visible workflow-ready node spec - lists compact paged enabled planner-visible workflow-ready node spec
summaries, with optional query/source filtering summaries, with optional query/source filtering
- also includes saved wrapper artifacts under source id `workflow`
- includes the owning `source_id`, outcomes, and top-level input/output field - includes the owning `source_id`, outcomes, and top-level input/output field
names, but not full schemas names, but not full schemas
- `wf.workflow.inspect_capability` - `wf.workflow.inspect_capability`
@@ -298,9 +299,10 @@ prompts
resources resources
``` ```
Possible later additions may include saved wrappers or workflow artifacts as Possible later additions may include full workflow artifacts as first-class
first-class projected capability kinds, but they should not erase the raw versus projected capability kinds, but they should not erase the raw versus
workflow-facing distinction. workflow-facing distinction. Saved wrapper artifacts are already projected as
workflow capabilities because they have a node-like callable boundary today.
Examples: Examples:
@@ -325,6 +327,8 @@ Today:
- saved artifacts can be tagged with `kind="workflow"` or `kind="wrapper"` - saved artifacts can be tagged with `kind="workflow"` or `kind="wrapper"`
- `wf.workflow.call_capability` can execute one planner-visible workflow - `wf.workflow.call_capability` can execute one planner-visible workflow
capability directly and return normalized `outcome` / `output` capability directly and return normalized `outcome` / `output`
- `wf.workflow.list_capabilities` and `wf.workflow.inspect_capability` project
saved wrapper artifacts as workflow capabilities under source id `workflow`
Not yet implemented: Not yet implemented:
+6 -1
View File
@@ -299,7 +299,8 @@ The workspace flow is:
2. `wf.workflow.get_draft_workspace` 2. `wf.workflow.get_draft_workspace`
3. `wf.workflow.patch_draft_workspace` 3. `wf.workflow.patch_draft_workspace`
4. repeat get/patch until valid 4. repeat get/patch until valid
5. `wf.workflow.create_artifact_from_workspace` 5. `wf.workflow.create_artifact_from_workspace` for a full workflow, or
`wf.workflow.create_wrapper_from_workspace` for a reusable callable wrapper
Workspaces are mutable and revisioned. Artifacts are immutable and versioned. Workspaces are mutable and revisioned. Artifacts are immutable and versioned.
Patch calls must include the current `revision`; stale revisions return Patch calls must include the current `revision`; stale revisions return
@@ -314,6 +315,10 @@ In MCP Inspector, workspace mutation tools accept a single `request` object.
This is deliberate: the request object carries descriptions and validation for This is deliberate: the request object carries descriptions and validation for
the authoring envelope while raw JSON Schema fields remain plain JSON objects. the authoring envelope while raw JSON Schema fields remain plain JSON objects.
`create_wrapper_from_workspace` is intentionally just the wrapper-specific save
path. It validates and compiles the same draft workspace, but fixes the saved
artifact kind to `wrapper` so clients do not need to pass `kind` manually.
## Patching Drafts ## Patching Drafts
`patch_draft` accepts JSON Patch operations. `patch_draft` accepts JSON Patch operations.
+1
View File
@@ -53,6 +53,7 @@ _SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
"wf.workflow.set_step_output_map", "wf.workflow.set_step_output_map",
"wf.workflow.create_minimal_draft_workspace", "wf.workflow.create_minimal_draft_workspace",
"wf.workflow.create_artifact_from_workspace", "wf.workflow.create_artifact_from_workspace",
"wf.workflow.create_wrapper_from_workspace",
"wf.workflow.call_capability", "wf.workflow.call_capability",
"wf.workflow.inspect_artifact", "wf.workflow.inspect_artifact",
"wf.workflow.list_deployments", "wf.workflow.list_deployments",
+115 -3
View File
@@ -97,6 +97,10 @@ class WorkflowSurfaceHandlers:
and query.casefold() in detail.description.casefold() and query.casefold() in detail.description.casefold()
) )
] ]
capabilities.extend(
self._wrapper_capability_summaries(query=query, source_id=source_id)
)
capabilities.sort(key=lambda capability: capability["name"])
page = page_items(capabilities, cursor=cursor, limit=limit) page = page_items(capabilities, cursor=cursor, limit=limit)
return { return {
"capabilities": list(page.items), "capabilities": list(page.items),
@@ -112,6 +116,9 @@ class WorkflowSurfaceHandlers:
for detail in source.as_inventory().capabilities.node_spec_details: for detail in source.as_inventory().capabilities.node_spec_details:
if detail.name == qualified_name: if detail.name == qualified_name:
return detail.model_dump(mode="json") return detail.model_dump(mode="json")
wrapper_detail = self._wrapper_capability_detail(qualified_name)
if wrapper_detail is not None:
return wrapper_detail
raise KeyError(f"unknown workflow capability {qualified_name!r}") raise KeyError(f"unknown workflow capability {qualified_name!r}")
async def call_capability( async def call_capability(
@@ -163,6 +170,71 @@ class WorkflowSurfaceHandlers:
return None return None
return artifact return artifact
def _wrapper_capability_summaries(
self,
*,
query: str | None,
source_id: str | None,
) -> list[dict[str, Any]]:
"""Project saved wrappers into workflow capability discovery rows.
Wrapper artifacts are not live source NodeSpecs, but authors need to
discover and test them through the same workflow-facing REPL surface.
Full saved workflows stay out of this projection until graph-as-node is
real in core.
"""
if source_id not in {None, "workflow"} or self.service.artifact_store is None:
return []
rows: list[dict[str, Any]] = []
for artifact in self.service.artifact_store.list_artifacts():
if artifact.kind != "wrapper":
continue
name = _artifact_capability_id(artifact)
if not _matches_capability_query(
name,
artifact.description,
query=query,
):
continue
rows.append(
{
"name": name,
"source_id": "workflow",
"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(
self,
qualified_name: str,
) -> dict[str, Any] | None:
"""Return a NodeSpec-like contract for one saved wrapper artifact."""
artifact = self._wrapper_artifact_for_capability_name(qualified_name)
if artifact is None:
return None
return {
"name": _artifact_capability_id(artifact),
"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_schema": artifact.input_schema,
"output_schema": artifact.output_schema,
"required_capabilities": {
name: capability.model_dump(mode="json")
for name, capability in sorted(artifact.required_capabilities.items())
},
}
async def _call_wrapper_artifact( async def _call_wrapper_artifact(
self, self,
artifact: WorkflowArtifact, artifact: WorkflowArtifact,
@@ -562,9 +634,7 @@ class WorkflowSurfaceHandlers:
"in": {error_source: "message"}, "in": {error_source: "message"},
"out": {}, "out": {},
} }
routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = ( routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = DEFAULT_ERROR_STEP_ID
DEFAULT_ERROR_STEP_ID
)
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"} routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
draft = { draft = {
"name": name, "name": name,
@@ -618,6 +688,33 @@ class WorkflowSurfaceHandlers:
created_from_catalog_version=created_from_catalog_version, created_from_catalog_version=created_from_catalog_version,
) )
async def create_wrapper_from_workspace(
self,
*,
workspace_id: str,
artifact_id: str,
version: int,
title: str,
outcomes: Sequence[str],
description: str | None = None,
required_capabilities: dict[str, dict[str, Any]] | None = None,
source_bindings: dict[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]:
"""Save the current draft workspace as a callable wrapper artifact."""
return await self.create_artifact_from_workspace(
workspace_id=workspace_id,
artifact_id=artifact_id,
version=version,
title=title,
outcomes=outcomes,
kind="wrapper",
description=description,
required_capabilities=required_capabilities,
source_bindings=source_bindings,
created_from_catalog_version=created_from_catalog_version,
)
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None: def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
try: try:
return self.service._get_qualified_spec(qualified_name).outcomes return self.service._get_qualified_spec(qualified_name).outcomes
@@ -842,6 +939,21 @@ def _schema_field_names(schema: dict[str, Any]) -> list[str]:
return sorted(str(name) for name in properties) return sorted(str(name) for name in properties)
def _matches_capability_query(
name: str,
description: str | None,
*,
query: str | None,
) -> bool:
"""Apply the same compact capability search semantics to every row kind."""
if query is None:
return True
lowered = query.casefold()
return lowered in name.casefold() or (
description is not None and lowered in description.casefold()
)
def _first_state_path(output_map: dict[str, str]) -> str | None: def _first_state_path(output_map: dict[str, str]) -> str | None:
"""Return the first mapped state path for minimal error-route bootstraps.""" """Return the first mapped state path for minimal error-route bootstraps."""
for target in output_map.values(): for target in output_map.values():
+23
View File
@@ -216,3 +216,26 @@ class CreateArtifactFromWorkspaceRequest(BaseModel):
default=None, default=None,
description="Optional catalog version used while authoring.", description="Optional catalog version used while authoring.",
) )
class CreateWrapperFromWorkspaceRequest(BaseModel):
"""Typed MCP request for saving a draft workspace as a wrapper artifact."""
workspace_id: WorkspaceId
artifact_id: str = Field(description="Immutable wrapper artifact id to write.")
version: int = Field(ge=1, description="Wrapper artifact version to write.")
title: str = Field(description="Human-readable wrapper title.")
outcomes: list[str] = Field(description="Wrapper-level outcomes.")
description: str | None = Field(default=None, description="Optional description.")
required_capabilities: dict[str, dict[str, Any]] | None = Field(
default=None,
description="Optional explicit dependency contract override.",
)
source_bindings: SourceBindings | None = Field(
default=None,
description="Optional logical-to-concrete source bindings.",
)
created_from_catalog_version: str | None = Field(
default=None,
description="Optional catalog version used while authoring.",
)
+33 -3
View File
@@ -14,6 +14,7 @@ from .models import (
CreateArtifactFromWorkspaceRequest, CreateArtifactFromWorkspaceRequest,
CreateDraftWorkspaceRequest, CreateDraftWorkspaceRequest,
CreateMinimalDraftWorkspaceRequest, CreateMinimalDraftWorkspaceRequest,
CreateWrapperFromWorkspaceRequest,
DeleteDraftWorkspaceRequest, DeleteDraftWorkspaceRequest,
DeleteDraftWorkspaceResult, DeleteDraftWorkspaceResult,
DraftWorkspaceListResult, DraftWorkspaceListResult,
@@ -405,9 +406,38 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
if isinstance(capability, RequiredCapability) if isinstance(capability, RequiredCapability)
else capability else capability
) )
for name, capability in ( for name, capability in (request.required_capabilities or {}).items()
request.required_capabilities or {} }
).items() or None,
source_bindings=dict(request.source_bindings or {}),
created_from_catalog_version=request.created_from_catalog_version,
)
@server.tool(
name="wf.workflow.create_wrapper_from_workspace",
title="Create Wrapper From Workspace",
description=(
"Validate the current draft workspace and save it as a callable "
"wrapper artifact."
),
)
async def create_wrapper_from_workspace(
request: CreateWrapperFromWorkspaceRequest,
) -> dict[str, Any]:
return await handlers.create_wrapper_from_workspace(
workspace_id=request.workspace_id,
artifact_id=request.artifact_id,
version=request.version,
title=request.title,
description=request.description,
outcomes=request.outcomes,
required_capabilities={
name: (
capability.model_dump()
if isinstance(capability, RequiredCapability)
else capability
)
for name, capability in (request.required_capabilities or {}).items()
} }
or None, or None,
source_bindings=dict(request.source_bindings or {}), source_bindings=dict(request.source_bindings or {}),
+8
View File
@@ -76,6 +76,7 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
assert "wf.workflow.set_step_output_map" in names assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.create_minimal_draft_workspace" in names assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_artifact_from_workspace" in names assert "wf.workflow.create_artifact_from_workspace" in names
assert "wf.workflow.create_wrapper_from_workspace" in names
assert "wf.workflow.run_deployment" in names assert "wf.workflow.run_deployment" in names
call_capability_schema = tools_by_name[ call_capability_schema = tools_by_name[
"wf.workflow.call_capability" "wf.workflow.call_capability"
@@ -101,6 +102,12 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
== "JSON Schema object. Keep this as ordinary JSON; " == "JSON Schema object. Keep this as ordinary JSON; "
"nested schema fields are passed through unchanged." "nested schema fields are passed through unchanged."
) )
wrapper_workspace_input = tools_by_name[
"wf.workflow.create_wrapper_from_workspace"
].inputSchema
wrapper_request = wrapper_workspace_input["properties"]["request"]
assert "kind" not in wrapper_request["properties"]
assert "artifact_id" in wrapper_request["properties"]
echo_result = await client.call_tool( echo_result = await client.call_tool(
"fixture.personal.echo_tool", "fixture.personal.echo_tool",
@@ -206,6 +213,7 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
assert "wf.workflow.set_step_output_map" in names assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.create_minimal_draft_workspace" in names assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_artifact_from_workspace" in names assert "wf.workflow.create_artifact_from_workspace" in names
assert "wf.workflow.create_wrapper_from_workspace" in names
assert "wf.workflow.call_capability" in names assert "wf.workflow.call_capability" in names
assert "wf.workflow.inspect_artifact" in names assert "wf.workflow.inspect_artifact" in names
assert "wf.workflow.list_deployments" in names assert "wf.workflow.list_deployments" in names
+88 -6
View File
@@ -111,6 +111,34 @@ def test_workflow_surface_filters_capabilities_by_source() -> None:
assert payload["capabilities"][0]["source_id"] == "wf.mcp" assert payload["capabilities"][0]["source_id"] == "wf.mcp"
def test_workflow_surface_lists_saved_wrapper_capabilities() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_wrapper_caps"
)
artifact_store.save_artifact(
_echo_artifact().model_copy(
update={
"id": "echo_wrapper",
"kind": "wrapper",
"description": "Reusable echo wrapper.",
}
)
)
artifact_store.save_artifact(_echo_artifact())
handlers = _handlers(artifact_store)
payload = asyncio.run(
handlers.list_capabilities(source_id="workflow", query="echo")
)
names = [capability["name"] for capability in payload["capabilities"]]
assert names == ["workflow.echo_wrapper.v1"]
assert payload["capabilities"][0]["source_id"] == "workflow"
assert payload["capabilities"][0]["outcomes"] == ["completed"]
assert payload["capabilities"][0]["input_fields"] == ["text"]
assert payload["capabilities"][0]["output_fields"] == ["echoed"]
def test_workflow_surface_inspects_one_capability() -> None: def test_workflow_surface_inspects_one_capability() -> None:
handlers = _handlers( handlers = _handlers(
FileWorkflowArtifactStore(local_temp_root() / "surface_inspect_cap") FileWorkflowArtifactStore(local_temp_root() / "surface_inspect_cap")
@@ -125,6 +153,27 @@ def test_workflow_surface_inspects_one_capability() -> None:
assert "input_schema" in payload assert "input_schema" in payload
def test_workflow_surface_inspects_saved_wrapper_capability() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_inspect_wrapper_cap"
)
artifact_store.save_artifact(
_echo_artifact().model_copy(update={"id": "echo_wrapper", "kind": "wrapper"})
)
handlers = _handlers(artifact_store)
payload = asyncio.run(
handlers.inspect_capability(qualified_name="workflow.echo_wrapper.v1")
)
assert payload["name"] == "workflow.echo_wrapper.v1"
assert payload["source_id"] == "workflow"
assert payload["kind"] == "wrapper_artifact"
assert payload["artifact_id"] == "echo_wrapper"
assert payload["outcomes"] == ["completed"]
assert "input_schema" in payload
def test_workflow_surface_validates_deployment_dependencies() -> None: def test_workflow_surface_validates_deployment_dependencies() -> None:
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_validate") artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_validate")
artifact_store.save_artifact(_artifact()) artifact_store.save_artifact(_artifact())
@@ -419,9 +468,7 @@ def test_workflow_surface_deletes_draft_workspace() -> None:
) )
) )
deleted = asyncio.run( deleted = asyncio.run(handlers.delete_draft_workspace(workspace_id="echo_draft"))
handlers.delete_draft_workspace(workspace_id="echo_draft")
)
deleted_again = asyncio.run( deleted_again = asyncio.run(
handlers.delete_draft_workspace(workspace_id="echo_draft") handlers.delete_draft_workspace(workspace_id="echo_draft")
) )
@@ -514,9 +561,7 @@ def test_workflow_surface_validates_draft_workspace_with_live_outcomes() -> None
) )
) )
payload = asyncio.run( payload = asyncio.run(handlers.validate_draft_workspace(workspace_id="echo_draft"))
handlers.validate_draft_workspace(workspace_id="echo_draft")
)
fetched = asyncio.run(handlers.get_draft_workspace(workspace_id="echo_draft")) fetched = asyncio.run(handlers.get_draft_workspace(workspace_id="echo_draft"))
assert payload["revision"] == 1 assert payload["revision"] == 1
@@ -629,6 +674,43 @@ def test_workflow_surface_creates_artifact_from_workspace() -> None:
assert artifact.id == "workspace_echo" assert artifact.id == "workspace_echo"
def test_workflow_surface_creates_wrapper_from_workspace() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_workspace_wrapper"
)
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_workspace_wrapper_mcp"),
artifact_store=artifact_store,
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
handlers = WorkflowSurfaceHandlers(service)
asyncio.run(
handlers.create_draft_workspace(
workspace_id="echo_draft",
draft=_echo_draft(),
)
)
result = asyncio.run(
handlers.create_wrapper_from_workspace(
workspace_id="echo_draft",
artifact_id="workspace_echo_wrapper",
version=1,
title="Workspace Echo Wrapper",
outcomes=("completed",),
source_bindings={"demo": "demo.personal"},
)
)
artifact = artifact_store.get_artifact("workspace_echo_wrapper", 1)
assert result["saved"] is True
assert artifact.kind == "wrapper"
assert artifact.plan["nodes"][0]["node"] == "demo.echo_tool"
def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None: def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None:
plan = RawWorkflowPlan.model_validate(_echo_artifact().plan) plan = RawWorkflowPlan.model_validate(_echo_artifact().plan)