19 KiB
MCP Schema Model Helpers Move 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: Move MCP JSON-schema-to-Pydantic model helper logic from wf_mcp.workflow.wrappers into wf_sources_mcp.schema_models so broker catalog hydration no longer imports private wrapper helpers.
Architecture: wf_sources_mcp.schema_models owns the tolerant JSON Schema subset compiler used for generated MCP tool NodeSpecs. wf_mcp.workflow.wrappers keeps wrap_discovered_tool and event emission for now, but imports the canonical model helper. SourceCatalogService also imports the canonical helper directly.
Tech Stack: Python 3.14, Pydantic v2 create_model, wf_authoring.NodeSpec, pytest, ruff, basedpyright.
Hard Boundaries
- Do not move
wrap_discovered_toolin this slice. - Do not move
specs_from_discovered_toolsin this slice. - Do not change event emission or
McpEventusage. - Do not change generated payload behavior: unset optional fields must still be omitted via
model_dump(exclude_unset=True)at call sites. - Do not add any
wf_mcpimports tosrc/wf_sources_mcp/schema_models.py. - Preserve compatibility for tests/imports that still reach private wrapper helpers where practical.
- Do not commit unless the caller explicitly asks for a commit.
File Map
- Create
src/wf_sources_mcp/schema_models.py: canonical_python_type_from_schema,_optional_type,_field_default, and publicmodel_from_schema. - Modify
src/wf_sources_mcp/__init__.py: exportmodel_from_schemalazily or directly. - Modify
src/wf_mcp/workflow/wrappers.py: importmodel_from_schema; keep_model_from_schemaas a compatibility alias if tests or code still use it. - Modify
src/wf_mcp/broker/service/source_catalog.py: importmodel_from_schemafromwf_sources_mcp.schema_models. - Create
tests/wf_sources_mcp/test_schema_models.py: canonical helper tests. - Modify
tests/wf_mcp/test_workflow_wrappers.py: only if needed; existing wrapper behavior tests should keep passing. - Modify
tests/wf_sources_mcp/test_import_direction_guard.py: forbid importingwf_mcp.workflow.wrappersinsidewf_sources_mcp. - Modify docs:
docs/current_roadmap.mdanddocs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md. - Move this plan to
docs/historical/superpowers/plans/after implementation is verified.
Task 1: Add Canonical Schema Model Tests
Files:
-
Create:
tests/wf_sources_mcp/test_schema_models.py -
Step 1: Write canonical behavior tests
Create tests/wf_sources_mcp/test_schema_models.py:
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
from wf_sources_mcp.schema_models import model_from_schema
def test_model_from_schema_maps_basic_json_schema_types() -> None:
model = model_from_schema(
"ToolInput",
{
"type": "object",
"properties": {
"name": {"type": "string", "description": "Display name"},
"count": {"type": "integer"},
"ratio": {"type": "number"},
"enabled": {"type": "boolean"},
"metadata": {"type": "object"},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "count"],
},
)
payload = model.model_validate(
{
"name": "demo",
"count": 2,
"ratio": 1.5,
"enabled": True,
"metadata": {"a": "b"},
"tags": ["one"],
}
)
assert isinstance(payload, BaseModel)
assert payload.model_dump(exclude_unset=True) == {
"name": "demo",
"count": 2,
"ratio": 1.5,
"enabled": True,
"metadata": {"a": "b"},
"tags": ["one"],
}
def test_model_from_schema_omits_unset_optional_fields() -> None:
model = model_from_schema(
"OptionalInput",
{
"type": "object",
"properties": {
"required_name": {"type": "string"},
"optional_depth": {"type": "integer"},
},
"required": ["required_name"],
},
)
payload = model.model_validate({"required_name": "root"})
assert payload.model_dump(exclude_unset=True) == {"required_name": "root"}
def test_model_from_schema_preserves_explicit_defaults() -> None:
model = model_from_schema(
"DefaultInput",
{
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 10},
"query": {"type": "string"},
},
},
)
payload = model.model_validate({})
assert payload.model_dump() == {"limit": 10, "query": None}
assert payload.model_dump(exclude_unset=True) == {}
def test_model_from_schema_allows_extra_fields_and_tolerates_unknown_shapes() -> None:
model = model_from_schema(
"LooseInput",
{
"type": "object",
"properties": {
"enum_value": {"enum": ["a", "b"]},
"union_value": {"type": ["string", "integer"]},
"unknown_value": {"x-custom": True},
},
},
)
payload = model.model_validate(
{
"enum_value": "a",
"union_value": 123,
"unknown_value": {"nested": True},
"extra": "kept",
}
)
dumped: dict[str, Any] = payload.model_dump(exclude_unset=True)
assert dumped["enum_value"] == "a"
assert dumped["union_value"] == 123
assert dumped["unknown_value"] == {"nested": True}
assert dumped["extra"] == "kept"
- Step 2: Run the new tests and verify they fail
Run:
uv run pytest tests/wf_sources_mcp/test_schema_models.py -q
Expected: fail with ModuleNotFoundError or import error for wf_sources_mcp.schema_models.
Task 2: Create Canonical wf_sources_mcp.schema_models
Files:
-
Create:
src/wf_sources_mcp/schema_models.py -
Step 1: Add canonical helper implementation
Create src/wf_sources_mcp/schema_models.py by moving the helper logic from src/wf_mcp/workflow/wrappers.py:
from __future__ import annotations
from types import NoneType, UnionType
from typing import Any, Union, cast, get_args, get_origin
from pydantic import BaseModel, ConfigDict, Field, create_model
_JSON_TYPE_MAP: dict[str, object] = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"object": dict[str, Any],
}
def _python_type_from_schema(schema: object) -> object:
"""Map the supported MCP JSON Schema subset into a Pydantic annotation.
This is intentionally not a full JSON Schema compiler. Unsupported shapes
become `Any` so discovery remains tolerant while the original schema
contract stays available on generated NodeSpecs.
"""
if not isinstance(schema, dict):
return Any
if "enum" in schema:
return Any
schema_type = schema.get("type")
if isinstance(schema_type, list):
non_null_types = [item for item in schema_type if item != "null"]
if len(non_null_types) == 1:
return _optional_type(
_python_type_from_schema({**schema, "type": non_null_types[0]})
)
return Any
if schema_type == "array":
item_type = _python_type_from_schema(schema.get("items", {}))
return list[item_type] if isinstance(item_type, type) else list[Any]
if not isinstance(schema_type, str):
return Any
return _JSON_TYPE_MAP.get(schema_type, Any)
def _optional_type(annotation: object) -> object:
"""Return an optional version of a supported runtime annotation."""
if annotation is Any:
return Any
origin = get_origin(annotation)
if origin in {Union, UnionType} and NoneType in get_args(annotation):
return annotation
return annotation | None if isinstance(annotation, type) else Any
def _field_default(
field_name: str,
property_schema: object,
required: set[str],
) -> object:
"""Return the Pydantic default for one MCP tool input property."""
if isinstance(property_schema, dict) and "default" in property_schema:
return property_schema["default"]
return ... if field_name in required else None
def model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
"""Create a loose Pydantic adapter model for an MCP JSON Schema object.
Input should be object-like JSON Schema with `properties` and optional
`required`. The returned model is the Python-call boundary for generated
NodeSpecs; the original JSON Schema remains the public contract.
"""
properties = cast(dict[str, Any], schema.get("properties", {}))
required = set(cast(list[str], schema.get("required", [])))
field_defs: dict[str, tuple[object, object]] = {}
for field_name, property_schema in properties.items():
annotation = _python_type_from_schema(property_schema)
default = _field_default(field_name, property_schema, required)
description = (
property_schema.get("description")
if isinstance(property_schema, dict)
else None
)
field_defs[field_name] = (
annotation,
Field(default=default, description=description),
)
raw_field_defs = cast(dict[str, Any], field_defs)
model = create_model(
name,
__config__=ConfigDict(extra="allow"),
**raw_field_defs,
)
return cast(type[BaseModel], model)
__all__ = ["model_from_schema"]
- Step 2: Run canonical tests
Run:
uv run pytest tests/wf_sources_mcp/test_schema_models.py -q
Expected: all tests pass.
Task 3: Export model_from_schema From wf_sources_mcp
Files:
-
Modify:
src/wf_sources_mcp/__init__.py -
Step 1: Add package-root export
Update src/wf_sources_mcp/__init__.py so this works:
from wf_sources_mcp import model_from_schema
If the package uses lazy __getattr__, add "model_from_schema" to __all__ and route it to .schema_models:
if name == "model_from_schema":
from . import schema_models
return schema_models.model_from_schema
If direct imports are safe, use:
from .schema_models import model_from_schema
- Step 2: Add root export test
Append to tests/wf_sources_mcp/test_schema_models.py:
def test_model_from_schema_exports_from_package_root() -> None:
from wf_sources_mcp import model_from_schema as root_model_from_schema
from wf_sources_mcp.schema_models import model_from_schema
assert root_model_from_schema is model_from_schema
- Step 3: Run canonical tests
Run:
uv run pytest tests/wf_sources_mcp/test_schema_models.py -q
Expected: all tests pass.
Task 4: Update wf_mcp.workflow.wrappers to Use Canonical Helper
Files:
-
Modify:
src/wf_mcp/workflow/wrappers.py -
Step 1: Replace local helper implementation
In src/wf_mcp/workflow/wrappers.py:
- Remove imports used only by helper internals:
from types import NoneType, UnionType
from typing import Union, cast, get_args, get_origin
from pydantic import ConfigDict, Field, create_model
- Keep imports needed by
wrap_discovered_tool:
from collections.abc import Callable
from typing import Any
from pydantic import BaseModel
- Add:
from wf_sources_mcp.schema_models import model_from_schema
-
Delete
_JSON_TYPE_MAP,_python_type_from_schema,_optional_type,_field_default, and the old_model_from_schemaimplementation. -
Add a compatibility alias after imports:
_model_from_schema = model_from_schema
Keep this alias because some old tests/imports may still reference wf_mcp.workflow.wrappers._model_from_schema.
- Step 2: Update wrapper calls
Change:
input_model = _model_from_schema(
to:
input_model = model_from_schema(
Change:
output_model = _model_from_schema(
to:
output_model = model_from_schema(
- Step 3: Run wrapper tests
Run:
uv run pytest tests/wf_mcp/test_workflow_wrappers.py tests/wf_mcp/test_stateful_runtime.py -q
Expected: pass.
Task 5: Update Source Catalog Hydration Import
Files:
-
Modify:
src/wf_mcp/broker/service/source_catalog.py -
Step 1: Replace private wrapper helper import
Change:
from ...workflow.wrappers import _model_from_schema
to:
from wf_sources_mcp.schema_models import model_from_schema
- Step 2: Update hydration calls
Change:
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
to:
input_model = model_from_schema(f"{model_prefix}_Input", entry.input_schema)
Change:
output_model = _model_from_schema(f"{model_prefix}_Output", output_schema)
to:
output_model = model_from_schema(f"{model_prefix}_Output", output_schema)
- Step 3: Run source catalog tests
Run:
uv run pytest tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_adapters.py -q
Expected: pass.
Task 6: Add Import Guard for Old Wrapper Helper Dependency
Files:
-
Modify:
tests/wf_sources_mcp/test_import_direction_guard.py -
Step 1: Add forbidden old wrapper import test
Append to tests/wf_sources_mcp/test_import_direction_guard.py:
def test_wf_sources_mcp_does_not_import_old_workflow_wrapper_module() -> None:
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
forbidden = {"wf_mcp.workflow", "wf_mcp.workflow.wrappers"}
violations: list[str] = []
for py_file in sorted(root.rglob("*.py")):
rel = py_file.relative_to(root.parent)
module = str(rel.with_suffix("")).replace("/", ".").replace("\\", ".")
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module in forbidden:
violations.append(f"{module}:{node.lineno}: from {node.module} import ...")
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name in forbidden:
violations.append(f"{module}:{node.lineno}: import {alias.name}")
assert violations == [], (
"wf_sources_mcp still imports old wf_mcp workflow wrapper module:\n"
+ "\n".join(f" {violation}" for violation in violations)
)
- Step 2: Run import guard
Run:
uv run pytest tests/wf_sources_mcp/test_import_direction_guard.py -q
Expected: pass.
Task 7: Update Docs and Archive Plan
Files:
-
Modify:
docs/current_roadmap.md -
Modify:
docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md -
Move:
docs/superpowers/plans/2026-06-08-wf-sources-mcp-schema-models.mdtodocs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-schema-models.md -
Step 1: Update
docs/current_roadmap.md
Under the wf_sources_mcp cleanup section, add:
- Completed: MCP JSON-schema-to-Pydantic model helper now lives in
`wf_sources_mcp.schema_models`. `wf_mcp.workflow.wrappers` still owns
tool wrapper event emission, but no longer owns the shared schema compiler.
- Step 2: Update
docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md
Add a completed numbered item before the pending upstream transport/discovery/session services item:
17. Complete: MCP JSON-schema-to-Pydantic model helper moved to
`wf_sources_mcp.schema_models`. This removes the broker catalog hydration
dependency on private `wf_mcp.workflow.wrappers` helpers; eventful tool
wrapping remains in `wf_mcp` for the next seam.
If numbering differs because new items landed meanwhile, keep the completed item before the broad pending item and renumber.
- Step 3: Archive the plan
Run:
git mv docs/superpowers/plans/2026-06-08-wf-sources-mcp-schema-models.md docs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-schema-models.md
Expected: git status --short shows an R rename for the plan.
Task 8: Final Verification
Files:
-
No code edits unless verification finds a real issue.
-
Step 1: Run focused tests
Run:
uv run pytest tests/wf_sources_mcp/test_schema_models.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_workflow_wrappers.py tests/wf_mcp/test_stateful_runtime.py tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_adapters.py -q
Expected: all selected tests pass.
- Step 2: Run source-provider tests
Run:
uv run pytest tests/wf_sources_mcp -q
Expected: all wf_sources_mcp tests pass.
- Step 3: Run lint
Run:
uv run ruff check src/wf_sources_mcp/schema_models.py src/wf_mcp/workflow/wrappers.py src/wf_mcp/broker/service/source_catalog.py tests/wf_sources_mcp/test_schema_models.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_workflow_wrappers.py
Expected: All checks passed!
- Step 4: Run typecheck
Run:
uv run basedpyright --level error src/wf_sources_mcp/schema_models.py src/wf_mcp/workflow/wrappers.py src/wf_mcp/broker/service/source_catalog.py tests/wf_sources_mcp/test_schema_models.py
Expected: 0 errors, 0 warnings, 0 notes
- Step 5: Check old private helper usage
Run:
rg -n "_model_from_schema|_python_type_from_schema|_field_default|_optional_type|wf_mcp\.workflow\.wrappers" src tests
Expected:
-
src/wf_mcp/workflow/wrappers.pymay contain_model_from_schema = model_from_schemacompatibility alias. -
testsmay importwrap_discovered_toolfromwf_mcp.workflow. -
src/wf_mcp/broker/service/source_catalog.pymust not import_model_from_schema. -
src/wf_sources_mcpmust not importwf_mcp.workfloworwf_mcp.workflow.wrappers. -
Step 6: Check whitespace
Run:
git diff --check
Expected: no whitespace errors. CRLF warnings on Windows are acceptable.
Expected Final Report
The implementer should report:
- Files created, modified, and moved.
- Exact verification commands and pass/fail output.
- Confirmation that
source_catalog.pyno longer imports_model_from_schemafromwf_mcp.workflow.wrappers. - Confirmation that
wrap_discovered_toolstill lives inwf_mcp. - Any deviations from this plan.
Do not claim "full suite passed" unless the full suite was actually run.