Files
lda-wf/docs/historical/superpowers/plans/2026-06-02-wfmcpservice-resource-prompt-access-extraction.md
T

22 KiB

WfMcpService Resource/Prompt Access Extraction 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: Extract the local-docs-aware resource read and prompt render logic from WfMcpService into a focused ContentAccessService while preserving existing MCP admin, CLI, and workflow behavior.

Architecture: Add ContentAccessService under wf_mcp.broker.service. It owns the "is this a local doc? if so return inline, else look up catalog entry and delegate upstream" branching that currently lives in WfMcpService.read_resource and WfMcpService.render_prompt. WfMcpService remains the compatibility coordinator; its read_resource and render_prompt become one-line delegates.

Why a new service instead of folding into UpstreamTransportService: The local-docs check depends on SourceCatalogService, not on upstream adapters. Putting it in UpstreamTransportService would add a cross-cutting catalog dependency to a transport-focused service. A thin content-access layer keeps the boundary clean.

Tech Stack: Python 3.14, dataclasses, SourceCatalogService, UpstreamTransportService, ConnectionService, pytest, ruff, basedpyright.


Scope

Move now:

  • WfMcpService.read_resource branching logic (local docs check + upstream delegation).
  • WfMcpService.render_prompt branching logic (local docs check + upstream delegation).

Keep now:

  • invoke_method. Already a clean delegate to upstream; no local-docs branching. Does not belong in this service.
  • send_notification. Same reasoning.
  • refresh_connection_catalog. Different semantics; stays on WfMcpService / UpstreamTransportService.
  • All other WfMcpService delegates and compatibility properties.
  • Event recording internals on BrokerEventRecorder.

Do not do in this slice:

  • Do not move invoke_method or send_notification into ContentAccessService.
  • Do not move refresh_connection_catalog.
  • Do not rename WfMcpService.
  • Do not change adapter implementations.
  • Do not change MCP tool schemas or CLI behavior.

Target File Structure

  • Create src/wf_mcp/broker/service/content_access.py

    • Owns ContentAccessService.
    • Depends on SourceCatalogService (for local docs lookup and catalog entry resolution), UpstreamTransportService (for upstream reads/renders), ConnectionService (for connection lookup), and BrokerEventRecorder (for local-docs event recording).
    • Contains docstrings stating this is a broker-internal service, not a protocol-neutral API.
  • Modify src/wf_mcp/broker/service/core.py

    • Add content_access: ContentAccessService = field(init=False).
    • Construct it after source_catalog, upstream, connection_service, and events in __post_init__.
    • Replace read_resource and render_prompt bodies with one-line delegates.
  • Add tests in tests/wf_mcp/service/test_content_access.py.

  • Update docs:

    • docs/superpowers/research/2026-06-02-wfmcpservice-remaining-responsibilities.md if stale.

Task 1: Add ContentAccessService Skeleton

Files:

  • Create: src/wf_mcp/broker/service/content_access.py

  • Create: tests/wf_mcp/service/test_content_access.py

  • Step 1: Write a direct local-resource test

Create tests/wf_mcp/service/test_content_access.py:

from __future__ import annotations

import asyncio

import pytest

from wf_mcp.broker import WfMcpService
from wf_mcp.broker.service.content_access import ContentAccessService
from wf_mcp.broker.service.connection_service import ConnectionService
from wf_mcp.broker.service.events import BrokerEventRecorder
from wf_mcp.broker.service.source_catalog import SourceCatalogService
from wf_mcp.broker.service.upstream_transport import UpstreamTransportService
from wf_mcp.events import EventBus
from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore
from wf_platform import (
    CapabilityBuckets,
    CapabilitySource,
    DocumentationPrompt,
    DocumentationResource,
    SourceVisibility,
)

from ..test_support import FakeAdapter, local_temp_root


def _make_content_access(
    *,
    store_root: str = "content_access_default",
) -> tuple[ContentAccessService, BrokerEventRecorder]:
    store = FileStore(local_temp_root() / store_root)
    events = BrokerEventRecorder(EventBus())
    connection_service = ConnectionService(events=events)
    upstream = UpstreamTransportService(
        store=store,
        event_sink=events.record_event,
    )
    source_catalog = SourceCatalogService(
        store=store,
        connection_lookup=connection_service.get,
        connection_list_enabled=connection_service.list_enabled,
        connection_list_all=connection_service.list_all,
        tool_executor_for=upstream.tool_executor_for,
        load_auth=upstream.load_auth,
        emit_event=events.record_event,
    )
    connection_service.bind_source_catalog(source_catalog)
    _register_local_docs(source_catalog)
    content_access = ContentAccessService(
        source_catalog=source_catalog,
        upstream=upstream,
        connection_service=connection_service,
        event_sink=events.record_event,
    )
    return content_access, events


