Files
lda-wf/docs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-tool-wrappers.md
T

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_tools in this slice.
  • Do not change broker event kinds or payload shapes.
  • Do not change generated NodeSpec names, schemas, outcomes, or payload behavior.
  • Do not import wf_mcp from src/wf_sources_mcp/tool_wrappers.py.
  • Keep wf_mcp.workflow.wrap_discovered_tool import-compatible via a shim.
  • Keep _model_from_schema compatibility alias in wf_mcp.workflow.wrappers for now.
  • Do not commit unless the caller explicitly asks for a commit.

File Map

  • Create src/wf_sources_mcp/tool_wrappers.py: canonical wrap_discovered_tool.
  • Modify src/wf_sources_mcp/__init__.py: export wrap_discovered_tool lazily or directly.
  • Replace src/wf_mcp/workflow/wrappers.py: compatibility shim re-exporting wrap_discovered_tool and _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: import wrap_discovered_tool from wf_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 forbidding wf_mcp.workflow imports if not already sufficient.
  • Modify docs: docs/current_roadmap.md and docs/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.py only 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.md to docs/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.py may use the compatibility import.

  • tests/wf_mcp/test_compat_imports.py may assert shim identity.

  • src/wf_mcp/broker/discovery.py must import from wf_sources_mcp.tool_wrappers.

  • src/wf_sources_mcp must not import wf_mcp.workflow or wf_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_tool is canonical in wf_sources_mcp.tool_wrappers.
  • Confirmation that wf_mcp.workflow and wf_mcp.workflow.wrappers are compatibility shims.
  • Confirmation that specs_from_discovered_tools still lives in wf_mcp.broker.discovery.
  • Any deviations from this plan.

Do not claim "full suite passed" unless the full suite was actually run.