22 KiB
MCP Discovery Capabilities 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 upstream capability discovery/listing helpers from wf_mcp.broker.discovery into canonical wf_sources_mcp.discovery without moving NodeSpec wrapping yet.
Architecture: wf_sources_mcp.discovery will own DiscoveredConnectionCapabilities, discover_connection_capabilities, and optional resource/prompt fallback handling. wf_mcp.broker.discovery remains as a partial compatibility module: it re-exports moved discovery symbols and keeps specs_from_discovered_tools local because that function still depends on wf_mcp.workflow.wrappers and broker event types.
Tech Stack: Python 3.14, MCP SDK McpError / METHOD_NOT_FOUND, wf_sources_mcp typed connections, pytest, ruff, basedpyright.
Hard Boundaries
- Do not move
specs_from_discovered_toolsin this slice. - Do not move
wf_mcp.workflow.wrappersin this slice. - Do not change catalog refresh behavior or event emission.
- Do not add any
wf_mcpimports tosrc/wf_sources_mcp/discovery.py. - Preserve compatibility imports from
wf_mcp.broker.discovery. - Do not commit unless the caller explicitly asks for a commit.
File Map
- Create
src/wf_sources_mcp/discovery.py: canonical discovery/listing helpers. - Modify
src/wf_sources_mcp/__init__.py: export moved discovery symbols lazily or directly, depending on circular imports. - Modify
src/wf_mcp/broker/discovery.py: keepspecs_from_discovered_tools, re-export moved discovery symbols. - Modify
src/wf_mcp/broker/service/upstream_transport.py: importdiscover_connection_capabilitiesfromwf_sources_mcp.discovery; keepspecs_from_discovered_toolsfromwf_mcp.broker.discovery. - Keep
src/wf_mcp/broker/__init__.pypublic exports working. - Create
tests/wf_sources_mcp/test_discovery.py: canonical discovery tests. - Modify
tests/wf_mcp/test_compat_imports.py: shim identity test. - Modify
tests/wf_sources_mcp/test_import_direction_guard.py: forbidwf_mcp.broker.discoveryimports insidewf_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 Discovery Tests
Files:
-
Create:
tests/wf_sources_mcp/test_discovery.py -
Step 1: Write tests for successful discovery and optional capability fallback
Create tests/wf_sources_mcp/test_discovery.py:
from __future__ import annotations
from typing import Any
import pytest
from mcp import McpError
from mcp.types import ErrorData
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.discovery import discover_connection_capabilities
from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult
from wf_sources_mcp.transports import StdioSourceTransport
def _connection() -> McpSourceConnection:
return McpSourceConnection(
id="demo.default",
provider="demo",
account="default",
transport=StdioSourceTransport(command="demo-mcp"),
)
class _Adapter:
def __init__(self) -> None:
self.seen_connections: list[McpSourceConnection] = []
async def list_tools(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredTool]:
self.seen_connections.append(connection)
return [
DiscoveredTool(
name="echo",
title="Echo",
description="Echo input",
input_schema={"type": "object"},
output_schema={"type": "object"},
)
]
async def list_resources(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
return [
DiscoveredResource(
uri="demo://docs/guide",
name="guide",
title="Guide",
description="Read me",
)
]
async def list_prompts(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]:
return [
DiscoveredPrompt(
name="summarize",
title="Summarize",
description="Summarize text",
)
]
async def get_connection_metadata(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> dict[str, Any]:
return {"server": connection.provider}
async def read_resource(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]:
raise NotImplementedError
async def get_prompt(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
raise NotImplementedError
async def invoke_method(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
raise NotImplementedError
async def send_notification(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
raise NotImplementedError
async def call_tool(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
raise NotImplementedError
class _ToolsOnlyAdapter(_Adapter):
async def list_resources(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
raise McpError(ErrorData(code=-32601, message="Method not found"))
async def list_prompts(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]:
raise ExceptionGroup(
"unhandled errors in a TaskGroup",
[McpError(ErrorData(code=-32601, message="Method not found"))],
)
class _BrokenResourceAdapter(_Adapter):
async def list_resources(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
raise RuntimeError("resource listing broke")
async def test_discover_connection_capabilities_collects_all_capability_families() -> None:
adapter = _Adapter()
connection = _connection()
capabilities = await discover_connection_capabilities(
connection=connection,
auth=None,
adapter=adapter,
)
assert capabilities.tools[0].name == "echo"
assert capabilities.resources[0].name == "guide"
assert capabilities.prompts[0].name == "summarize"
assert capabilities.metadata == {"server": "demo"}
assert adapter.seen_connections == [connection]
async def test_discover_connection_capabilities_treats_missing_optional_families_as_empty() -> None:
capabilities = await discover_connection_capabilities(
connection=_connection(),
auth=None,
adapter=_ToolsOnlyAdapter(),
)
assert [tool.name for tool in capabilities.tools] == ["echo"]
assert capabilities.resources == []
assert capabilities.prompts == []
async def test_discover_connection_capabilities_reraises_non_method_not_found_errors() -> None:
with pytest.raises(RuntimeError, match="resource listing broke"):
await discover_connection_capabilities(
connection=_connection(),
auth=None,
adapter=_BrokenResourceAdapter(),
)
def test_backend_adapter_static_shape() -> None:
adapter: BackendAdapter = _Adapter()
assert adapter is not None
- Step 2: Run the tests and verify they fail before implementation
Run:
uv run pytest tests/wf_sources_mcp/test_discovery.py -q
Expected: fail with ModuleNotFoundError / import error for wf_sources_mcp.discovery.
Task 2: Create wf_sources_mcp.discovery
Files:
-
Create:
src/wf_sources_mcp/discovery.py -
Step 1: Add canonical discovery implementation
Create src/wf_sources_mcp/discovery.py:
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, TypeVar
from mcp import McpError
from mcp.types import METHOD_NOT_FOUND
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import BackendAdapter
_CapabilityT = TypeVar("_CapabilityT")
@dataclass(slots=True)
class DiscoveredConnectionCapabilities:
tools: list[DiscoveredTool] = field(default_factory=list)
resources: list[DiscoveredResource] = field(default_factory=list)
prompts: list[DiscoveredPrompt] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
async def discover_connection_capabilities(
*,
connection: McpSourceConnection,
auth: AuthRecord | None,
adapter: BackendAdapter,
) -> DiscoveredConnectionCapabilities:
tools = await adapter.list_tools(connection, auth)
resources = await _list_optional_capabilities(
lambda: adapter.list_resources(connection, auth)
)
prompts = await _list_optional_capabilities(lambda: adapter.list_prompts(connection, auth))
metadata = await adapter.get_connection_metadata(connection, auth)
return DiscoveredConnectionCapabilities(
tools=tools,
resources=resources,
prompts=prompts,
metadata=metadata,
)
async def _list_optional_capabilities(
load: Callable[[], Awaitable[list[_CapabilityT]]],
) -> list[_CapabilityT]:
"""Treat unsupported optional MCP capability families as empty lists.
Some SDK transports raise ``METHOD_NOT_FOUND`` from inside an
``ExceptionGroup`` because the request ran through a task group. Resources
and prompts are optional families, so only that exact root error means "not
supported"; every other failure still needs to surface.
"""
try:
return await load()
except Exception as exc:
root = _root_exception(exc)
if isinstance(root, McpError) and root.error.code == METHOD_NOT_FOUND:
return []
raise
def _root_exception(exc: BaseException) -> BaseException:
"""Unwrap the first nested exception from MCP task-group ExceptionGroups."""
current: BaseException = exc
while isinstance(current, ExceptionGroup) and current.exceptions:
nested = current.exceptions[0]
if isinstance(nested, BaseException):
current = nested
continue
break
return current
__all__ = ["DiscoveredConnectionCapabilities", "discover_connection_capabilities"]
- Step 2: Run canonical tests
Run:
uv run pytest tests/wf_sources_mcp/test_discovery.py -q
Expected: all tests pass.
Task 3: Export Discovery Symbols From wf_sources_mcp
Files:
-
Modify:
src/wf_sources_mcp/__init__.py -
Step 1: Inspect existing lazy export style
Open src/wf_sources_mcp/__init__.py. If it already uses __getattr__ for circular imports, extend that style. If it is direct exports only, add direct imports.
- Step 2: Add exports without creating circular imports
The target public API must support:
from wf_sources_mcp import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
)
If direct imports are safe, add:
from .discovery import DiscoveredConnectionCapabilities, discover_connection_capabilities
and extend __all__:
"DiscoveredConnectionCapabilities",
"discover_connection_capabilities",
If direct imports cause a circular import, use the existing package-level lazy export pattern and add these names to it.
- Step 3: Add a small export assertion to canonical tests
Append to tests/wf_sources_mcp/test_discovery.py:
def test_discovery_symbols_export_from_package_root() -> None:
from wf_sources_mcp import (
DiscoveredConnectionCapabilities as RootDiscoveredConnectionCapabilities,
)
from wf_sources_mcp import (
discover_connection_capabilities as root_discover_connection_capabilities,
)
from wf_sources_mcp.discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
)
assert RootDiscoveredConnectionCapabilities is DiscoveredConnectionCapabilities
assert root_discover_connection_capabilities is discover_connection_capabilities
- Step 4: Run canonical tests
Run:
uv run pytest tests/wf_sources_mcp/test_discovery.py -q
Expected: all tests pass.
Task 4: Keep wf_mcp.broker.discovery as a Partial Shim
Files:
-
Modify:
src/wf_mcp/broker/discovery.py -
Modify:
tests/wf_mcp/test_compat_imports.py -
Step 1: Remove moved implementation from
wf_mcp.broker.discovery
Edit src/wf_mcp/broker/discovery.py:
- Remove imports that only supported moved code:
from collections.abc import Awaitable
from dataclasses import dataclass, field
from mcp import McpError
from mcp.types import METHOD_NOT_FOUND
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource
from wf_sources_mcp.sdk import BackendAdapter
from ..shared import root_exception
- Add canonical imports:
from wf_sources_mcp.discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
)
- Keep imports needed by
specs_from_discovered_tools:
from collections.abc import Callable
from typing import Any
from wf_authoring import NodeSpec
from wf_sources_mcp.catalog import DiscoveredTool
from wf_sources_mcp.connections import mcp_source_connection_from_connection_config
from wf_sources_mcp.sdk import ToolExecutor
from ..auth import AuthRecord
from ..models import ConnectionConfig
from ..workflow import wrap_discovered_tool
from .events import McpEvent
The resulting module should still define specs_from_discovered_tools exactly as before, and should export:
__all__ = [
"DiscoveredConnectionCapabilities",
"discover_connection_capabilities",
"specs_from_discovered_tools",
]
- Step 2: Add compatibility identity tests
Append to tests/wf_mcp/test_compat_imports.py:
def test_wf_mcp_broker_discovery_shim_reexports_wf_sources_mcp_discovery() -> None:
from wf_mcp.broker.discovery import (
DiscoveredConnectionCapabilities as CompatDiscoveredConnectionCapabilities,
)
from wf_mcp.broker.discovery import (
discover_connection_capabilities as compat_discover_connection_capabilities,
)
from wf_sources_mcp.discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
)
assert CompatDiscoveredConnectionCapabilities is DiscoveredConnectionCapabilities
assert compat_discover_connection_capabilities is discover_connection_capabilities
- Step 3: Run compatibility test
Run:
uv run pytest tests/wf_mcp/test_compat_imports.py::test_wf_mcp_broker_discovery_shim_reexports_wf_sources_mcp_discovery -q
Expected: pass.
Task 5: Update Broker Call Site to Convert at Boundary
Files:
-
Modify:
src/wf_mcp/broker/service/upstream_transport.py -
Step 1: Split imports
Change the discovery imports from:
from wf_mcp.broker.discovery import (
discover_connection_capabilities,
specs_from_discovered_tools,
)
to:
from wf_mcp.broker.discovery import specs_from_discovered_tools
from wf_sources_mcp.discovery import discover_connection_capabilities
The file already imports mcp_source_connection_from_connection_config; keep that import.
- Step 2: Pass
McpSourceConnectionto canonical discovery
In refresh_connection_catalog, find:
capabilities = await discover_connection_capabilities(
connection=connection,
auth=auth,
adapter=adapter,
)
Replace it with:
source_connection = mcp_source_connection_from_connection_config(connection)
capabilities = await discover_connection_capabilities(
connection=source_connection,
auth=auth,
adapter=adapter,
)
If source_connection is already created nearby for another call in the same method, reuse the existing variable instead of duplicating it.
- Step 3: Run focused upstream transport tests
Run:
uv run pytest tests/wf_mcp/service/test_upstream_transport.py tests/wf_mcp/service/test_catalog.py tests/wf_mcp/test_sdk_adapter.py -q
Expected: pass.
Task 6: Strengthen Import-Direction Guard
Files:
-
Modify:
tests/wf_sources_mcp/test_import_direction_guard.py -
Step 1: Add a forbidden old discovery import test
Append to tests/wf_sources_mcp/test_import_direction_guard.py:
def test_wf_sources_mcp_does_not_import_old_broker_discovery_module() -> None:
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
forbidden = {"wf_mcp.broker.discovery"}
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 discovery 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-discovery-capabilities.mdtodocs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-discovery-capabilities.md -
Step 1: Update
docs/current_roadmap.md
Under the MCP upstream source runtime cleanup / wf_sources_mcp section, add:
- Completed: MCP upstream capability discovery (`discover_connection_capabilities`
and `DiscoveredConnectionCapabilities`) now lives in `wf_sources_mcp.discovery`.
Tool-to-NodeSpec wrapping remains in `wf_mcp` until the event/wrapper seam
is neutralized.
- 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:
16. Complete: MCP upstream capability discovery moved to
`wf_sources_mcp.discovery`, with `wf_mcp.broker.discovery` retaining
compatibility re-exports. `specs_from_discovered_tools` remains in `wf_mcp`
until the wrapper/event seam is neutralized.
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-discovery-capabilities.md docs/historical/superpowers/plans/2026-06-08-wf-sources-mcp-discovery-capabilities.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_discovery.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_compat_imports.py tests/wf_mcp/service/test_upstream_transport.py tests/wf_mcp/service/test_catalog.py tests/wf_mcp/test_sdk_adapter.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/discovery.py src/wf_mcp/broker/discovery.py src/wf_mcp/broker/service/upstream_transport.py tests/wf_sources_mcp/test_discovery.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_compat_imports.py
Expected: All checks passed!
- Step 4: Run typecheck
Run:
uv run basedpyright --level error src/wf_sources_mcp/discovery.py src/wf_mcp/broker/discovery.py src/wf_mcp/broker/service/upstream_transport.py tests/wf_sources_mcp/test_discovery.py
Expected: 0 errors, 0 warnings, 0 notes
- Step 5: Check old import usage
Run:
rg -n "wf_mcp\.broker\.discovery|from \.discovery import|from \.\.discovery import" src tests
Expected:
-
src/wf_mcp/broker/__init__.pymay still export from.discovery. -
src/wf_mcp/broker/service/upstream_transport.pymay still importspecs_from_discovered_toolsfromwf_mcp.broker.discovery. -
tests/wf_mcp/test_compat_imports.pyand import-direction guard may reference the old path. -
src/wf_sources_mcpmust not import the old path. -
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_sources_mcp.discoveryimports nowf_mcpmodules. - 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.