def _register_local_docs(source_catalog: SourceCatalogService) -> None:
    """Install deterministic local docs without depending on repo Markdown files."""
    source_catalog.register_capability_source(
        CapabilitySource(
            id="test.docs",
            kind="system",
            capabilities=CapabilityBuckets(
                resources={
                    "test.docs.example": DocumentationResource(
                        name="test.docs.example",
                        uri="wf://docs/example",
                        title="Example Doc",
                        description="Test documentation resource.",
                        mime_type="text/markdown",
                        text="# Example",
                    )
                },
                prompts={
                    "test.docs.guide": DocumentationPrompt(
                        name="test.docs.guide",
                        title="Guide Prompt",
                        description="Test documentation prompt.",
                        text="Use the test docs.",
                    )
                },
            ),
            visibility=SourceVisibility(planner=True),
        )
    )


def test_content_access_reads_local_documentation_resource() -> None:
    content_access, events = _make_content_access()

    result = asyncio.run(
        content_access.read_resource("test.docs.example")
    )

    assert result["contents"][0]["uri"] == "wf://docs/example"
    assert result["contents"][0]["text"] == "# Example"
    assert "resource_read_completed" in [e.kind for e in events.list_events()]
  • Step 2: Run the test and verify it fails

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py::test_content_access_reads_local_documentation_resource -q

Expected: import failure because content_access.py does not exist.

  • Step 3: Create the skeleton service

Create src/wf_mcp/broker/service/content_access.py:

from __future__ import annotations

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

from wf_mcp.events import McpEvent, make_event

from .connection_service import ConnectionService
from .source_catalog import SourceCatalogService
from .upstream_transport import UpstreamTransportService

EventSink = Callable[[McpEvent], None]


