Files
lda-wf/docs/historical/superpowers/plans/2026-06-08-tool-wrapper-event-seam.md
T

17 KiB

MCP Tool Wrapper Event Seam 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: Remove direct wf_mcp event construction from wrap_discovered_tool so the wrapper can move to wf_sources_mcp in the next slice.

Architecture: Add a neutral ToolWrapperEvent DTO in wf_sources_mcp.tool_events. wf_mcp.workflow.wrappers.wrap_discovered_tool emits this neutral DTO. wf_mcp.broker.discovery.specs_from_discovered_tools adapts neutral wrapper events into broker-local McpEvent via make_event, preserving external event behavior.

Tech Stack: Python 3.14, dataclasses, wf_authoring.NodeSpec, wf_sources_mcp DTOs, pytest, ruff, basedpyright.


Hard Boundaries

  • Do not move wrap_discovered_tool in this slice.
  • Do not move specs_from_discovered_tools in this slice.
  • Do not change broker event kinds or payload shapes.
  • Do not import wf_mcp.events, wf_mcp.broker.events, or McpEvent from wf_mcp.workflow.wrappers.
  • Do not add any wf_mcp imports to src/wf_sources_mcp/tool_events.py.
  • Preserve wrap_discovered_tool(..., emit_event=...) parameter name, but change its event object to neutral ToolWrapperEvent.
  • Do not commit unless the caller explicitly asks for a commit.

File Map

  • Create src/wf_sources_mcp/tool_events.py: neutral ToolWrapperEvent, ToolWrapperEventSink, and small factories.
  • Modify src/wf_sources_mcp/__init__.py: export ToolWrapperEvent, ToolWrapperEventSink, tool_call_started_event, tool_call_completed_event.
  • Modify src/wf_mcp/workflow/wrappers.py: emit neutral events instead of broker McpEvent.
  • Modify src/wf_mcp/broker/discovery.py: adapt neutral events to broker-local McpEvent.
  • Create tests/wf_sources_mcp/test_tool_events.py: canonical neutral event tests.
  • Modify tests/wf_mcp/test_workflow_wrappers.py: add direct neutral event emission test.
  • Modify tests/wf_mcp/service/test_events.py: keep existing broker event behavior passing.
  • Modify tests/wf_sources_mcp/test_import_direction_guard.py: forbid old broker event imports inside wf_sources_mcp.
  • 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 Neutral Tool Event Tests

Files:

  • Create: tests/wf_sources_mcp/test_tool_events.py

  • Step 1: Write tests for neutral event DTO/factories

Create tests/wf_sources_mcp/test_tool_events.py:

from __future__ import annotations

from wf_sources_mcp.tool_events import (
    ToolWrapperEvent,
    tool_call_completed_event,
    tool_call_started_event,
)


def test_tool_call_started_event_shape() -> None:
    event = tool_call_started_event(
        connection_id="demo.default",
        capability_id="demo.default.echo",
        input_payload={"message": "hello"},
    )

    assert event == ToolWrapperEvent(
        kind="tool_call_started",
        connection_id="demo.default",
        capability_id="demo.default.echo",
        payload={"input": {"message": "hello"}},
    )


def test_tool_call_completed_event_shape() -> None:
    event = tool_call_completed_event(
        connection_id="demo.default",
        capability_id="demo.default.echo",
        outcome="ok",
        meta={"duration_ms": 3},
    )

    assert event.kind == "tool_call_completed"
    assert event.connection_id == "demo.default"
    assert event.capability_id == "demo.default.echo"
    assert event.payload == {"outcome": "ok", "meta": {"duration_ms": 3}}
  • Step 2: Run tests and verify they fail

Run:

uv run pytest tests/wf_sources_mcp/test_tool_events.py -q

Expected: fail with ModuleNotFoundError or import error for wf_sources_mcp.tool_events.


Task 2: Create wf_sources_mcp.tool_events

Files:

  • Create: src/wf_sources_mcp/tool_events.py

  • Step 1: Add neutral event DTO and factories

Create src/wf_sources_mcp/tool_events.py:

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True, slots=True)
class ToolWrapperEvent:
    """Neutral tool-wrapper event before broker-specific event projection."""

    kind: str
    connection_id: str
    capability_id: str
    payload: dict[str, Any] = field(default_factory=dict)


ToolWrapperEventSink = Callable[[ToolWrapperEvent], None]


