19 KiB
MCP Tool Wrapper 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 wrap_discovered_tool from wf_mcp.workflow.wrappers into canonical wf_sources_mcp.tool_wrappers now that event emission is neutral.
Architecture: wf_sources_mcp.tool_wrappers will own generated MCP tool NodeSpec creation. wf_mcp.workflow.wrappers becomes a compatibility shim re-exporting the canonical function and _model_from_schema alias. Broker discovery imports the canonical wrapper directly, then projects neutral tool events to broker McpEvent as it already does.
Tech Stack: Python 3.14, wf_authoring.NodeSpec, wf_core.RuntimeContext, Pydantic v2, wf_sources_mcp typed connection/auth/sdk DTOs, pytest, ruff, basedpyright.
Hard Boundaries
- Do not move
specs_from_discovered_toolsin this slice. - Do not change broker event kinds or payload shapes.
- Do not change generated
NodeSpecnames, schemas, outcomes, or payload behavior. - Do not import
wf_mcpfromsrc/wf_sources_mcp/tool_wrappers.py. - Keep
wf_mcp.workflow.wrap_discovered_toolimport-compatible via a shim. - Keep
_model_from_schemacompatibility alias inwf_mcp.workflow.wrappersfor now. - Do not commit unless the caller explicitly asks for a commit.
File Map
- Create
src/wf_sources_mcp/tool_wrappers.py: canonicalwrap_discovered_tool. - Modify
src/wf_sources_mcp/__init__.py: exportwrap_discovered_toollazily or directly. - Replace
src/wf_mcp/workflow/wrappers.py: compatibility shim re-exportingwrap_discovered_tooland_model_from_schema. - Keep
src/wf_mcp/workflow/__init__.py: can continue importing from.wrappers, now a shim. - Modify
src/wf_mcp/broker/discovery.py: importwrap_discovered_toolfromwf_sources_mcp.tool_wrappers. - Create
tests/wf_sources_mcp/test_tool_wrappers.py: canonical wrapper behavior tests. - Modify
tests/wf_mcp/test_compat_imports.py: shim identity test. - Modify
tests/wf_mcp/test_workflow_wrappers.py: either leave as compat-path tests or reduce if duplicated; do not delete coverage in this slice unless canonical tests are equal-or-stronger. - Modify
tests/wf_sources_mcp/test_import_direction_guard.py: add guard forbiddingwf_mcp.workflowimports if not already sufficient. - 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 Tool Wrapper Tests
Files:
-
Create:
tests/wf_sources_mcp/test_tool_wrappers.py -
Step 1: Write canonical wrapper behavior tests
Create tests/wf_sources_mcp/test_tool_wrappers.py:
from __future__ import annotations
from typing import Any, cast
import pytest
from wf_authoring import build_async_registry
from wf_core import RuntimeContext
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import ToolCallResult, ToolExecutor
from wf_sources_mcp.tool_wrappers import wrap_discovered_tool
from wf_sources_mcp.transports import StdioSourceTransport
def _connection() -> McpSourceConnection:
return McpSourceConnection(
id="everything.default",
provider="everything",
account="default",
transport=StdioSourceTransport(command="placeholder"),
)
class RecordingExecutor:
def __init__(self) -> None:
self.payloads: list[dict[str, Any]] = []
async def call_tool(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
self.payloads.append(payload)
return ToolCallResult(outcome="ok", output={})
class TextContentExecutor:
async def call_tool(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
message = payload.get("message", "")
return ToolCallResult(
outcome="ok",
output={
"content": [
{
"type": "text",
"text": f"Echo: {message}",
}
],
},
)
@pytest.mark.asyncio
async def test_wrap_discovered_tool_omits_unset_optional_arguments() -> None:
executor = RecordingExecutor()
spec = wrap_discovered_tool(
connection=_connection(),
auth=None,
executor=cast(ToolExecutor, executor),
tool=DiscoveredTool(
name="browser_snapshot",
title=None,
description=None,
input_schema={
"type": "object",
"properties": {
"target": {"type": "string"},
"depth": {"type": "integer"},
},
},
output_schema={"type": "object", "properties": {}},
),
)
handler = build_async_registry(spec)[spec.name]
await handler({}, RuntimeContext(current_node_id="snapshot"))
await handler({"target": "main"}, RuntimeContext(current_node_id="snapshot"))
assert executor.payloads == [{}, {"target": "main"}]
@pytest.mark.asyncio
async def test_wrap_discovered_tool_preserves_raw_mcp_content_output() -> None:
spec = wrap_discovered_tool(
connection=_connection(),
auth=None,
executor=cast(ToolExecutor, TextContentExecutor()),
tool=DiscoveredTool(
name="echo",
title="Echo",
description=None,
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {"content": {"type": "array"}},
},
),
)
handler = build_async_registry(spec)[spec.name]
result = await handler(
{"message": "hello"},
RuntimeContext(current_node_id="echo"),
)
assert result["outcome"] == "ok"
assert "text" not in result["output"]
assert result["output"]["content"][0]["type"] == "text"
assert result["output"]["content"][0]["text"] == "Echo: hello"
@pytest.mark.asyncio
async def test_wrap_discovered_tool_emits_neutral_tool_events() -> None:
events = []
spec = wrap_discovered_tool(
connection=_connection(),
auth=None,
executor=cast(ToolExecutor, TextContentExecutor()),
tool=DiscoveredTool(
name="echo",
title="Echo",
description=None,
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
output_schema={
"type": "object",
"properties": {"content": {"type": "array"}},
},
),
emit_event=events.append,
)
handler = build_async_registry(spec)[spec.name]
await handler({"message": "hello"}, RuntimeContext(current_node_id="echo"))
assert [event.kind for event in events] == [
"tool_call_started",
"tool_call_completed",
]
assert events[0].connection_id == "everything.default"
assert events[0].capability_id == "everything.default.echo"
assert events[0].payload == {"input": {"message": "hello"}}
assert events[1].payload["outcome"] == "ok"
- Step 2: Run the new tests and verify they fail
Run:
uv run pytest tests/wf_sources_mcp/test_tool_wrappers.py -q
Expected: fail with ModuleNotFoundError or import error for wf_sources_mcp.tool_wrappers.
Task 2: Create Canonical wf_sources_mcp.tool_wrappers
Files:
-
Create:
src/wf_sources_mcp/tool_wrappers.py -
Step 1: Move wrapper implementation
Create src/wf_sources_mcp/tool_wrappers.py with the current implementation from src/wf_mcp/workflow/wrappers.py, excluding _model_from_schema compatibility alias:
from __future__ import annotations
from pydantic import BaseModel
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.schema_models import model_from_schema
from wf_sources_mcp.sdk import ToolExecutor
from wf_sources_mcp.tool_events import (
ToolWrapperEventSink,
tool_call_completed_event,
tool_call_started_event,
)
def wrap_discovered_tool(
*,
connection: McpSourceConnection,
auth: AuthRecord | None,
executor: ToolExecutor,
tool: DiscoveredTool,
emit_event: ToolWrapperEventSink | None = None,
) -> NodeSpec[BaseModel, BaseModel]:
input_model = model_from_schema(
f"{connection.id}_{tool.name}_Input",
tool.input_schema,
)
output_model = model_from_schema(
f"{connection.id}_{tool.name}_Output",
tool.output_schema,
)
async def invoke_tool(
payload: BaseModel,
ctx: RuntimeContext,
) -> NodeReturn[BaseModel]:
if emit_event is not None:
emit_event(
tool_call_started_event(
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
input_payload=payload.model_dump(exclude_unset=True),
)
)
result = await executor.call_tool(
connection=connection,
auth=auth,
tool_name=tool.name,
# Pydantic fills absent optional fields with None, but strict MCP
# servers such as Playwright distinguish omitted from explicit null.
payload=payload.model_dump(exclude_unset=True),
)
if emit_event is not None:
emit_event(
tool_call_completed_event(
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
outcome=result.outcome,
meta=result.meta,
)
)
return NodeReturn(
outcome=result.outcome,
output=output_model.model_validate(result.output),
)
return NodeSpec(
name=tool.name,
input_model=input_model,
output_model=output_model,
outcomes=tool.outcomes,
fn=invoke_tool,
description=tool.description,
is_async=True,
input_schema_contract=tool.input_schema,
output_schema_contract=tool.output_schema,
)
__all__ = ["wrap_discovered_tool"]
- Step 2: Run canonical tests
Run:
uv run pytest tests/wf_sources_mcp/test_tool_wrappers.py -q
Expected: pass.
Task 3: Export wrap_discovered_tool From wf_sources_mcp
Files:
-
Modify:
src/wf_sources_mcp/__init__.py -
Modify:
tests/wf_sources_mcp/test_tool_wrappers.py -
Step 1: Add package-root export
Update src/wf_sources_mcp/__init__.py so this works:
from wf_sources_mcp import wrap_discovered_tool
If the package uses lazy __getattr__, add "wrap_discovered_tool" to __all__ and route it to .tool_wrappers:
if name == "wrap_discovered_tool":
from . import tool_wrappers
return tool_wrappers.wrap_discovered_tool
- Step 2: Add package-root export test
Append to tests/wf_sources_mcp/test_tool_wrappers.py:
def test_wrap_discovered_tool_exports_from_package_root() -> None:
from wf_sources_mcp import wrap_discovered_tool as root_wrap_discovered_tool
from wf_sources_mcp.tool_wrappers import wrap_discovered_tool
assert root_wrap_discovered_tool is wrap_discovered_tool
- Step 3: Run canonical tests
Run:
uv run pytest tests/wf_sources_mcp/test_tool_wrappers.py -q
Expected: pass.
Task 4: Replace wf_mcp.workflow.wrappers With Compatibility Shim
Files:
-
Modify:
src/wf_mcp/workflow/wrappers.py -
Modify:
tests/wf_mcp/test_compat_imports.py -
Step 1: Replace wrapper module with shim
Replace src/wf_mcp/workflow/wrappers.py with:
"""Compatibility shim for MCP source tool wrapper generation.
Canonical implementation lives in `wf_sources_mcp.tool_wrappers`.
"""
from __future__ import annotations
from wf_sources_mcp.schema_models import model_from_schema
from wf_sources_mcp.tool_wrappers import wrap_discovered_tool
_model_from_schema = model_from_schema
__all__ = ["_model_from_schema", "wrap_discovered_tool"]
- Step 2: Add shim identity test
Append to tests/wf_mcp/test_compat_imports.py:
def test_wf_mcp_workflow_wrapper_shim_reexports_wf_sources_mcp_tool_wrapper() -> None:
from wf_mcp.workflow import wrap_discovered_tool as compat_package_wrap
from wf_mcp.workflow.wrappers import wrap_discovered_tool as compat_module_wrap
from wf_sources_mcp.tool_wrappers import wrap_discovered_tool
assert compat_package_wrap is wrap_discovered_tool
assert compat_module_wrap is wrap_discovered_tool
- Step 3: Run compatibility tests
Run:
uv run pytest tests/wf_mcp/test_compat_imports.py::test_wf_mcp_workflow_wrapper_shim_reexports_wf_sources_mcp_tool_wrapper -q
Expected: pass.
Task 5: Update Broker Discovery to Canonical Import
Files:
-
Modify:
src/wf_mcp/broker/discovery.py -
Step 1: Replace wrapper import
Change:
from ..workflow import wrap_discovered_tool
to:
from wf_sources_mcp.tool_wrappers import wrap_discovered_tool
- Step 2: Run broker discovery/event tests
Run:
uv run pytest tests/wf_mcp/service/test_events.py::test_service_records_tool_call_events tests/wf_mcp/service/test_upstream_transport.py tests/wf_mcp/service/test_catalog.py -q
Expected: pass.
Task 6: Keep Old Wrapper Tests Passing or Move Imports to Canonical
Files:
-
Modify:
tests/wf_mcp/test_workflow_wrappers.pyonly if needed. -
Step 1: Run old wrapper tests through compatibility path
Run:
uv run pytest tests/wf_mcp/test_workflow_wrappers.py -q
Expected: pass. These tests may continue importing wrap_discovered_tool from wf_mcp.workflow because that validates the shim path.
- Step 2: Only if type/lint complains, update imports
If basedpyright or ruff complains, change test imports to canonical wf_sources_mcp types where practical, but keep at least one compatibility test in tests/wf_mcp/test_compat_imports.py.
Do not delete tests/wf_mcp/test_workflow_wrappers.py in this slice.
Task 7: Strengthen Import-Direction Guard
Files:
-
Modify:
tests/wf_sources_mcp/test_import_direction_guard.py -
Step 1: Ensure old workflow wrapper import guard exists
Confirm this test exists from the previous seam:
def test_wf_sources_mcp_does_not_import_old_workflow_wrapper_module() -> None:
...
If it does not exist, add it with forbidden modules:
forbidden = {"wf_mcp.workflow", "wf_mcp.workflow.wrappers"}
- Step 2: Run import guards
Run:
uv run pytest tests/wf_sources_mcp/test_import_direction_guard.py -q
Expected: pass.
Task 8: 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-tool-wrappers.mdtodocs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-tool-wrappers.md -
Step 1: Update
docs/current_roadmap.md
Under the wf_sources_mcp cleanup section, add:
- Completed: MCP discovered-tool wrapper generation (`wrap_discovered_tool`)
now lives in `wf_sources_mcp.tool_wrappers`. `wf_mcp.workflow` remains a
compatibility shim; broker discovery imports the canonical wrapper.
- 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:
19. Complete: MCP discovered-tool wrapper generation (`wrap_discovered_tool`)
moved to `wf_sources_mcp.tool_wrappers`, with `wf_mcp.workflow` retained as
a compatibility shim. `specs_from_discovered_tools` remains in `wf_mcp`
as the broker event projection adapter.
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-tool-wrappers.md docs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-tool-wrappers.md
Expected: git status --short shows an R rename for the plan.
Task 9: 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_tool_wrappers.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_compat_imports.py tests/wf_mcp/test_workflow_wrappers.py tests/wf_mcp/test_stateful_runtime.py tests/wf_mcp/service/test_events.py::test_service_records_tool_call_events tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_upstream_transport.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/tool_wrappers.py src/wf_mcp/workflow/wrappers.py src/wf_mcp/broker/discovery.py tests/wf_sources_mcp/test_tool_wrappers.py tests/wf_mcp/test_compat_imports.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/tool_wrappers.py src/wf_mcp/workflow/wrappers.py src/wf_mcp/broker/discovery.py tests/wf_sources_mcp/test_tool_wrappers.py tests/wf_mcp/test_workflow_wrappers.py
Expected: 0 errors, 0 warnings, 0 notes
- Step 5: Check old wrapper import usage
Run:
rg -n "wf_mcp\\.workflow|from \\.workflow import|from \\.wrappers import|wrap_discovered_tool" src tests
Expected:
-
src/wf_mcp/workflow/*may contain shim exports. -
tests/wf_mcp/test_workflow_wrappers.pymay use the compatibility import. -
tests/wf_mcp/test_compat_imports.pymay assert shim identity. -
src/wf_mcp/broker/discovery.pymust import fromwf_sources_mcp.tool_wrappers. -
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
wrap_discovered_toolis canonical inwf_sources_mcp.tool_wrappers. - Confirmation that
wf_mcp.workflowandwf_mcp.workflow.wrappersare compatibility shims. - Confirmation that
specs_from_discovered_toolsstill lives inwf_mcp.broker.discovery. - Any deviations from this plan.
Do not claim "full suite passed" unless the full suite was actually run.