40 KiB
Draft Workspaces Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add mutable draft workspaces so LLM and human clients can create one workflow draft, patch it by id across calls, validate it repeatedly, and finally save an immutable workflow artifact.
Architecture: Keep the draft workspace domain in wf_artifacts, next to immutable artifacts but separate from them. WorkflowDraftWorkspace is mutable and revisioned; WorkflowArtifact remains immutable and versioned. wf_mcp.workflow_surface exposes workspace tools as a projection over the same draft APIs, not a second graph builder.
Tech Stack: Python 3.14, Pydantic v2, JSON file storage, jsonpatch, existing wf_artifacts.drafts, existing WorkflowSurfaceHandlers, pytest, ruff, basedpyright.
Scope
This plan builds the promised stateful wf_authoring over MCP loop:
list sources
list/inspect/call capability
create draft workspace
patch draft workspace by id
validate/get workspace by id
create artifact from workspace
save deployment
run deployment
This plan does not add:
- graph-as-node/subgraph runtime support
- rich JSON
match/when/choosedraft sugar - UI
- auth/user ownership
- automatic clever field mapping
- per-outcome output schemas
The key design choice is explicit: the server owns the draft document after workspace creation, so clients send JSON Patch operations instead of resending the entire draft every turn.
File Structure
- Create
src/wf_artifacts/draft_workspaces/models.py- Pydantic models for mutable workspaces and summaries.
- Create
src/wf_artifacts/draft_workspaces/store.py- Protocol-style store plus file-backed implementation.
- Create
src/wf_artifacts/draft_workspaces/api.py- Pure functions for create/get/patch/validate summaries.
- Create
src/wf_artifacts/draft_workspaces/__init__.py- Public package exports.
- Modify
src/wf_artifacts/__init__.py- Re-export workspace models/store/api.
- Modify
src/wf_mcp/broker/service/core.py- Add
draft_workspace_storetoWfMcpService.
- Add
- Modify
src/wf_mcp/broker/config.py- Wire default file-backed workspace store beside the artifact store.
- Modify
src/wf_mcp/workflow_surface/handlers.py- Add workspace operations and minimal-draft bootstrapping.
- Modify
src/wf_mcp/workflow_surface/tools.py- Register MCP tools for workspace operations.
- Modify
src/wf_mcp/workflow_surface/models.py- Add Inspector-visible response models for workspace tools where useful.
- Modify
src/wf_mcp/transparent_proxy/runtime.py- Pin always-visible workflow workspace tools if this allowlist still gates search mode.
- Modify
docs/workflow_drafts.md- Document stateless draft tools versus stateful draft workspace tools.
- Modify
docs/wf_mcp_operator_manual.md- Add the authoring loop using workspace ids/revisions.
- Modify
docs/wf_mcp_end_to_end_runbook.md- Add a short draft workspace path after the current full-draft path.
- Test
tests/artifacts/test_draft_workspaces.py- Domain/store/API tests.
- Test
tests/wf_mcp/test_workflow_surface.py- Handler tests for workspace tools.
- Test
tests/wf_mcp/test_server.py- MCP tool registration/schema smoke tests.
Public API Shape
Domain Model
class WorkflowDraftWorkspace(BaseModel):
id: str
revision: int = 1
title: str | None = None
draft: dict[str, Any]
status: Literal["valid", "invalid"]
diagnostics: list[dict[str, Any]] = []
created_at_epoch_ms: int
updated_at_epoch_ms: int
Rules:
idis stable and caller-chosen or generated.revisionstarts at1.- Each successful patch increments
revision. - A patch request must include the expected
revision. - Stale revision returns a structured
revision_conflictresult and does not mutate. - Invalid drafts can be stored. The workspace exists so clients can repair them.
- Artifacts are only created from a workspace when the current draft validates.
MCP Tools
New tools:
wf.workflow.create_draft_workspace
wf.workflow.get_draft_workspace
wf.workflow.patch_draft_workspace
wf.workflow.create_minimal_draft_workspace
wf.workflow.create_artifact_from_workspace
Keep existing tools:
wf.workflow.validate_draft
wf.workflow.compile_draft
wf.workflow.patch_draft
wf.workflow.create_artifact_from_draft
The existing stateless tools remain useful for one-shot clients and tests. Workspace tools are the preferred LLM authoring surface.
Response Shape
Workspace mutating tools should return:
{
"workspace_id": "echo_draft",
"revision": 2,
"status": "valid",
"diagnostics": [],
"summary": {
"name": "echo",
"start": "echo",
"step_count": 2,
"route_count": 2,
"steps": ["echo", "tool_error"]
}
}
get_draft_workspace should accept:
include_draft: bool = False
Default response stays compact. If include_draft=True, include the full draft.
Task 1: Add Draft Workspace Domain Models
Files:
-
Create:
src/wf_artifacts/draft_workspaces/models.py -
Create:
src/wf_artifacts/draft_workspaces/__init__.py -
Modify:
src/wf_artifacts/__init__.py -
Test:
tests/artifacts/test_draft_workspaces.py -
Step 1: Write failing model tests
Create tests/artifacts/test_draft_workspaces.py:
from __future__ import annotations
from wf_artifacts import WorkflowDraftWorkspace, summarize_draft_workspace
def test_draft_workspace_stores_mutable_draft_with_revision() -> None:
workspace = WorkflowDraftWorkspace(
id="echo_draft",
revision=1,
title="Echo Draft",
draft=_draft(),
status="valid",
diagnostics=[],
created_at_epoch_ms=100,
updated_at_epoch_ms=100,
)
assert workspace.id == "echo_draft"
assert workspace.revision == 1
assert workspace.draft["steps"]["echo"]["use"] == "demo.echo"
def test_draft_workspace_summary_is_compact() -> None:
workspace = WorkflowDraftWorkspace(
id="echo_draft",
revision=3,
draft=_draft(),
status="valid",
diagnostics=[],
created_at_epoch_ms=100,
updated_at_epoch_ms=200,
)
summary = summarize_draft_workspace(workspace)
assert summary["workspace_id"] == "echo_draft"
assert summary["revision"] == 3
assert summary["status"] == "valid"
assert summary["summary"]["name"] == "echo"
assert summary["summary"]["steps"] == ["echo"]
assert "draft" not in summary
def _draft() -> dict[str, object]:
return {
"name": "echo",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {"fields": {"echoed": {"type": "string"}}},
"output_schema": {"type": "object", "properties": {}},
"start": "echo",
"steps": {
"echo": {
"use": "demo.echo",
"in": {},
"out": {"echoed": "state.echoed"},
}
},
"routes": {"echo": {"ok": "__end__"}},
}
- Step 2: Run model tests and verify they fail
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py -q
Expected: import failure for WorkflowDraftWorkspace.
- Step 3: Implement models
Create src/wf_artifacts/draft_workspaces/models.py:
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
JsonObject = dict[str, Any]
class WorkflowDraftWorkspace(BaseModel):
"""Mutable, revisioned authoring workspace for one workflow draft."""
id: str
revision: int = Field(default=1, ge=1)
title: str | None = None
draft: JsonObject
status: Literal["valid", "invalid"]
diagnostics: list[JsonObject] = Field(default_factory=list)
created_at_epoch_ms: int
updated_at_epoch_ms: int
def summarize_draft_workspace(
workspace: WorkflowDraftWorkspace,
*,
include_draft: bool = False,
) -> JsonObject:
"""Return a compact workspace payload for MCP clients."""
steps = workspace.draft.get("steps", {})
routes = workspace.draft.get("routes", {})
summary: JsonObject = {
"workspace_id": workspace.id,
"revision": workspace.revision,
"title": workspace.title,
"status": workspace.status,
"diagnostics": workspace.diagnostics,
"summary": {
"name": workspace.draft.get("name"),
"start": workspace.draft.get("start"),
"step_count": len(steps) if isinstance(steps, dict) else 0,
"route_count": len(routes) if isinstance(routes, dict) else 0,
"steps": sorted(steps) if isinstance(steps, dict) else [],
},
}
if include_draft:
summary["draft"] = workspace.draft
return summary
Create src/wf_artifacts/draft_workspaces/__init__.py:
from .models import WorkflowDraftWorkspace, summarize_draft_workspace
__all__ = [
"WorkflowDraftWorkspace",
"summarize_draft_workspace",
]
Modify src/wf_artifacts/__init__.py:
from .draft_workspaces import WorkflowDraftWorkspace, summarize_draft_workspace
Add both names to __all__.
- Step 4: Run model tests
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py -q
Expected: 2 passed.
- Step 5: Commit
git add src\wf_artifacts tests\artifacts\test_draft_workspaces.py
git commit -m "feat: add draft workspace models"
Task 2: Add File-Backed Draft Workspace Store
Files:
-
Create:
src/wf_artifacts/draft_workspaces/store.py -
Modify:
src/wf_artifacts/draft_workspaces/__init__.py -
Modify:
src/wf_artifacts/__init__.py -
Test:
tests/artifacts/test_draft_workspaces.py -
Step 1: Add failing store tests
Append to tests/artifacts/test_draft_workspaces.py:
from wf_artifacts import FileDraftWorkspaceStore
def test_file_draft_workspace_store_round_trips_workspace(tmp_path) -> None:
store = FileDraftWorkspaceStore(tmp_path)
workspace = WorkflowDraftWorkspace(
id="echo_draft",
revision=1,
draft=_draft(),
status="valid",
diagnostics=[],
created_at_epoch_ms=100,
updated_at_epoch_ms=100,
)
store.save_workspace(workspace)
loaded = store.get_workspace("echo_draft")
assert loaded == workspace
def test_file_draft_workspace_store_lists_workspaces(tmp_path) -> None:
store = FileDraftWorkspaceStore(tmp_path)
store.save_workspace(
WorkflowDraftWorkspace(
id="b",
revision=1,
draft=_draft(),
status="valid",
diagnostics=[],
created_at_epoch_ms=100,
updated_at_epoch_ms=100,
)
)
store.save_workspace(
WorkflowDraftWorkspace(
id="a",
revision=1,
draft=_draft(),
status="valid",
diagnostics=[],
created_at_epoch_ms=100,
updated_at_epoch_ms=100,
)
)
assert [workspace.id for workspace in store.list_workspaces()] == ["a", "b"]
- Step 2: Run store tests and verify they fail
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py -q
Expected: import failure for FileDraftWorkspaceStore.
- Step 3: Implement store
Create src/wf_artifacts/draft_workspaces/store.py:
from __future__ import annotations
import json
from pathlib import Path
from .models import WorkflowDraftWorkspace
class DraftWorkspaceStore:
"""Storage boundary for mutable workflow draft workspaces."""
def save_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
raise NotImplementedError
def get_workspace(self, workspace_id: str) -> WorkflowDraftWorkspace:
raise NotImplementedError
def list_workspaces(self) -> list[WorkflowDraftWorkspace]:
raise NotImplementedError
class FileDraftWorkspaceStore(DraftWorkspaceStore):
"""JSON file-backed draft workspace store for local development and tests."""
def __init__(self, root: Path) -> None:
self.root = root
self.workspaces_dir.mkdir(parents=True, exist_ok=True)
@property
def workspaces_dir(self) -> Path:
return self.root / "draft_workspaces"
def save_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
path = self.workspaces_dir / f"{workspace.id}.json"
path.write_text(
json.dumps(workspace.model_dump(mode="json"), indent=2),
encoding="utf-8",
)
def get_workspace(self, workspace_id: str) -> WorkflowDraftWorkspace:
path = self.workspaces_dir / f"{workspace_id}.json"
if not path.exists():
raise KeyError(f"unknown draft workspace {workspace_id!r}")
return WorkflowDraftWorkspace.model_validate_json(
path.read_text(encoding="utf-8")
)
def list_workspaces(self) -> list[WorkflowDraftWorkspace]:
return [
WorkflowDraftWorkspace.model_validate_json(path.read_text(encoding="utf-8"))
for path in sorted(self.workspaces_dir.glob("*.json"))
]
Update exports in src/wf_artifacts/draft_workspaces/__init__.py:
from .models import WorkflowDraftWorkspace, summarize_draft_workspace
from .store import DraftWorkspaceStore, FileDraftWorkspaceStore
__all__ = [
"DraftWorkspaceStore",
"FileDraftWorkspaceStore",
"WorkflowDraftWorkspace",
"summarize_draft_workspace",
]
Update src/wf_artifacts/__init__.py to re-export DraftWorkspaceStore and FileDraftWorkspaceStore.
- Step 4: Run store tests
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py -q
Expected: all tests pass.
- Step 5: Commit
git add src\wf_artifacts tests\artifacts\test_draft_workspaces.py
git commit -m "feat: persist draft workspaces"
Task 3: Add Draft Workspace API Functions
Files:
-
Create:
src/wf_artifacts/draft_workspaces/api.py -
Modify:
src/wf_artifacts/draft_workspaces/__init__.py -
Modify:
src/wf_artifacts/__init__.py -
Test:
tests/artifacts/test_draft_workspaces.py -
Step 1: Add failing API tests
Append:
from wf_artifacts import (
create_draft_workspace,
patch_draft_workspace,
)
def test_create_draft_workspace_validates_and_saves(tmp_path) -> None:
store = FileDraftWorkspaceStore(tmp_path)
result = create_draft_workspace(
store,
workspace_id="echo_draft",
draft=_draft(),
title="Echo Draft",
)
loaded = store.get_workspace("echo_draft")
assert result["workspace_id"] == "echo_draft"
assert result["status"] == "valid"
assert loaded.revision == 1
assert loaded.status == "valid"
def test_patch_draft_workspace_applies_patch_and_increments_revision(tmp_path) -> None:
store = FileDraftWorkspaceStore(tmp_path)
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
result = patch_draft_workspace(
store,
workspace_id="echo_draft",
revision=1,
patch=[
{
"op": "replace",
"path": "/steps/echo/in/input.text",
"value": "message",
}
],
)
loaded = store.get_workspace("echo_draft")
assert result["revision"] == 2
assert result["status"] == "valid"
assert loaded.revision == 2
assert loaded.draft["steps"]["echo"]["in"]["input.text"] == "message"
def test_patch_draft_workspace_rejects_stale_revision(tmp_path) -> None:
store = FileDraftWorkspaceStore(tmp_path)
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
patch_draft_workspace(
store,
workspace_id="echo_draft",
revision=1,
patch=[],
)
result = patch_draft_workspace(
store,
workspace_id="echo_draft",
revision=1,
patch=[],
)
assert result["status"] == "conflict"
assert result["diagnostics"][0]["code"] == "revision_conflict"
assert store.get_workspace("echo_draft").revision == 2
- Step 2: Run API tests and verify they fail
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py -q
Expected: import failure for API functions.
- Step 3: Implement API
Create src/wf_artifacts/draft_workspaces/api.py:
from __future__ import annotations
import time
from typing import Any
from wf_artifacts.drafts import patch_workflow_draft, validate_workflow_draft
from .models import WorkflowDraftWorkspace, summarize_draft_workspace
from .store import DraftWorkspaceStore
JsonObject = dict[str, Any]
JsonPatch = list[dict[str, Any]]
def create_draft_workspace(
store: DraftWorkspaceStore,
*,
workspace_id: str,
draft: JsonObject,
title: str | None = None,
) -> JsonObject:
"""Validate and save a new mutable draft workspace."""
now = _now_ms()
validation = validate_workflow_draft(draft)
workspace = WorkflowDraftWorkspace(
id=workspace_id,
revision=1,
title=title,
draft=draft,
status=validation["status"],
diagnostics=validation["diagnostics"],
created_at_epoch_ms=now,
updated_at_epoch_ms=now,
)
store.save_workspace(workspace)
return summarize_draft_workspace(workspace)
def patch_draft_workspace(
store: DraftWorkspaceStore,
*,
workspace_id: str,
revision: int,
patch: JsonPatch,
) -> JsonObject:
"""Apply JSON Patch to a stored workspace when the revision matches."""
workspace = store.get_workspace(workspace_id)
if workspace.revision != revision:
return {
"workspace_id": workspace.id,
"revision": workspace.revision,
"status": "conflict",
"diagnostics": [
{
"code": "revision_conflict",
"path": "revision",
"message": (
f"workspace {workspace.id!r} is at revision "
f"{workspace.revision}, not {revision}"
),
}
],
"summary": summarize_draft_workspace(workspace)["summary"],
}
patched = patch_workflow_draft(workspace.draft, patch)
next_workspace = workspace.model_copy(
update={
"revision": workspace.revision + 1,
"draft": patched.get("draft", workspace.draft),
"status": patched["status"],
"diagnostics": patched["diagnostics"],
"updated_at_epoch_ms": _now_ms(),
}
)
store.save_workspace(next_workspace)
return summarize_draft_workspace(next_workspace)
def get_draft_workspace(
store: DraftWorkspaceStore,
*,
workspace_id: str,
include_draft: bool = False,
) -> JsonObject:
"""Return one stored workspace, compact by default."""
return summarize_draft_workspace(
store.get_workspace(workspace_id),
include_draft=include_draft,
)
def _now_ms() -> int:
return int(time.time() * 1000)
Update exports in both __init__.py files.
- Step 4: Run API tests
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py -q
Expected: all tests pass.
- Step 5: Commit
git add src\wf_artifacts tests\artifacts\test_draft_workspaces.py
git commit -m "feat: patch draft workspaces by revision"
Task 4: Wire Draft Workspace Store Into WfMcpService
Files:
-
Modify:
src/wf_mcp/broker/service/core.py -
Modify:
src/wf_mcp/broker/config.py -
Test:
tests/wf_mcp/test_service.py -
Step 1: Add failing service test
Append to tests/wf_mcp/test_service.py:
from wf_artifacts import FileDraftWorkspaceStore
def test_service_installs_default_draft_workspace_store() -> None:
root = local_temp_root() / "service_default_draft_workspace_store"
service = WfMcpService(store=FileStore(root))
assert isinstance(service.draft_workspace_store, FileDraftWorkspaceStore)
assert service.draft_workspace_store.root == root
- Step 2: Run service test and verify it fails
Run:
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_service_installs_default_draft_workspace_store -q
Expected: attribute error for draft_workspace_store.
- Step 3: Implement service field
Modify imports in src/wf_mcp/broker/service/core.py:
from wf_artifacts import (
FileDraftWorkspaceStore,
FileWorkflowArtifactStore,
WorkflowArtifact,
WorkflowArtifactCatalogEntry,
WorkflowArtifactStore,
WorkflowDeployment,
DraftWorkspaceStore,
artifact_catalog_entry,
)
Add field:
draft_workspace_store: DraftWorkspaceStore | None = None
In __post_init__ after artifact store setup:
if self.draft_workspace_store is None:
self.draft_workspace_store = FileDraftWorkspaceStore(_store_root(self.store))
Modify src/wf_mcp/broker/config.py if it constructs WfMcpService with explicit stores. Pass:
draft_workspace_store=FileDraftWorkspaceStore(config.store_root)
- Step 4: Run service test
Run:
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_service_installs_default_draft_workspace_store -q
Expected: pass.
- Step 5: Commit
git add src\wf_mcp tests\wf_mcp\test_service.py
git commit -m "feat: attach draft workspace store to service"
Task 5: Add Workflow Surface Workspace Handlers
Files:
-
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Test:
tests/wf_mcp/test_workflow_surface.py -
Step 1: Add failing handler tests
Append to tests/wf_mcp/test_workflow_surface.py:
def test_workflow_surface_creates_and_gets_draft_workspace() -> None:
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_workspace")
handlers = _handlers(artifact_store)
created = asyncio.run(
handlers.create_draft_workspace(
workspace_id="echo_draft",
title="Echo Draft",
draft=_echo_draft(),
)
)
fetched = asyncio.run(
handlers.get_draft_workspace(
workspace_id="echo_draft",
include_draft=True,
)
)
assert created["workspace_id"] == "echo_draft"
assert created["revision"] == 1
assert fetched["draft"]["steps"]["echo"]["use"] == "demo.echo_tool"
def test_workflow_surface_patches_draft_workspace_by_revision() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_workspace_patch"
)
handlers = _handlers(artifact_store)
asyncio.run(
handlers.create_draft_workspace(
workspace_id="echo_draft",
draft=_echo_draft(),
)
)
patched = asyncio.run(
handlers.patch_draft_workspace(
workspace_id="echo_draft",
revision=1,
patch=[
{
"op": "replace",
"path": "/steps/echo/in/input.text",
"value": "message",
}
],
)
)
assert patched["revision"] == 2
assert patched["status"] == "valid"
- Step 2: Run handler tests and verify they fail
Run:
uv run --with pytest pytest tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_creates_and_gets_draft_workspace tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_patches_draft_workspace_by_revision -q
Expected: missing handler methods.
- Step 3: Implement handler methods
Modify imports:
from wf_artifacts import (
create_draft_workspace,
get_draft_workspace,
patch_draft_workspace,
)
Add helper:
def _draft_store(self):
if self.service.draft_workspace_store is None:
raise KeyError("draft workspace store is not configured")
return self.service.draft_workspace_store
Add methods:
async def create_draft_workspace(
self,
*,
workspace_id: str,
draft: dict[str, Any],
title: str | None = None,
) -> dict[str, Any]:
return create_draft_workspace(
self._draft_store(),
workspace_id=workspace_id,
draft=draft,
title=title,
)
async def get_draft_workspace(
self,
*,
workspace_id: str,
include_draft: bool = False,
) -> dict[str, Any]:
return get_draft_workspace(
self._draft_store(),
workspace_id=workspace_id,
include_draft=include_draft,
)
async def patch_draft_workspace(
self,
*,
workspace_id: str,
revision: int,
patch: list[dict[str, Any]],
) -> dict[str, Any]:
return patch_draft_workspace(
self._draft_store(),
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
Note: In implementation, avoid name shadowing by importing API functions with aliases:
create_draft_workspace as create_draft_workspace_record
- Step 4: Run handler tests
Run:
uv run --with pytest pytest tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_creates_and_gets_draft_workspace tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_patches_draft_workspace_by_revision -q
Expected: pass.
- Step 5: Commit
git add src\wf_mcp tests\wf_mcp\test_workflow_surface.py
git commit -m "feat: expose draft workspace handlers"
Task 6: Add Minimal Draft Workspace Bootstrapper
Files:
-
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Test:
tests/wf_mcp/test_workflow_surface.py -
Step 1: Add failing bootstrap test
Append:
def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_minimal_workspace"
)
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_minimal_workspace_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)
result = asyncio.run(
handlers.create_minimal_draft_workspace(
workspace_id="echo_draft",
name="echo",
capability_name="demo.personal.echo_tool",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
state_schema={"fields": {"echoed": {"type": "string"}}},
output_schema={
"type": "object",
"properties": {"echoed": {"type": "string"}},
"required": ["echoed"],
},
input_map={"input.text": "text"},
output_map={"echoed": "state.echoed"},
)
)
workspace = service.draft_workspace_store.get_workspace("echo_draft")
assert result["workspace_id"] == "echo_draft"
assert workspace.draft["routes"]["echo"]["ok"] == "__end__"
assert workspace.draft["routes"]["echo"]["error"] == "tool_error"
assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error"
- Step 2: Run test and verify it fails
Run:
uv run --with pytest pytest tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_creates_minimal_draft_workspace_with_error_route -q
Expected: missing handler method.
- Step 3: Implement minimal draft builder
Add method to WorkflowSurfaceHandlers:
async def create_minimal_draft_workspace(
self,
*,
workspace_id: str,
name: str,
capability_name: str,
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],
title: str | None = None,
) -> dict[str, Any]:
outcomes = self._outcomes_for_capability(capability_name) or ("ok",)
steps: dict[str, Any] = {
"echo": {
"use": capability_name,
"in": input_map,
"out": output_map,
}
}
routes: dict[str, dict[str, str]] = {"echo": {"ok": "__end__"}}
if "error" in outcomes:
steps["tool_error"] = {
"use": "wf.std.runtime_error",
"in": {"state.echoed": "message"},
"out": {},
}
routes["echo"]["error"] = "tool_error"
routes["tool_error"] = {"ok": "__end__"}
draft = {
"name": name,
"input_schema": input_schema,
"state_schema": state_schema,
"output_schema": output_schema,
"start": "echo",
"steps": steps,
"routes": routes,
}
return await self.create_draft_workspace(
workspace_id=workspace_id,
title=title,
draft=draft,
)
Important: The error-route input map is intentionally simple and imperfect.
If the output map does not write a usable error message into state, validation may still pass but runtime may fail. Document this as an initial bootstrapper limitation. A later version can accept error_message_path.
- Step 4: Run bootstrap test
Run:
uv run --with pytest pytest tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_creates_minimal_draft_workspace_with_error_route -q
Expected: pass.
- Step 5: Commit
git add src\wf_mcp tests\wf_mcp\test_workflow_surface.py
git commit -m "feat: bootstrap minimal draft workspaces"
Task 7: Add Artifact Creation From Workspace
Files:
-
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Test:
tests/wf_mcp/test_workflow_surface.py -
Step 1: Add failing artifact-from-workspace test
Append:
def test_workflow_surface_creates_artifact_from_workspace() -> None:
artifact_store = FileWorkflowArtifactStore(
local_temp_root() / "surface_workspace_artifact"
)
service = WfMcpService(
store=FileStore(local_temp_root() / "surface_workspace_artifact_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_artifact_from_workspace(
workspace_id="echo_draft",
artifact_id="workspace_echo",
version=1,
title="Workspace Echo",
outcomes=("completed",),
source_bindings={"demo": "demo.personal"},
)
)
artifact = artifact_store.get_artifact("workspace_echo", 1)
assert result["saved"] is True
assert artifact.id == "workspace_echo"
- Step 2: Run test and verify it fails
Run:
uv run --with pytest pytest tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_creates_artifact_from_workspace -q
Expected: missing handler method.
- Step 3: Implement artifact-from-workspace handler
Add:
async def create_artifact_from_workspace(
self,
*,
workspace_id: str,
artifact_id: str,
version: int,
title: str,
outcomes: Sequence[str],
kind: ArtifactKind = "workflow",
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]:
workspace = self._draft_store().get_workspace(workspace_id)
validation = await self.validate_draft(draft=workspace.draft)
if validation["status"] != "valid":
return {
"saved": False,
"workspace_id": workspace_id,
"revision": workspace.revision,
"status": validation["status"],
"diagnostics": validation["diagnostics"],
}
return await self.create_artifact_from_draft(
artifact_id=artifact_id,
version=version,
title=title,
kind=kind,
description=description,
draft=workspace.draft,
outcomes=outcomes,
required_capabilities=required_capabilities,
source_bindings=source_bindings,
created_from_catalog_version=created_from_catalog_version,
)
- Step 4: Run test
Run:
uv run --with pytest pytest tests\wf_mcp\test_workflow_surface.py::test_workflow_surface_creates_artifact_from_workspace -q
Expected: pass.
- Step 5: Commit
git add src\wf_mcp tests\wf_mcp\test_workflow_surface.py
git commit -m "feat: save artifacts from draft workspaces"
Task 8: Register MCP Workspace Tools
Files:
-
Modify:
src/wf_mcp/workflow_surface/tools.py -
Modify:
src/wf_mcp/workflow_surface/models.py -
Modify:
src/wf_mcp/transparent_proxy/runtime.py -
Test:
tests/wf_mcp/test_server.py -
Step 1: Add failing MCP tool registration test
Modify tests/wf_mcp/test_server.py::test_server_exposes_upstream_admin_and_workflow_tools:
Add expected names:
assert "wf.workflow.create_draft_workspace" in names
assert "wf.workflow.get_draft_workspace" in names
assert "wf.workflow.patch_draft_workspace" in names
assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_artifact_from_workspace" in names
Add schema assertion:
create_workspace_schema = tools_by_name[
"wf.workflow.create_draft_workspace"
].outputSchema
assert create_workspace_schema is not None
assert "workspace_id" in create_workspace_schema["properties"]
assert "revision" in create_workspace_schema["properties"]
- Step 2: Run server test and verify it fails
Run:
uv run --with pytest pytest tests\wf_mcp\test_server.py::test_server_exposes_upstream_admin_and_workflow_tools -q
Expected: missing tool names.
- Step 3: Add response model
Add to src/wf_mcp/workflow_surface/models.py:
class DraftWorkspaceResult(BaseModel):
"""Inspector-visible response for draft workspace operations."""
workspace_id: str
revision: int
title: str | None = None
status: str
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
summary: dict[str, Any]
draft: dict[str, Any] | None = None
- Step 4: Register tools
Add to src/wf_mcp/workflow_surface/tools.py:
@server.tool(
name="wf.workflow.create_draft_workspace",
title="Create Draft Workspace",
description="Store a mutable workflow draft workspace for iterative patching.",
)
async def create_draft_workspace(
workspace_id: str,
draft: dict[str, Any],
title: str | None = None,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.create_draft_workspace(
workspace_id=workspace_id,
draft=draft,
title=title,
)
)
Register the other four tools similarly:
get_draft_workspace(workspace_id: str, include_draft: bool = False)
patch_draft_workspace(workspace_id: str, revision: int, patch: list[dict[str, Any]])
create_minimal_draft_workspace(...)
create_artifact_from_workspace(...)
For create_artifact_from_workspace, returning dict[str, Any] is acceptable if the response already mirrors create_artifact_from_draft. If Inspector schema quality matters, add a response model in the same file.
Update src/wf_mcp/transparent_proxy/runtime.py allowlist if it has explicit always-visible workflow tools.
- Step 5: Run server test
Run:
uv run --with pytest pytest tests\wf_mcp\test_server.py::test_server_exposes_upstream_admin_and_workflow_tools -q
Expected: pass.
- Step 6: Commit
git add src\wf_mcp tests\wf_mcp\test_server.py
git commit -m "feat: expose draft workspace mcp tools"
Task 9: Documentation
Files:
-
Modify:
docs/workflow_drafts.md -
Modify:
docs/wf_mcp_operator_manual.md -
Modify:
docs/wf_mcp_end_to_end_runbook.md -
Test: none beyond docs lint via ruff
-
Step 1: Update
docs/workflow_drafts.md
Add a section after "Draft Tools":
## Draft Workspaces
Stateless draft tools require the caller to resend the whole draft. Draft
workspaces are the preferred LLM authoring flow when a client will patch a
workflow over several turns.
Workflow:
1. `wf.workflow.create_minimal_draft_workspace`
2. `wf.workflow.get_draft_workspace`
3. `wf.workflow.patch_draft_workspace`
4. repeat get/patch until valid
5. `wf.workflow.create_artifact_from_workspace`
Workspaces are mutable and revisioned. Artifacts are immutable and versioned.
Patch calls must include the current `revision`; stale revisions return
`revision_conflict` and do not mutate the workspace.
- Step 2: Update
docs/wf_mcp_operator_manual.md
Add a concise tool family update:
| Start a patchable authoring session | `wf.workflow.create_minimal_draft_workspace` |
| Fetch current draft workspace | `wf.workflow.get_draft_workspace` |
| Patch current draft workspace | `wf.workflow.patch_draft_workspace` |
| Save final workspace as artifact | `wf.workflow.create_artifact_from_workspace` |
- Step 3: Update
docs/wf_mcp_end_to_end_runbook.md
Add a workspace-based variant:
### Workspace Variant
If the client is iterating with an LLM, prefer a draft workspace:
1. Create a minimal workspace from the selected capability.
2. Patch it by id and revision.
3. Save an artifact from the workspace after validation is clean.
This avoids resending the whole draft object every turn.
- Step 4: Run docs check
Run:
uvx ruff check docs\workflow_drafts.md docs\wf_mcp_operator_manual.md docs\wf_mcp_end_to_end_runbook.md
Expected: pass.
- Step 5: Commit
git add docs\workflow_drafts.md docs\wf_mcp_operator_manual.md docs\wf_mcp_end_to_end_runbook.md
git commit -m "docs: describe draft workspace authoring loop"
Task 10: Final Verification
Files:
-
No code changes unless failures require fixes.
-
Step 1: Run focused test suite
Run:
uv run --with pytest pytest tests\artifacts\test_draft_workspaces.py tests\artifacts\test_draft_api.py tests\wf_mcp\test_workflow_surface.py tests\wf_mcp\test_server.py -q
Expected: pass.
- Step 2: Run static checks
Run:
uvx ruff check src tests docs
uv run basedpyright --level error
Expected: both pass.
- Step 3: Run full tests if time allows
Run:
uv run --with pytest pytest -q
Expected: all tests pass or known live-only skips.
- Step 4: Commit final fixes
Only if Step 1-3 required additional fixes:
git add src tests docs
git commit -m "test: verify draft workspace authoring flow"
Self-Review
Spec coverage:
- Mutable server-side draft state: Task 1-5.
- Revisioned JSON Patch loop: Task 3 and Task 5.
- Minimal draft bootstrapper: Task 6.
- Artifact creation from workspace: Task 7.
- MCP-visible tools and schemas: Task 8.
- Documentation: Task 9.
- Verification: Task 10.
No placeholders remain. The only intentionally deferred behavior is richer automatic mapping and richer JSON control flow; both are explicitly out of scope.
Potential implementation risks:
- Name collision between imported API functions and handler methods. Use alias imports in handlers.
create_minimal_draft_workspaceerror mapping is intentionally simple. If the generated error route needs a better message path later, adderror_message_maporerror_message_path.- Existing
patch_draftvalidates without MCP outcome lookup. Workspace patching may initially share that limitation unless handler-level patching callsvalidate_draftafter patch. Prefer handler validation with outcome lookup if practical.