def tool_call_started_event(
    *,
    connection_id: str,
    capability_id: str,
    input_payload: dict[str, Any],
) -> ToolWrapperEvent:
    return ToolWrapperEvent(
        kind="tool_call_started",
        connection_id=connection_id,
        capability_id=capability_id,
        payload={"input": input_payload},
    )


def tool_call_completed_event(
    *,
    connection_id: str,
    capability_id: str,
    outcome: str,
    meta: dict[str, Any],
) -> ToolWrapperEvent:
    return ToolWrapperEvent(
        kind="tool_call_completed",
        connection_id=connection_id,
        capability_id=capability_id,
        payload={"outcome": outcome, "meta": meta},
    )


__all__ = [
    "ToolWrapperEvent",
    "ToolWrapperEventSink",
    "tool_call_completed_event",
    "tool_call_started_event",
]
  • Step 2: Run canonical tests

Run:

uv run pytest tests/wf_sources_mcp/test_tool_events.py -q

Expected: pass.


Task 3: Export Neutral Tool Event Symbols

Files:

  • Modify: src/wf_sources_mcp/__init__.py

  • Modify: tests/wf_sources_mcp/test_tool_events.py

  • Step 1: Add package-root exports

Update src/wf_sources_mcp/__init__.py so this works:

from wf_sources_mcp import (
    ToolWrapperEvent,
    ToolWrapperEventSink,
    tool_call_completed_event,
    tool_call_started_event,
)

If the package uses lazy __getattr__, add these names to __all__ and route them to .tool_events:

    if name in {
        "ToolWrapperEvent",
        "ToolWrapperEventSink",
        "tool_call_completed_event",
        "tool_call_started_event",
    }:
        from . import tool_events

        return getattr(tool_events, name)
  • Step 2: Add package-root export test

Append to tests/wf_sources_mcp/test_tool_events.py:

def test_tool_event_symbols_export_from_package_root() -> None:
    from wf_sources_mcp import ToolWrapperEvent as RootToolWrapperEvent
    from wf_sources_mcp import tool_call_started_event as root_started
    from wf_sources_mcp.tool_events import ToolWrapperEvent, tool_call_started_event

    assert RootToolWrapperEvent is ToolWrapperEvent
    assert root_started is tool_call_started_event
  • Step 3: Run canonical tests

Run:

uv run pytest tests/wf_sources_mcp/test_tool_events.py -q

Expected: pass.


Task 4: Make wrap_discovered_tool Emit Neutral Events

Files:

  • Modify: src/wf_mcp/workflow/wrappers.py

  • Modify: tests/wf_mcp/test_workflow_wrappers.py

  • Step 1: Replace broker event imports

In src/wf_mcp/workflow/wrappers.py, remove:

from wf_mcp.broker.events import McpEvent, make_event

Add:

from wf_sources_mcp.tool_events import (
    ToolWrapperEventSink,
    tool_call_completed_event,
    tool_call_started_event,
)
  • Step 2: Change emit_event type

Change:

emit_event: Callable[[McpEvent], None] | None = None,

to:

emit_event: ToolWrapperEventSink | None = None,

If Callable is no longer used in the file, remove from collections.abc import Callable.

  • Step 3: Emit neutral started event

Replace:

emit_event(
    make_event(
        "tool_call_started",
        connection_id=connection.id,
        capability_id=f"{connection.id}.{tool.name}",
        payload={"input": payload.model_dump(exclude_unset=True)},
    )
)

with:

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),
    )
)
  • Step 4: Emit neutral completed event

Replace:

emit_event(
    make_event(
        "tool_call_completed",
        connection_id=connection.id,
        capability_id=f"{connection.id}.{tool.name}",
        payload={
            "outcome": result.outcome,
            "meta": result.meta,
        },
    )
)

with:

emit_event(
    tool_call_completed_event(
        connection_id=connection.id,
        capability_id=f"{connection.id}.{tool.name}",
        outcome=result.outcome,
        meta=result.meta,
    )
)
  • Step 5: Add direct wrapper neutral event test

Append to tests/wf_mcp/test_workflow_wrappers.py:

def test_discovered_tool_wrapper_emits_neutral_tool_events() -> None:
    events = []
    spec = wrap_discovered_tool(
        connection=McpSourceConnection(
            id="everything.default",
            provider="everything",
            account="default",
            transport=StdioSourceTransport(command="placeholder"),
        ),
        auth=None,
        executor=cast(ToolExecutor, TextContentAdapter()),
        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]

    async def run_call() -> None:
        await handler(
            {"message": "hello"},
            RuntimeContext(current_node_id="echo"),
        )

    asyncio.run(run_call())

    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 6: Run wrapper tests