@dataclass(slots=True)
class ContentAccessService:
    """Own local-docs-aware resource reads and prompt renders for the broker.

    This service checks SourceCatalogService for local documentation entries
    before falling back to upstream transport. It is broker-internal, not a
    protocol-neutral content API.
    """

    source_catalog: SourceCatalogService
    upstream: UpstreamTransportService
    connection_service: ConnectionService
    event_sink: EventSink

    async def read_resource(self, qualified_name: str) -> dict[str, Any]:
        local_resource = self.source_catalog.local_documentation_resource(
            qualified_name
        )
        if local_resource is not None:
            self.event_sink(
                make_event(
                    "resource_read_completed",
                    capability_id=qualified_name,
                    payload={"uri": local_resource.uri, "source": "local"},
                )
            )
            return {
                "contents": [
                    {
                        "uri": local_resource.uri,
                        "mimeType": local_resource.mime_type,
                        "text": local_resource.text,
                    }
                ]
            }

        resource = self.source_catalog.get_resource(qualified_name)
        connection = self.connection_service.get(resource.connection_id)
        return await self.upstream.read_resource(
            connection,
            qualified_name,
            resource.uri,
        )

    async def render_prompt(
        self,
        qualified_name: str,
        *,
        arguments: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        local_prompt = self.source_catalog.local_documentation_prompt(qualified_name)
        if local_prompt is not None:
            self.event_sink(
                make_event(
                    "prompt_get_completed",
                    capability_id=qualified_name,
                    payload={
                        "argument_keys": sorted((arguments or {}).keys()),
                        "source": "local",
                    },
                )
            )
            return {
                "description": local_prompt.description,
                "messages": [
                    {
                        "role": "user",
                        "content": {
                            "type": "text",
                            "text": local_prompt.text,
                        },
                    }
                ],
            }

        prompt = self.source_catalog.get_prompt(qualified_name)
        connection = self.connection_service.get(prompt.connection_id)
        return await self.upstream.render_prompt(
            connection,
            qualified_name,
            prompt.local_name,
            arguments,
        )
  • Step 4: Run the test

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py::test_content_access_reads_local_documentation_resource -q

Expected: pass.

  • Step 5: Run ruff

Run:

uv run ruff check src/wf_mcp/broker/service/content_access.py tests/wf_mcp/service/test_content_access.py

Expected: pass.


Task 2: Add Direct Upstream Resource and Prompt Tests

Files:

  • Modify: tests/wf_mcp/service/test_content_access.py

  • Step 1: Add upstream resource read test

Append to tests/wf_mcp/service/test_content_access.py:

def test_content_access_reads_upstream_resource_with_events() -> None:
    service = WfMcpService(
        store=FileStore(local_temp_root() / "content_upstream_resource")
    )
    service.register_connection(
        ConnectionConfig(id="demo.personal", server="demo", account="personal")
    )
    service.register_adapter("demo", FakeAdapter())
    asyncio.run(service.refresh_connection_catalog("demo.personal"))

    result = asyncio.run(
        service.content_access.read_resource("demo.personal.resource.welcome")
    )

    assert result["contents"][0]["text"] == "Welcome from the fake adapter resource."
    event_kinds = [e.kind for e in service.list_events()]
    assert "resource_read_started" in event_kinds
    assert "resource_read_completed" in event_kinds


def test_content_access_renders_upstream_prompt_with_events() -> None:
    service = WfMcpService(
        store=FileStore(local_temp_root() / "content_upstream_prompt")
    )
    service.register_connection(
        ConnectionConfig(id="demo.personal", server="demo", account="personal")
    )
    service.register_adapter("demo", FakeAdapter())
    asyncio.run(service.refresh_connection_catalog("demo.personal"))

    result = asyncio.run(
        service.content_access.render_prompt(
            "demo.personal.prompt.summarize",
            arguments={"text": "hello world"},
        )
    )

    assert "hello world" in result["messages"][0]["content"]["text"]
    event_kinds = [e.kind for e in service.list_events()]
    assert "prompt_get_started" in event_kinds
    assert "prompt_get_completed" in event_kinds
  • Step 2: Run the tests and verify they fail

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py::test_content_access_reads_upstream_resource_with_events tests/wf_mcp/service/test_content_access.py::test_content_access_renders_upstream_prompt_with_events -q

Expected: fail because service.content_access does not exist on WfMcpService.

  • Step 3: Wire ContentAccessService into WfMcpService

In src/wf_mcp/broker/service/core.py, import:

from .content_access import ContentAccessService

Add field:

    content_access: ContentAccessService = field(init=False)

In __post_init__, after constructing upstream, source_catalog, connection_service, and events:

        self.content_access = ContentAccessService(
            source_catalog=self.source_catalog,
            upstream=self.upstream,
            connection_service=self.connection_service,
            event_sink=self.events.record_event,
        )
  • Step 4: Run the tests

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py::test_content_access_reads_upstream_resource_with_events tests/wf_mcp/service/test_content_access.py::test_content_access_renders_upstream_prompt_with_events -q

Expected: pass.

  • Step 5: Run ruff

Run:

uv run ruff check src/wf_mcp/broker/service/core.py src/wf_mcp/broker/service/content_access.py tests/wf_mcp/service/test_content_access.py

Expected: pass.


Task 3: Delegate WfMcpService.read_resource and render_prompt

Files:

  • Modify: src/wf_mcp/broker/service/core.py

  • Test: tests/wf_mcp/service/test_events.py

  • Test: tests/wf_mcp/service/test_content_access.py

  • Step 1: Replace read_resource body with delegate

In src/wf_mcp/broker/service/core.py, replace:

    async def read_resource(self, qualified_name: str) -> dict[str, Any]:
        local_resource = self.source_catalog.local_documentation_resource(
            qualified_name
        )
        if local_resource is not None:
            self._record_event(
                make_event(
                    "resource_read_completed",
                    capability_id=qualified_name,
                    payload={"uri": local_resource.uri, "source": "local"},
                )
            )
            return {
                "contents": [
                    {
                        "uri": local_resource.uri,
                        "mimeType": local_resource.mime_type,
                        "text": local_resource.text,
                    }
                ]
            }

        resource = self.get_resource(qualified_name)
        connection = self.connections.get(resource.connection_id)
        return await self.upstream.read_resource(
            connection,
            qualified_name,
            resource.uri,
        )

with:

    async def read_resource(self, qualified_name: str) -> dict[str, Any]:
        return await self.content_access.read_resource(qualified_name)
  • Step 2: Replace render_prompt body with delegate

Replace:

    async def render_prompt(
        self,
        qualified_name: str,
        *,
        arguments: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        local_prompt = self.source_catalog.local_documentation_prompt(qualified_name)
        if local_prompt is not None:
            self._record_event(
                make_event(
                    "prompt_get_completed",
                    capability_id=qualified_name,
                    payload={
                        "argument_keys": sorted((arguments or {}).keys()),
                        "source": "local",
                    },
                )
            )
            return {
                "description": local_prompt.description,
                "messages": [
                    {
                        "role": "user",
                        "content": {
                            "type": "text",
                            "text": local_prompt.text,
                        },
                    }
                ],
            }

        prompt = self.get_prompt(qualified_name)
        connection = self.connections.get(prompt.connection_id)
        return await self.upstream.render_prompt(
            connection,
            qualified_name,
            prompt.local_name,
            arguments,
        )

with:

    async def render_prompt(
        self,
        qualified_name: str,
        *,
        arguments: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        return await self.content_access.render_prompt(
            qualified_name,
            arguments=arguments,
        )
  • Step 3: Run the existing event/proxy tests

Run:

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

Expected: pass. This proves the delegate preserves existing behavior.

  • Step 4: Run full content_access tests

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py -q

Expected: pass.

  • Step 5: Run ruff

Run:

uv run ruff check src/wf_mcp/broker/service/core.py

Expected: pass.


Task 4: Add Local Prompt Test and Edge-Case Coverage

Files:

  • Modify: tests/wf_mcp/service/test_content_access.py

  • Step 1: Add local prompt test

Append:

def test_content_access_renders_local_documentation_prompt() -> None:
    content_access, events = _make_content_access(
        store_root="content_access_local_prompt"
    )

    result = asyncio.run(
        content_access.render_prompt("test.docs.guide")
    )

    assert result["description"] == "Test documentation prompt."
    assert result["messages"][0]["role"] == "user"
    assert result["messages"][0]["content"]["text"] == "Use the test docs."
    assert "prompt_get_completed" in [e.kind for e in events.list_events()]
  • Step 2: Run the local prompt test

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py::test_content_access_renders_local_documentation_prompt -q

Expected: pass.

  • Step 3: Add missing-resource error test

Append:

def test_content_access_raises_on_unknown_resource() -> None:
    content_access, _ = _make_content_access(
        store_root="content_access_missing_resource"
    )

    with pytest.raises(KeyError):
        asyncio.run(content_access.read_resource("nonexistent.resource"))
  • Step 4: Run the error test

Run:

uv run pytest tests/wf_mcp/service/test_content_access.py::test_content_access_raises_on_unknown_resource -q

Expected: pass (KeyError from catalog lookup).

  • Step 5: Run ruff

Run:

uv run ruff check tests/wf_mcp/service/test_content_access.py

Expected: pass.


Task 5: Clean Unused Imports and Verify

Files:

  • Modify: src/wf_mcp/broker/service/core.py

  • Modify: docs/superpowers/research/2026-06-02-wfmcpservice-remaining-responsibilities.md if stale.

  • Step 1: Remove stale imports from core.py

After the delegate change, core.py may no longer use make_event directly (verify with grep). If make_event is still used by _record_catalog_change_events or other methods, keep it. Only remove imports that are truly unused after the extraction.

Run:

rg -n "make_event" src/wf_mcp/broker/service/core.py

If only the removed read_resource/render_prompt bodies used make_event, remove the import. Otherwise keep it.

  • Step 2: Update research doc if stale

If docs/superpowers/research/2026-06-02-wfmcpservice-remaining-responsibilities.md still lists read_resource and render_prompt as "Mixed real responsibility", update their classification:

| `read_resource` | Delegates to `ContentAccessService` | Compatibility facade | Keep for admin/MCP callers; new code should call `content_access`. |
| `render_prompt` | Delegates to `ContentAccessService` | Compatibility facade | Keep for admin/MCP callers; new code should call `content_access`. |
  • Step 3: Run focused verification

Run:

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

Expected: all selected tests pass.

  • Step 4: Run full verification

Run:

uv run pytest -q
uv run ruff check src/wf_mcp/broker/service tests/wf_mcp/service
uv run ruff format --check src/wf_mcp/broker/service tests/wf_mcp/service
uv run basedpyright --level error

Expected:

  • pytest passes.
  • ruff check passes.
  • ruff format check passes.
  • basedpyright reports 0 errors. If the known workspace enumeration warning causes a nonzero exit despite 0 errors, record the exact output.

Non-Goals and Follow-Up Slices

This plan intentionally leaves these for later:

  1. invoke_method/send_notification: Already clean delegates to upstream. No local-docs branching. Moving them would add coupling without reducing complexity.
  2. refresh_connection_catalog: Different semantics (catalog refresh, not content access). Stays on WfMcpService / UpstreamTransportService.
  3. Connection registry extraction: Already done in ConnectionService; do not change it in this slice.
  4. Event recorder extraction: Already done in BrokerEventRecorder; do not change it in this slice.
  5. workflow_artifact_catalog_entry: Not broker content access; handle in a separate small cleanup.

Self-Review

  • Spec coverage: The plan extracts the local-docs-aware resource/prompt branching logic while preserving current public WfMcpService methods as one-line delegates. Upstream event semantics are unchanged.
  • Placeholder scan: No placeholder implementation tasks are left. Local docs tests use deterministic test-only resource and prompt keys.
  • Type consistency: ContentAccessService depends on SourceCatalogService, UpstreamTransportService, ConnectionService, and an EventSink callback. WfMcpService.content_access is exposed as a public field so callers (admin handlers, tests) can use it directly.