concrete typing from pydantic

This commit is contained in:
lda
2026-05-19 05:35:38 +07:00 Verified
parent dd577d9731
commit 1d6a70431b
8 changed files with 290 additions and 65 deletions
+32
View File
@@ -495,3 +495,35 @@ If the client is iterating with an LLM, prefer a draft workspace:
This avoids resending the whole draft object every turn. The saved artifact is This avoids resending the whole draft object every turn. The saved artifact is
still immutable and should be deployed through the normal deployment path. still immutable and should be deployed through the normal deployment path.
Concrete MCP sequence:
1. `wf.workflow.list_capabilities` with a query such as `echo`.
2. `wf.workflow.call_capability` with a small payload to verify the selected
capability behaves as expected.
3. `wf.workflow.create_minimal_draft_workspace` with a `request` object that
contains schemas, `input_map`, and `output_map`.
4. `wf.workflow.get_draft_workspace` with `include_draft=true` if the client
needs to inspect the full current draft.
5. `wf.workflow.patch_draft_workspace` with the current `revision`.
6. `wf.workflow.create_artifact_from_workspace` after validation is clean.
7. `wf.workflow.save_deployment`, then `validate_deployment`, then
`run_deployment`.
`create_artifact_from_workspace` also uses a `request` object:
```json
{
"request": {
"workspace_id": "echo_draft",
"artifact_id": "echo",
"version": 1,
"title": "Echo",
"outcomes": ["completed"],
"source_bindings": {
"demo": "demo.personal",
"wf.std": "wf.std"
}
}
}
```
+65
View File
@@ -356,3 +356,68 @@ resending the full draft each turn.
Workspace patches are optimistic-concurrency guarded. Pass the current Workspace patches are optimistic-concurrency guarded. Pass the current
`revision` from `get_draft_workspace`; a stale revision returns `revision` from `get_draft_workspace`; a stale revision returns
`revision_conflict` and leaves the stored draft unchanged. `revision_conflict` and leaves the stored draft unchanged.
Workspace mutation tools use a single `request` object in MCP Inspector. That
keeps the form grouped and lets the schema describe fields like
`input_schema`, `output_map`, and `error_message_source`.
Minimal example:
```json
{
"request": {
"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"
}
}
}
```
Patch example:
```json
{
"request": {
"workspace_id": "echo_draft",
"revision": 1,
"patch": [
{
"op": "replace",
"path": "/name",
"value": "echo_v2"
}
]
}
}
```
+4
View File
@@ -220,6 +220,10 @@ 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 `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.
This is deliberate: the request object carries descriptions and validation for
the authoring envelope while raw JSON Schema fields remain plain JSON objects.
## Patching Drafts ## Patching Drafts
`patch_draft` accepts JSON Patch operations. `patch_draft` accepts JSON Patch operations.
+15
View File
@@ -0,0 +1,15 @@
"""Shared workflow-surface literals used by generated draft helpers."""
DEFAULT_CALL_STEP_ID = "call"
DEFAULT_ERROR_STEP_ID = "tool_error"
DEFAULT_OK_OUTCOME = "ok"
DEFAULT_ERROR_OUTCOME = "error"
RUNTIME_ERROR_CAPABILITY = "wf.std.runtime_error"
__all__ = [
"DEFAULT_CALL_STEP_ID",
"DEFAULT_ERROR_OUTCOME",
"DEFAULT_ERROR_STEP_ID",
"DEFAULT_OK_OUTCOME",
"RUNTIME_ERROR_CAPABILITY",
]
+22 -9
View File
@@ -35,6 +35,13 @@ from wf_core import RuntimeContext
from ..events import make_event from ..events import make_event
from ..models import RawWorkflowPlan from ..models import RawWorkflowPlan
from .constants import (
DEFAULT_CALL_STEP_ID,
DEFAULT_ERROR_OUTCOME,
DEFAULT_ERROR_STEP_ID,
DEFAULT_OK_OUTCOME,
RUNTIME_ERROR_CAPABILITY,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..broker.service import WfMcpService from ..broker.service import WfMcpService
@@ -423,33 +430,39 @@ class WorkflowSurfaceHandlers:
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."""
outcomes = self._outcomes_for_capability(capability_name) or ("ok",) outcomes = self._outcomes_for_capability(capability_name) or (
DEFAULT_OK_OUTCOME,
)
steps: dict[str, Any] = { steps: dict[str, Any] = {
"call": { DEFAULT_CALL_STEP_ID: {
"use": capability_name, "use": capability_name,
"in": input_map, "in": input_map,
"out": output_map, "out": output_map,
} }
} }
routes: dict[str, dict[str, str]] = {"call": {"ok": "__end__"}} routes: dict[str, dict[str, str]] = {
DEFAULT_CALL_STEP_ID: {DEFAULT_OK_OUTCOME: "__end__"}
}
error_source = error_message_source or _first_state_path(output_map) error_source = error_message_source or _first_state_path(output_map)
if "error" 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
# exposes, a concrete state path that can become a runtime message. # exposes, a concrete state path that can become a runtime message.
steps["tool_error"] = { steps[DEFAULT_ERROR_STEP_ID] = {
"use": "wf.std.runtime_error", "use": RUNTIME_ERROR_CAPABILITY,
"in": {error_source: "message"}, "in": {error_source: "message"},
"out": {}, "out": {},
} }
routes["call"]["error"] = "tool_error" routes[DEFAULT_CALL_STEP_ID][DEFAULT_ERROR_OUTCOME] = (
routes["tool_error"] = {"ok": "__end__"} DEFAULT_ERROR_STEP_ID
)
routes[DEFAULT_ERROR_STEP_ID] = {DEFAULT_OK_OUTCOME: "__end__"}
draft = { draft = {
"name": name, "name": name,
"input_schema": input_schema, "input_schema": input_schema,
"state_schema": state_schema, "state_schema": state_schema,
"output_schema": output_schema, "output_schema": output_schema,
"start": "call", "start": DEFAULT_CALL_STEP_ID,
"steps": steps, "steps": steps,
"routes": routes, "routes": routes,
} }
+101 -1
View File
@@ -1,9 +1,46 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from wf_artifacts import ArtifactKind
from wf_artifacts.draft_workspaces.models import WORKSPACE_ID_PATTERN
WorkspaceId = Annotated[
str,
Field(
pattern=WORKSPACE_ID_PATTERN,
description="Draft workspace id. Use letters, numbers, underscore, dot, or dash.",
),
]
JsonSchemaObject = Annotated[
dict[str, Any],
Field(
description=(
"JSON Schema object. Keep this as ordinary JSON; nested schema fields "
"are passed through unchanged."
)
),
]
DraftPathMap = Annotated[
dict[str, str],
Field(
description=(
"Map local draft paths to workflow paths, for example "
"{'input.text': 'text'} or {'echoed': 'state.echoed'}."
)
),
]
JsonPatchOperations = Annotated[
list[dict[str, Any]],
Field(description="RFC 6902 JSON Patch operations."),
]
SourceBindings = Annotated[
dict[str, str],
Field(description="Map logical source ids to concrete source ids."),
]
class CallCapabilityResult(BaseModel): class CallCapabilityResult(BaseModel):
"""Inspector-visible response contract for testing one workflow capability.""" """Inspector-visible response contract for testing one workflow capability."""
@@ -52,3 +89,66 @@ class DraftWorkspaceResult(BaseModel):
default=None, default=None,
description="Full draft document, only returned when requested.", description="Full draft document, only returned when requested.",
) )
class CreateDraftWorkspaceRequest(BaseModel):
"""Typed MCP request payload for creating a stored draft workspace."""
workspace_id: WorkspaceId
draft: dict[str, Any] = Field(description="WorkflowDraft JSON document.")
title: str | None = Field(default=None, description="Optional workspace title.")
class PatchDraftWorkspaceRequest(BaseModel):
"""Typed MCP request payload for revision-checked draft workspace patching."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
patch: JsonPatchOperations
class CreateMinimalDraftWorkspaceRequest(BaseModel):
"""Typed MCP request payload for bootstrapping one-capability drafts."""
workspace_id: WorkspaceId
name: str = Field(description="Workflow draft name.")
capability_name: str = Field(
description="Workflow capability to call, such as demo.default.echo_tool."
)
input_schema: JsonSchemaObject
state_schema: JsonSchemaObject
output_schema: JsonSchemaObject
input_map: DraftPathMap
output_map: DraftPathMap
error_message_source: str | None = Field(
default=None,
description=(
"Optional state path used as runtime_error.message when the capability "
"has an error outcome, for example state.error_message."
),
)
title: str | None = Field(default=None, description="Optional workspace title.")
class CreateArtifactFromWorkspaceRequest(BaseModel):
"""Typed MCP request payload for saving a draft workspace as an artifact."""
workspace_id: WorkspaceId
artifact_id: str = Field(description="Immutable artifact id to write.")
version: int = Field(ge=1, description="Artifact version to write.")
title: str = Field(description="Human-readable artifact title.")
outcomes: list[str] = Field(description="Artifact-level outcomes.")
kind: ArtifactKind = Field(default="workflow", description="Artifact kind.")
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.",
)
+40 -55
View File
@@ -9,7 +9,14 @@ from wf_artifacts.models import RequiredCapability
from wf_mcp.broker.service import WfMcpService from wf_mcp.broker.service import WfMcpService
from .handlers import WorkflowSurfaceHandlers from .handlers import WorkflowSurfaceHandlers
from .models import CallCapabilityResult, DraftWorkspaceResult from .models import (
CallCapabilityResult,
CreateArtifactFromWorkspaceRequest,
CreateDraftWorkspaceRequest,
CreateMinimalDraftWorkspaceRequest,
DraftWorkspaceResult,
PatchDraftWorkspaceRequest,
)
def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None: def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None:
@@ -196,15 +203,13 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
description="Store a mutable workflow draft workspace for iterative patching.", description="Store a mutable workflow draft workspace for iterative patching.",
) )
async def create_draft_workspace( async def create_draft_workspace(
workspace_id: str, request: CreateDraftWorkspaceRequest,
draft: dict[str, Any],
title: str | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate( return DraftWorkspaceResult.model_validate(
await handlers.create_draft_workspace( await handlers.create_draft_workspace(
workspace_id=workspace_id, workspace_id=request.workspace_id,
draft=draft, draft=request.draft,
title=title, title=request.title,
) )
) )
@@ -233,15 +238,13 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
), ),
) )
async def patch_draft_workspace( async def patch_draft_workspace(
workspace_id: str, request: PatchDraftWorkspaceRequest,
revision: int,
patch: list[dict[str, Any]],
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate( return DraftWorkspaceResult.model_validate(
await handlers.patch_draft_workspace( await handlers.patch_draft_workspace(
workspace_id=workspace_id, workspace_id=request.workspace_id,
revision=revision, revision=request.revision,
patch=patch, patch=request.patch,
) )
) )
@@ -251,29 +254,20 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
description="Bootstrap a patchable draft workspace around one capability.", description="Bootstrap a patchable draft workspace around one capability.",
) )
async def create_minimal_draft_workspace( async def create_minimal_draft_workspace(
workspace_id: str, request: CreateMinimalDraftWorkspaceRequest,
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],
error_message_source: str | None = None,
title: str | None = None,
) -> DraftWorkspaceResult: ) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate( return DraftWorkspaceResult.model_validate(
await handlers.create_minimal_draft_workspace( await handlers.create_minimal_draft_workspace(
workspace_id=workspace_id, workspace_id=request.workspace_id,
name=name, name=request.name,
capability_name=capability_name, capability_name=request.capability_name,
input_schema=input_schema, input_schema=request.input_schema,
state_schema=state_schema, state_schema=request.state_schema,
output_schema=output_schema, output_schema=request.output_schema,
input_map=input_map, input_map=request.input_map,
output_map=output_map, output_map=request.output_map,
error_message_source=error_message_source, error_message_source=request.error_message_source,
title=title, title=request.title,
) )
) )
@@ -286,38 +280,29 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
), ),
) )
async def create_artifact_from_workspace( async def create_artifact_from_workspace(
workspace_id: str, request: CreateArtifactFromWorkspaceRequest,
artifact_id: str,
version: int,
title: str,
outcomes: list[str],
kind: ArtifactKind = "workflow",
description: str | None = None,
required_capabilities: (
Mapping[str, RequiredCapability | dict[str, Any]] | None
) = None,
source_bindings: Mapping[str, str] | None = None,
created_from_catalog_version: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return await handlers.create_artifact_from_workspace( return await handlers.create_artifact_from_workspace(
workspace_id=workspace_id, workspace_id=request.workspace_id,
artifact_id=artifact_id, artifact_id=request.artifact_id,
version=version, version=request.version,
title=title, title=request.title,
kind=kind, kind=request.kind,
description=description, description=request.description,
outcomes=outcomes, outcomes=request.outcomes,
required_capabilities={ required_capabilities={
name: ( name: (
capability.model_dump() capability.model_dump()
if isinstance(capability, RequiredCapability) if isinstance(capability, RequiredCapability)
else capability else capability
) )
for name, capability in (required_capabilities or {}).items() for name, capability in (
request.required_capabilities or {}
).items()
} }
or None, or None,
source_bindings=dict(source_bindings or {}), source_bindings=dict(request.source_bindings or {}),
created_from_catalog_version=created_from_catalog_version, created_from_catalog_version=request.created_from_catalog_version,
) )
@server.tool( @server.tool(
+11
View File
@@ -83,6 +83,17 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
assert create_workspace_schema is not None assert create_workspace_schema is not None
assert "workspace_id" in create_workspace_schema["properties"] assert "workspace_id" in create_workspace_schema["properties"]
assert "revision" in create_workspace_schema["properties"] assert "revision" in create_workspace_schema["properties"]
minimal_workspace_input = tools_by_name[
"wf.workflow.create_minimal_draft_workspace"
].inputSchema
minimal_request = minimal_workspace_input["properties"]["request"]
assert minimal_request["properties"]["workspace_id"]["pattern"]
assert "error_message_source" in minimal_request["properties"]
assert (
minimal_request["properties"]["input_schema"]["description"]
== "JSON Schema object. Keep this as ordinary JSON; "
"nested schema fields are passed through unchanged."
)
echo_result = await client.call_tool( echo_result = await client.call_tool(
"fixture.personal.echo_tool", "fixture.personal.echo_tool",