Run:

uv run pytest tests/wf_mcp/test_workflow_wrappers.py -q

Expected: pass.


Task 5: Adapt Neutral Events to Broker McpEvent

Files:

  • Modify: src/wf_mcp/broker/discovery.py

  • Run: existing broker event tests

  • Step 1: Import neutral event type and broker event factory

In src/wf_mcp/broker/discovery.py, add:

from wf_sources_mcp.tool_events import ToolWrapperEvent

Change:

from .events import McpEvent

to:

from .events import McpEvent, make_event
  • Step 2: Add adapter helper

Add above specs_from_discovered_tools:

def _project_tool_wrapper_event(event: ToolWrapperEvent) -> McpEvent:
    return make_event(
        event.kind,
        connection_id=event.connection_id,
        capability_id=event.capability_id,
        payload=event.payload,
    )
  • Step 3: Wrap emit_event when calling wrap_discovered_tool

Inside specs_from_discovered_tools, before the list comprehension:

def emit_tool_event(event: ToolWrapperEvent) -> None:
    if emit_event is not None:
        emit_event(_project_tool_wrapper_event(event))

Then change the wrap_discovered_tool call argument from:

emit_event=emit_event,

to:

emit_event=emit_tool_event if emit_event is not None else None,
  • Step 4: Run broker event behavior tests

Run:

uv run pytest tests/wf_mcp/service/test_events.py::test_service_records_tool_call_events -q

Expected: pass. This confirms public broker McpEvent behavior is preserved.


Task 6: Add Import Guard for Broker Event Dependency

Files:

  • Modify: tests/wf_sources_mcp/test_import_direction_guard.py

  • Step 1: Add forbidden broker event import test

Append to tests/wf_sources_mcp/test_import_direction_guard.py:

def test_wf_sources_mcp_does_not_import_old_broker_event_modules() -> None:
    root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
    forbidden = {"wf_mcp.events", "wf_mcp.broker.events"}
    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 broker event modules:\n"
        + "\n".join(f"  {violation}" for violation in violations)
    )
  • Step 2: Run import guards

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-tool-wrapper-event-seam.md to docs/historical/superpowers/plans/2026-06-08-tool-wrapper-event-seam.md

  • Step 1: Update docs/current_roadmap.md

Under the wf_sources_mcp cleanup section, add:

    - Completed: MCP tool wrapper event emission now uses neutral
      `wf_sources_mcp.tool_events` DTOs. Broker discovery projects those events
      into `McpEvent`, preparing `wrap_discovered_tool` for a package move.
  • 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:

18. Complete: MCP tool wrapper event emission now uses neutral
    `wf_sources_mcp.tool_events` DTOs; `wf_mcp.broker.discovery` adapts them to
    broker-local `McpEvent`. `wrap_discovered_tool` remains in `wf_mcp` until
    the next move slice.

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-tool-wrapper-event-seam.md docs/historical/superpowers/plans/2026-06-08-tool-wrapper-event-seam.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_tool_events.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_workflow_wrappers.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_events.py src/wf_mcp/workflow/wrappers.py src/wf_mcp/broker/discovery.py tests/wf_sources_mcp/test_tool_events.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/tool_events.py src/wf_mcp/workflow/wrappers.py src/wf_mcp/broker/discovery.py tests/wf_sources_mcp/test_tool_events.py tests/wf_mcp/test_workflow_wrappers.py

Expected: 0 errors, 0 warnings, 0 notes

  • Step 5: Check old wrapper event imports

Run:

rg -n "wf_mcp\\.broker\\.events|wf_mcp\\.events|McpEvent|make_event|ToolWrapperEvent" src\\wf_mcp\\workflow src\\wf_sources_mcp src\\wf_mcp\\broker\\discovery.py tests

Expected:

  • src/wf_mcp/workflow/wrappers.py must not import McpEvent, make_event, wf_mcp.events, or wf_mcp.broker.events.

  • src/wf_mcp/broker/discovery.py may import McpEvent, make_event, and ToolWrapperEvent.

  • src/wf_sources_mcp may define/use ToolWrapperEvent but must not import wf_mcp.events or wf_mcp.broker.events.

  • 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 wf_mcp.workflow.wrappers no longer imports broker event modules.
  • Confirmation that broker tool-call event behavior still emits McpEvent with tool_call_started and tool_call_completed.
  • Confirmation that wrap_discovered_tool still lives in wf_mcp.
  • Any deviations from this plan.

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