refactor: move mcp catalog aggregation to wf_sources_mcp
This commit is contained in:
@@ -260,6 +260,9 @@ implementation state.
|
|||||||
resources, and prompts, and can route session-scoped `list_resources` and
|
resources, and prompts, and can route session-scoped `list_resources` and
|
||||||
`list_prompts` through the owner task. Catalog refresh still uses one-shot
|
`list_prompts` through the owner task. Catalog refresh still uses one-shot
|
||||||
adapter policy.
|
adapter policy.
|
||||||
|
- Completed: MCP source catalog aggregation helpers (`CombinedCatalog` and
|
||||||
|
`snapshot_from_specs`) now live in `wf_sources_mcp.catalog`; the old
|
||||||
|
`wf_mcp.broker.catalog` path is a compatibility shim.
|
||||||
- Auth/source secrets boundary: keep registry desired state separate from
|
- Auth/source secrets boundary: keep registry desired state separate from
|
||||||
upstream credentials, and surface missing auth as validation diagnostics.
|
upstream credentials, and surface missing auth as validation diagnostics.
|
||||||
The contract is now specified in
|
The contract is now specified in
|
||||||
|
|||||||
@@ -0,0 +1,701 @@
|
|||||||
|
# MCP Catalog Aggregation 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 source catalog aggregation helpers from `wf_mcp.broker.catalog` into canonical `wf_sources_mcp.catalog` while keeping `wf_mcp` compatibility imports working.
|
||||||
|
|
||||||
|
**Architecture:** `wf_sources_mcp.catalog.aggregate` will own `CombinedCatalog` and `snapshot_from_specs`. `wf_mcp.broker.catalog` becomes a thin re-export shim. Production import sites should prefer the canonical path, but `wf_mcp.broker.__init__` can keep re-exporting for compatibility.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14, dataclasses, `wf_authoring.NodeCatalog`, `wf_sources_mcp.catalog` DTOs, pytest, ruff, basedpyright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard Boundaries
|
||||||
|
|
||||||
|
- Do not move `SourceCatalogService`, `UpstreamTransportService`, `discover_connection_capabilities`, or `wrap_discovered_tool` in this slice.
|
||||||
|
- Do not introduce any `wf_mcp` import inside `src/wf_sources_mcp/catalog/aggregate.py`.
|
||||||
|
- Keep `wf_mcp.broker.catalog.CombinedCatalog` and `wf_mcp.broker.catalog.snapshot_from_specs` import-compatible via a shim.
|
||||||
|
- Do not change payload shape from `CombinedCatalog.as_payload()`.
|
||||||
|
- Do not change catalog refresh/discovery policy; this is only a code ownership move.
|
||||||
|
- Do not commit unless the caller explicitly asks for a commit.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create `src/wf_sources_mcp/catalog/aggregate.py`: canonical `snapshot_from_specs`, `CombinedCatalog`, and private `_qualify_local_name`.
|
||||||
|
- Modify `src/wf_sources_mcp/catalog/__init__.py`: export `CombinedCatalog` and `snapshot_from_specs`.
|
||||||
|
- Replace `src/wf_mcp/broker/catalog.py`: compatibility shim re-exporting from `wf_sources_mcp.catalog`.
|
||||||
|
- Modify `src/wf_mcp/broker/service/source_catalog.py`: import canonical `CombinedCatalog` and `snapshot_from_specs`.
|
||||||
|
- Modify `src/wf_mcp/broker/service/upstream_transport.py`: import canonical `snapshot_from_specs`.
|
||||||
|
- Keep or adjust `src/wf_mcp/broker/__init__.py`: re-export from the shim or canonical path, as long as public imports remain stable.
|
||||||
|
- Create `tests/wf_sources_mcp/test_catalog_aggregate.py`: canonical behavior tests.
|
||||||
|
- Modify `tests/wf_sources_mcp/test_import_direction_guard.py`: forbid `wf_mcp.broker.catalog` imports inside `wf_sources_mcp`.
|
||||||
|
- Modify `tests/wf_mcp/test_compat_imports.py`: add shim identity tests.
|
||||||
|
- 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 Catalog Aggregation Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/wf_sources_mcp/test_catalog_aggregate.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write canonical tests before implementation**
|
||||||
|
|
||||||
|
Create `tests/wf_sources_mcp/test_catalog_aggregate.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from wf_sources_mcp.catalog import (
|
||||||
|
CombinedCatalog,
|
||||||
|
DiscoveredPrompt,
|
||||||
|
DiscoveredResource,
|
||||||
|
snapshot_from_specs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EchoInput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _echo(payload: EchoInput) -> EchoOutput:
|
||||||
|
return EchoOutput(text=payload.message)
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_spec() -> NodeSpec[EchoInput, EchoOutput]:
|
||||||
|
return NodeSpec(
|
||||||
|
name="echo",
|
||||||
|
input_model=EchoInput,
|
||||||
|
output_model=EchoOutput,
|
||||||
|
outcomes=("ok",),
|
||||||
|
fn=_echo,
|
||||||
|
description="Echo message",
|
||||||
|
is_async=True,
|
||||||
|
input_schema_contract={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"message": {"type": "string"}},
|
||||||
|
"required": ["message"],
|
||||||
|
},
|
||||||
|
output_schema_contract={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_from_specs_qualifies_nodes_resources_and_prompts() -> None:
|
||||||
|
snapshot = snapshot_from_specs(
|
||||||
|
"demo.default",
|
||||||
|
specs={"echo": _echo_spec()},
|
||||||
|
tool_display_names={"echo": "Echo Tool"},
|
||||||
|
resources=[
|
||||||
|
DiscoveredResource(
|
||||||
|
uri="demo://docs/guide",
|
||||||
|
name="guide",
|
||||||
|
title="Guide",
|
||||||
|
description="Read me",
|
||||||
|
mime_type="text/markdown",
|
||||||
|
metadata={"kind": "doc"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
prompts=[
|
||||||
|
DiscoveredPrompt(
|
||||||
|
name="summarize",
|
||||||
|
title="Summarize",
|
||||||
|
description="Summarize text",
|
||||||
|
arguments=[{"name": "topic"}],
|
||||||
|
metadata={"kind": "prompt"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
metadata={"source": "test"},
|
||||||
|
fetched_at_epoch_ms=123,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.connection_id == "demo.default"
|
||||||
|
assert snapshot.nodes[0].qualified_name == "demo.default.echo"
|
||||||
|
assert snapshot.nodes[0].local_name == "echo"
|
||||||
|
assert snapshot.nodes[0].title == "Echo Tool"
|
||||||
|
assert snapshot.resources[0].qualified_name == "demo.default.guide"
|
||||||
|
assert snapshot.resources[0].uri == "demo://docs/guide"
|
||||||
|
assert snapshot.prompts[0].qualified_name == "demo.default.summarize"
|
||||||
|
assert snapshot.prompts[0].arguments == [{"name": "topic"}]
|
||||||
|
assert snapshot.metadata == {"source": "test"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_from_specs_preserves_already_qualified_node_name() -> None:
|
||||||
|
spec = _echo_spec().model_copy(update={"name": "demo.default.echo"})
|
||||||
|
|
||||||
|
snapshot = snapshot_from_specs(
|
||||||
|
"demo.default",
|
||||||
|
specs={"demo.default.echo": spec},
|
||||||
|
fetched_at_epoch_ms=123,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.nodes[0].qualified_name == "demo.default.echo"
|
||||||
|
assert snapshot.nodes[0].local_name == "echo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_catalog_sorts_entries_and_serializes_payload() -> None:
|
||||||
|
first = snapshot_from_specs(
|
||||||
|
"zeta.default",
|
||||||
|
specs={"echo": _echo_spec()},
|
||||||
|
resources=[DiscoveredResource(uri="zeta://guide", name="guide")],
|
||||||
|
prompts=[DiscoveredPrompt(name="prompt")],
|
||||||
|
metadata={"order": "second"},
|
||||||
|
fetched_at_epoch_ms=2,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
second = snapshot_from_specs(
|
||||||
|
"alpha.default",
|
||||||
|
specs={"echo": _echo_spec()},
|
||||||
|
resources=[DiscoveredResource(uri="alpha://guide", name="guide")],
|
||||||
|
prompts=[DiscoveredPrompt(name="prompt")],
|
||||||
|
metadata={"order": "first"},
|
||||||
|
fetched_at_epoch_ms=1,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = CombinedCatalog(
|
||||||
|
snapshots={
|
||||||
|
first.connection_id: first,
|
||||||
|
second.connection_id: second,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = catalog.as_payload()
|
||||||
|
|
||||||
|
assert [entry.qualified_name for entry in catalog.entries()] == [
|
||||||
|
"alpha.default.echo",
|
||||||
|
"zeta.default.echo",
|
||||||
|
]
|
||||||
|
assert catalog.find_resource("alpha.default.guide") is not None
|
||||||
|
assert catalog.find_prompt("zeta.default.prompt") is not None
|
||||||
|
assert [node["qualified_name"] for node in payload["nodes"]] == [
|
||||||
|
"alpha.default.echo",
|
||||||
|
"zeta.default.echo",
|
||||||
|
]
|
||||||
|
assert [item["connection_id"] for item in payload["connections"]] == [
|
||||||
|
"alpha.default",
|
||||||
|
"zeta.default",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new tests and verify they fail**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_sources_mcp/test_catalog_aggregate.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail with `ImportError` because `CombinedCatalog` and `snapshot_from_specs` are not exported from `wf_sources_mcp.catalog` yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create Canonical `wf_sources_mcp.catalog.aggregate`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/wf_sources_mcp/catalog/aggregate.py`
|
||||||
|
- Modify: `src/wf_sources_mcp/catalog/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the canonical implementation**
|
||||||
|
|
||||||
|
Create `src/wf_sources_mcp/catalog/aggregate.py` with the current implementation from `src/wf_mcp/broker/catalog.py`, but replace `wf_mcp.connections.qualify_node_name` with a local helper:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wf_authoring import NodeCatalog, NodeSpec
|
||||||
|
|
||||||
|
from wf_sources_mcp.ids import parse_connection_id
|
||||||
|
from wf_sources_mcp.sdk.converters import workflow_output_schema_from_mcp_tool_schema
|
||||||
|
|
||||||
|
from .entries import (
|
||||||
|
CatalogNodeEntry,
|
||||||
|
CatalogPromptEntry,
|
||||||
|
CatalogResourceEntry,
|
||||||
|
DiscoveredPrompt,
|
||||||
|
DiscoveredResource,
|
||||||
|
)
|
||||||
|
from .models import CatalogSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _qualify_local_name(connection_id: str, local_name: str) -> str:
|
||||||
|
"""Qualify a source-local catalog name without depending on wf_mcp shims."""
|
||||||
|
parse_connection_id(connection_id)
|
||||||
|
if not local_name:
|
||||||
|
raise ValueError("local catalog name must not be empty")
|
||||||
|
return f"{connection_id}.{local_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_from_specs(
|
||||||
|
connection_id: str,
|
||||||
|
*,
|
||||||
|
specs: dict[str, NodeSpec[Any, Any]],
|
||||||
|
tool_display_names: dict[str, str | None] | None = None,
|
||||||
|
resources: list[DiscoveredResource] | None = None,
|
||||||
|
prompts: list[DiscoveredPrompt] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
fetched_at_epoch_ms: int,
|
||||||
|
max_age_seconds: int,
|
||||||
|
) -> CatalogSnapshot:
|
||||||
|
catalog = NodeCatalog.from_specs(*specs.values())
|
||||||
|
nodes = [
|
||||||
|
CatalogNodeEntry(
|
||||||
|
qualified_name=entry.name
|
||||||
|
if entry.name.startswith(f"{connection_id}.")
|
||||||
|
else _qualify_local_name(connection_id, entry.name),
|
||||||
|
connection_id=connection_id,
|
||||||
|
local_name=entry.name.removeprefix(f"{connection_id}."),
|
||||||
|
title=(tool_display_names or {}).get(
|
||||||
|
entry.name.removeprefix(f"{connection_id}."),
|
||||||
|
entry.display_name,
|
||||||
|
),
|
||||||
|
description=entry.description,
|
||||||
|
outcomes=entry.outcomes,
|
||||||
|
input_schema=entry.input_schema,
|
||||||
|
output_schema=workflow_output_schema_from_mcp_tool_schema(
|
||||||
|
entry.output_schema
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for entry in catalog.entries()
|
||||||
|
]
|
||||||
|
resource_entries = [
|
||||||
|
CatalogResourceEntry(
|
||||||
|
qualified_name=_qualify_local_name(connection_id, resource.name),
|
||||||
|
connection_id=connection_id,
|
||||||
|
local_name=resource.name,
|
||||||
|
title=resource.title,
|
||||||
|
uri=resource.uri,
|
||||||
|
description=resource.description,
|
||||||
|
mime_type=resource.mime_type,
|
||||||
|
metadata=resource.metadata,
|
||||||
|
)
|
||||||
|
for resource in resources or []
|
||||||
|
]
|
||||||
|
prompt_entries = [
|
||||||
|
CatalogPromptEntry(
|
||||||
|
qualified_name=_qualify_local_name(connection_id, prompt.name),
|
||||||
|
connection_id=connection_id,
|
||||||
|
local_name=prompt.name,
|
||||||
|
title=prompt.title,
|
||||||
|
description=prompt.description,
|
||||||
|
arguments=prompt.arguments,
|
||||||
|
metadata=prompt.metadata,
|
||||||
|
)
|
||||||
|
for prompt in prompts or []
|
||||||
|
]
|
||||||
|
return CatalogSnapshot(
|
||||||
|
connection_id=connection_id,
|
||||||
|
fetched_at_epoch_ms=fetched_at_epoch_ms,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
nodes=nodes,
|
||||||
|
resources=resource_entries,
|
||||||
|
prompts=prompt_entries,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CombinedCatalog:
|
||||||
|
snapshots: dict[str, CatalogSnapshot] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def entries(self) -> list[CatalogNodeEntry]:
|
||||||
|
result: list[CatalogNodeEntry] = []
|
||||||
|
for snapshot in self.snapshots.values():
|
||||||
|
result.extend(snapshot.nodes)
|
||||||
|
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||||
|
|
||||||
|
def resource_entries(self) -> list[CatalogResourceEntry]:
|
||||||
|
result: list[CatalogResourceEntry] = []
|
||||||
|
for snapshot in self.snapshots.values():
|
||||||
|
result.extend(snapshot.resources)
|
||||||
|
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||||
|
|
||||||
|
def prompt_entries(self) -> list[CatalogPromptEntry]:
|
||||||
|
result: list[CatalogPromptEntry] = []
|
||||||
|
for snapshot in self.snapshots.values():
|
||||||
|
result.extend(snapshot.prompts)
|
||||||
|
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||||
|
|
||||||
|
def find_resource(self, qualified_name: str) -> CatalogResourceEntry | None:
|
||||||
|
for entry in self.resource_entries():
|
||||||
|
if entry.qualified_name == qualified_name:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_prompt(self, qualified_name: str) -> CatalogPromptEntry | None:
|
||||||
|
for entry in self.prompt_entries():
|
||||||
|
if entry.qualified_name == qualified_name:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def as_payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"qualified_name": entry.qualified_name,
|
||||||
|
"connection_id": entry.connection_id,
|
||||||
|
"local_name": entry.local_name,
|
||||||
|
"title": entry.title,
|
||||||
|
"description": entry.description,
|
||||||
|
"outcomes": list(entry.outcomes),
|
||||||
|
"input_schema": entry.input_schema,
|
||||||
|
"output_schema": entry.output_schema,
|
||||||
|
}
|
||||||
|
for entry in self.entries()
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"qualified_name": entry.qualified_name,
|
||||||
|
"connection_id": entry.connection_id,
|
||||||
|
"local_name": entry.local_name,
|
||||||
|
"title": entry.title,
|
||||||
|
"uri": entry.uri,
|
||||||
|
"description": entry.description,
|
||||||
|
"mime_type": entry.mime_type,
|
||||||
|
"metadata": entry.metadata,
|
||||||
|
}
|
||||||
|
for entry in self.resource_entries()
|
||||||
|
],
|
||||||
|
"prompts": [
|
||||||
|
{
|
||||||
|
"qualified_name": entry.qualified_name,
|
||||||
|
"connection_id": entry.connection_id,
|
||||||
|
"local_name": entry.local_name,
|
||||||
|
"title": entry.title,
|
||||||
|
"description": entry.description,
|
||||||
|
"arguments": entry.arguments,
|
||||||
|
"metadata": entry.metadata,
|
||||||
|
}
|
||||||
|
for entry in self.prompt_entries()
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"connection_id": snapshot.connection_id,
|
||||||
|
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
|
||||||
|
"max_age_seconds": snapshot.max_age_seconds,
|
||||||
|
"metadata": snapshot.metadata,
|
||||||
|
}
|
||||||
|
for snapshot in sorted(
|
||||||
|
self.snapshots.values(),
|
||||||
|
key=lambda snapshot: snapshot.connection_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CombinedCatalog", "snapshot_from_specs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Export the canonical symbols**
|
||||||
|
|
||||||
|
Modify `src/wf_sources_mcp/catalog/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .aggregate import CombinedCatalog, snapshot_from_specs
|
||||||
|
from .entries import (
|
||||||
|
CatalogNodeEntry,
|
||||||
|
CatalogPromptEntry,
|
||||||
|
CatalogResourceEntry,
|
||||||
|
DiscoveredPrompt,
|
||||||
|
DiscoveredResource,
|
||||||
|
DiscoveredTool,
|
||||||
|
)
|
||||||
|
from .models import CatalogSnapshot, dump_catalog_snapshot
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CatalogNodeEntry",
|
||||||
|
"CatalogPromptEntry",
|
||||||
|
"CatalogResourceEntry",
|
||||||
|
"CatalogSnapshot",
|
||||||
|
"CombinedCatalog",
|
||||||
|
"DiscoveredPrompt",
|
||||||
|
"DiscoveredResource",
|
||||||
|
"DiscoveredTool",
|
||||||
|
"dump_catalog_snapshot",
|
||||||
|
"snapshot_from_specs",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run canonical tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_sources_mcp/test_catalog_aggregate.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Replace `wf_mcp.broker.catalog` With a Compatibility Shim
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/broker/catalog.py`
|
||||||
|
- Modify: `tests/wf_mcp/test_compat_imports.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the old implementation with a shim**
|
||||||
|
|
||||||
|
Replace `src/wf_mcp/broker/catalog.py` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Compatibility shim for MCP source catalog aggregation helpers.
|
||||||
|
|
||||||
|
Canonical implementation lives in `wf_sources_mcp.catalog`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_sources_mcp.catalog import CombinedCatalog, snapshot_from_specs
|
||||||
|
|
||||||
|
__all__ = ["CombinedCatalog", "snapshot_from_specs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add shim identity tests**
|
||||||
|
|
||||||
|
Append this test to `tests/wf_mcp/test_compat_imports.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_wf_mcp_broker_catalog_shim_reexports_wf_sources_mcp_catalog() -> None:
|
||||||
|
from wf_mcp.broker.catalog import CombinedCatalog as CompatCombinedCatalog
|
||||||
|
from wf_mcp.broker.catalog import snapshot_from_specs as compat_snapshot_from_specs
|
||||||
|
from wf_sources_mcp.catalog import CombinedCatalog, snapshot_from_specs
|
||||||
|
|
||||||
|
assert CompatCombinedCatalog is CombinedCatalog
|
||||||
|
assert compat_snapshot_from_specs is snapshot_from_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the compatibility test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_mcp/test_compat_imports.py::test_wf_mcp_broker_catalog_shim_reexports_wf_sources_mcp_catalog -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update Production Imports to Canonical Path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/broker/service/source_catalog.py`
|
||||||
|
- Modify: `src/wf_mcp/broker/service/upstream_transport.py`
|
||||||
|
- Review: `src/wf_mcp/broker/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `source_catalog.py` imports**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..catalog import CombinedCatalog, snapshot_from_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_sources_mcp.catalog import CombinedCatalog, snapshot_from_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `upstream_transport.py` imports**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_mcp.broker.catalog import snapshot_from_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_sources_mcp.catalog import snapshot_from_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Leave or simplify broker package exports**
|
||||||
|
|
||||||
|
Inspect `src/wf_mcp/broker/__init__.py`. If it still imports from `.catalog`, that is acceptable because `.catalog` is now a compatibility shim. If ruff flags anything, change only the import line to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_sources_mcp.catalog import CombinedCatalog, snapshot_from_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not remove `CombinedCatalog` or `snapshot_from_specs` from `__all__`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused service tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_adapters.py tests/wf_mcp/service/test_upstream_transport.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Strengthen Import-Direction Guard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/wf_sources_mcp/test_import_direction_guard.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add old broker catalog module to the forbidden catalog imports**
|
||||||
|
|
||||||
|
In `test_wf_sources_mcp_does_not_import_wf_mcp_catalog_dtos`, extend `forbidden`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
forbidden = {
|
||||||
|
"wf_mcp.broker.catalog",
|
||||||
|
"wf_mcp.capabilities",
|
||||||
|
"wf_mcp.catalog",
|
||||||
|
"wf_mcp.catalog.models",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the import-direction guard**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_sources_mcp/test_import_direction_guard.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 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-07-wf-sources-mcp-catalog-aggregation.md` to `docs/historical/superpowers/plans/2026-06-07-wf-sources-mcp-catalog-aggregation.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `docs/current_roadmap.md`**
|
||||||
|
|
||||||
|
Under the MCP upstream source runtime cleanup / `wf_sources_mcp` section, add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Completed: MCP source catalog aggregation helpers (`CombinedCatalog` and
|
||||||
|
`snapshot_from_specs`) now live in `wf_sources_mcp.catalog`; the old
|
||||||
|
`wf_mcp.broker.catalog` path is a compatibility shim.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`**
|
||||||
|
|
||||||
|
Add a completed numbered item after the current catalog/runtime items:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
16. Complete: MCP source catalog aggregation helpers (`CombinedCatalog` and
|
||||||
|
`snapshot_from_specs`) moved to `wf_sources_mcp.catalog`, with
|
||||||
|
`wf_mcp.broker.catalog` retained as a compatibility shim.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the numbering differs because new items landed meanwhile, keep the order chronological and renumber the following item.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Archive the plan**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git mv docs/superpowers/plans/2026-06-07-wf-sources-mcp-catalog-aggregation.md docs/historical/superpowers/plans/2026-06-07-wf-sources-mcp-catalog-aggregation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `git status --short` shows an `R` rename for the plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code edits unless verification finds a real issue.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_sources_mcp/test_catalog_aggregate.py tests/wf_sources_mcp/test_catalog_dtos.py tests/wf_sources_mcp/test_import_direction_guard.py tests/wf_mcp/test_compat_imports.py tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_adapters.py tests/wf_mcp/service/test_upstream_transport.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all selected tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run source package tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/wf_sources_mcp -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all `wf_sources_mcp` tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check src/wf_sources_mcp/catalog src/wf_mcp/broker/catalog.py src/wf_mcp/broker/service/source_catalog.py src/wf_mcp/broker/service/upstream_transport.py tests/wf_sources_mcp/test_catalog_aggregate.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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run basedpyright --level error src/wf_sources_mcp src/wf_mcp/broker/catalog.py src/wf_mcp/broker/service/source_catalog.py src/wf_mcp/broker/service/upstream_transport.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `0 errors, 0 warnings, 0 notes`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Check old import usage**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "wf_mcp\.broker\.catalog|from \.\.catalog import|from \.catalog import" src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only compatibility exports/tests or unrelated non-MCP package imports remain. `src/wf_mcp/broker/service/source_catalog.py` and `src/wf_mcp/broker/service/upstream_transport.py` should not import the old broker catalog path.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Check whitespace**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
- Whether `wf_mcp.broker.catalog` is now a shim.
|
||||||
|
- Whether `wf_sources_mcp.catalog.aggregate` imports any `wf_mcp` module. It should not.
|
||||||
|
- Any deviations from this plan.
|
||||||
|
|
||||||
|
Do not claim "full suite passed" unless the full suite was actually run.
|
||||||
@@ -126,7 +126,10 @@ First slices should move leaf modules only and leave `wf_mcp` re-export shims:
|
|||||||
14. Complete: stateful MCP runtime protocols split into tool/resource/prompt
|
14. Complete: stateful MCP runtime protocols split into tool/resource/prompt
|
||||||
slices. Runtime can route `list_resources` and `list_prompts` through the
|
slices. Runtime can route `list_resources` and `list_prompts` through the
|
||||||
owner task for session-scoped listings; catalog refresh remains one-shot.
|
owner task for session-scoped listings; catalog refresh remains one-shot.
|
||||||
15. Upstream transport/discovery/session services.
|
15. Complete: MCP source catalog aggregation helpers (`CombinedCatalog` and
|
||||||
|
`snapshot_from_specs`) moved to `wf_sources_mcp.catalog`, with
|
||||||
|
`wf_mcp.broker.catalog` retained as a compatibility shim.
|
||||||
|
16. Upstream transport/discovery/session services.
|
||||||
|
|
||||||
Each slice should add import-direction tests so the new source-provider package
|
Each slice should add import-direction tests so the new source-provider package
|
||||||
does not depend on `wf_mcp.workflow_surface`, `wf_mcp.admin_surface`,
|
does not depend on `wf_mcp.workflow_surface`, `wf_mcp.admin_surface`,
|
||||||
|
|||||||
@@ -1,176 +1,10 @@
|
|||||||
|
"""Compatibility shim for MCP source catalog aggregation helpers.
|
||||||
|
|
||||||
|
Canonical implementation lives in ``wf_sources_mcp.catalog``.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from wf_sources_mcp.catalog import CombinedCatalog, snapshot_from_specs
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from wf_authoring import NodeCatalog, NodeSpec
|
__all__ = ["CombinedCatalog", "snapshot_from_specs"]
|
||||||
from wf_sources_mcp.catalog import (
|
|
||||||
CatalogNodeEntry,
|
|
||||||
CatalogPromptEntry,
|
|
||||||
CatalogResourceEntry,
|
|
||||||
DiscoveredPrompt,
|
|
||||||
DiscoveredResource,
|
|
||||||
)
|
|
||||||
from wf_sources_mcp.catalog.models import CatalogSnapshot
|
|
||||||
from wf_sources_mcp.sdk.converters import (
|
|
||||||
workflow_output_schema_from_mcp_tool_schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..connections import qualify_node_name
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_from_specs(
|
|
||||||
connection_id: str,
|
|
||||||
*,
|
|
||||||
specs: dict[str, NodeSpec[Any, Any]],
|
|
||||||
tool_display_names: dict[str, str | None] | None = None,
|
|
||||||
resources: list[DiscoveredResource] | None = None,
|
|
||||||
prompts: list[DiscoveredPrompt] | None = None,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
fetched_at_epoch_ms: int,
|
|
||||||
max_age_seconds: int,
|
|
||||||
) -> CatalogSnapshot:
|
|
||||||
catalog = NodeCatalog.from_specs(*specs.values())
|
|
||||||
nodes = [
|
|
||||||
CatalogNodeEntry(
|
|
||||||
qualified_name=entry.name
|
|
||||||
if entry.name.startswith(f"{connection_id}.")
|
|
||||||
else qualify_node_name(connection_id, entry.name),
|
|
||||||
connection_id=connection_id,
|
|
||||||
local_name=entry.name.removeprefix(f"{connection_id}."),
|
|
||||||
title=(tool_display_names or {}).get(
|
|
||||||
entry.name.removeprefix(f"{connection_id}."),
|
|
||||||
entry.display_name,
|
|
||||||
),
|
|
||||||
description=entry.description,
|
|
||||||
outcomes=entry.outcomes,
|
|
||||||
input_schema=entry.input_schema,
|
|
||||||
output_schema=workflow_output_schema_from_mcp_tool_schema(
|
|
||||||
entry.output_schema
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for entry in catalog.entries()
|
|
||||||
]
|
|
||||||
resource_entries = [
|
|
||||||
CatalogResourceEntry(
|
|
||||||
qualified_name=qualify_node_name(connection_id, resource.name),
|
|
||||||
connection_id=connection_id,
|
|
||||||
local_name=resource.name,
|
|
||||||
title=resource.title,
|
|
||||||
uri=resource.uri,
|
|
||||||
description=resource.description,
|
|
||||||
mime_type=resource.mime_type,
|
|
||||||
metadata=resource.metadata,
|
|
||||||
)
|
|
||||||
for resource in resources or []
|
|
||||||
]
|
|
||||||
prompt_entries = [
|
|
||||||
CatalogPromptEntry(
|
|
||||||
qualified_name=qualify_node_name(connection_id, prompt.name),
|
|
||||||
connection_id=connection_id,
|
|
||||||
local_name=prompt.name,
|
|
||||||
title=prompt.title,
|
|
||||||
description=prompt.description,
|
|
||||||
arguments=prompt.arguments,
|
|
||||||
metadata=prompt.metadata,
|
|
||||||
)
|
|
||||||
for prompt in prompts or []
|
|
||||||
]
|
|
||||||
return CatalogSnapshot(
|
|
||||||
connection_id=connection_id,
|
|
||||||
fetched_at_epoch_ms=fetched_at_epoch_ms,
|
|
||||||
max_age_seconds=max_age_seconds,
|
|
||||||
nodes=nodes,
|
|
||||||
resources=resource_entries,
|
|
||||||
prompts=prompt_entries,
|
|
||||||
metadata=metadata or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class CombinedCatalog:
|
|
||||||
snapshots: dict[str, CatalogSnapshot] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def entries(self) -> list[CatalogNodeEntry]:
|
|
||||||
result: list[CatalogNodeEntry] = []
|
|
||||||
for snapshot in self.snapshots.values():
|
|
||||||
result.extend(snapshot.nodes)
|
|
||||||
return sorted(result, key=lambda entry: entry.qualified_name)
|
|
||||||
|
|
||||||
def resource_entries(self) -> list[CatalogResourceEntry]:
|
|
||||||
result: list[CatalogResourceEntry] = []
|
|
||||||
for snapshot in self.snapshots.values():
|
|
||||||
result.extend(snapshot.resources)
|
|
||||||
return sorted(result, key=lambda entry: entry.qualified_name)
|
|
||||||
|
|
||||||
def prompt_entries(self) -> list[CatalogPromptEntry]:
|
|
||||||
result: list[CatalogPromptEntry] = []
|
|
||||||
for snapshot in self.snapshots.values():
|
|
||||||
result.extend(snapshot.prompts)
|
|
||||||
return sorted(result, key=lambda entry: entry.qualified_name)
|
|
||||||
|
|
||||||
def find_resource(self, qualified_name: str) -> CatalogResourceEntry | None:
|
|
||||||
for entry in self.resource_entries():
|
|
||||||
if entry.qualified_name == qualified_name:
|
|
||||||
return entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
def find_prompt(self, qualified_name: str) -> CatalogPromptEntry | None:
|
|
||||||
for entry in self.prompt_entries():
|
|
||||||
if entry.qualified_name == qualified_name:
|
|
||||||
return entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
def as_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"qualified_name": entry.qualified_name,
|
|
||||||
"connection_id": entry.connection_id,
|
|
||||||
"local_name": entry.local_name,
|
|
||||||
"title": entry.title,
|
|
||||||
"description": entry.description,
|
|
||||||
"outcomes": list(entry.outcomes),
|
|
||||||
"input_schema": entry.input_schema,
|
|
||||||
"output_schema": entry.output_schema,
|
|
||||||
}
|
|
||||||
for entry in self.entries()
|
|
||||||
],
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"qualified_name": entry.qualified_name,
|
|
||||||
"connection_id": entry.connection_id,
|
|
||||||
"local_name": entry.local_name,
|
|
||||||
"title": entry.title,
|
|
||||||
"uri": entry.uri,
|
|
||||||
"description": entry.description,
|
|
||||||
"mime_type": entry.mime_type,
|
|
||||||
"metadata": entry.metadata,
|
|
||||||
}
|
|
||||||
for entry in self.resource_entries()
|
|
||||||
],
|
|
||||||
"prompts": [
|
|
||||||
{
|
|
||||||
"qualified_name": entry.qualified_name,
|
|
||||||
"connection_id": entry.connection_id,
|
|
||||||
"local_name": entry.local_name,
|
|
||||||
"title": entry.title,
|
|
||||||
"description": entry.description,
|
|
||||||
"arguments": entry.arguments,
|
|
||||||
"metadata": entry.metadata,
|
|
||||||
}
|
|
||||||
for entry in self.prompt_entries()
|
|
||||||
],
|
|
||||||
"connections": [
|
|
||||||
{
|
|
||||||
"connection_id": snapshot.connection_id,
|
|
||||||
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
|
|
||||||
"max_age_seconds": snapshot.max_age_seconds,
|
|
||||||
"metadata": snapshot.metadata,
|
|
||||||
}
|
|
||||||
for snapshot in sorted(
|
|
||||||
self.snapshots.values(),
|
|
||||||
key=lambda snapshot: snapshot.connection_id,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from wf_sources_mcp.catalog import (
|
|||||||
CatalogNodeEntry,
|
CatalogNodeEntry,
|
||||||
CatalogPromptEntry,
|
CatalogPromptEntry,
|
||||||
CatalogResourceEntry,
|
CatalogResourceEntry,
|
||||||
|
CombinedCatalog,
|
||||||
)
|
)
|
||||||
from wf_sources_mcp.sdk import BackendAdapter, StatefulMcpRuntime, ToolExecutor
|
from wf_sources_mcp.sdk import BackendAdapter, StatefulMcpRuntime, ToolExecutor
|
||||||
from wf_sources_mcp.source_registry import SourceRegistryStore
|
from wf_sources_mcp.source_registry import SourceRegistryStore
|
||||||
@@ -38,7 +39,6 @@ from ...models import (
|
|||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
)
|
)
|
||||||
from ..admin_capabilities import admin_source
|
from ..admin_capabilities import admin_source
|
||||||
from ..catalog import CombinedCatalog
|
|
||||||
from .builtins import builtin_sources
|
from .builtins import builtin_sources
|
||||||
from .connection_service import ConnectionService
|
from .connection_service import ConnectionService
|
||||||
from .content_access import ContentAccessService
|
from .content_access import ContentAccessService
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from wf_sources_mcp.catalog import (
|
|||||||
CatalogNodeEntry,
|
CatalogNodeEntry,
|
||||||
CatalogPromptEntry,
|
CatalogPromptEntry,
|
||||||
CatalogResourceEntry,
|
CatalogResourceEntry,
|
||||||
|
CombinedCatalog,
|
||||||
|
snapshot_from_specs,
|
||||||
)
|
)
|
||||||
from wf_sources_mcp.connections import mcp_source_connection_from_connection_config
|
from wf_sources_mcp.connections import mcp_source_connection_from_connection_config
|
||||||
from wf_sources_mcp.sdk import ToolExecutor
|
from wf_sources_mcp.sdk import ToolExecutor
|
||||||
@@ -33,7 +35,6 @@ from ...models import (
|
|||||||
CatalogSnapshot,
|
CatalogSnapshot,
|
||||||
)
|
)
|
||||||
from ...workflow.wrappers import _model_from_schema
|
from ...workflow.wrappers import _model_from_schema
|
||||||
from ..catalog import CombinedCatalog, snapshot_from_specs
|
|
||||||
from .specs import get_qualified_spec, qualify_spec
|
from .specs import get_qualified_spec, qualify_spec
|
||||||
|
|
||||||
ConnectionLookup = Callable[[str], ConnectionConfig]
|
ConnectionLookup = Callable[[str], ConnectionConfig]
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from wf_artifacts import (
|
|||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowDeployment,
|
WorkflowDeployment,
|
||||||
)
|
)
|
||||||
from wf_mcp.broker.catalog import snapshot_from_specs
|
|
||||||
from wf_mcp.broker.discovery import (
|
from wf_mcp.broker.discovery import (
|
||||||
discover_connection_capabilities,
|
discover_connection_capabilities,
|
||||||
specs_from_discovered_tools,
|
specs_from_discovered_tools,
|
||||||
@@ -26,6 +25,7 @@ from wf_mcp.events import McpEvent, make_event
|
|||||||
from wf_mcp.models import ConnectionConfig
|
from wf_mcp.models import ConnectionConfig
|
||||||
from wf_mcp.shared.errors import error_payload
|
from wf_mcp.shared.errors import error_payload
|
||||||
from wf_sources_mcp.auth import AuthRecord, connection_auth_diagnostic
|
from wf_sources_mcp.auth import AuthRecord, connection_auth_diagnostic
|
||||||
|
from wf_sources_mcp.catalog import snapshot_from_specs
|
||||||
from wf_sources_mcp.catalog.models import CatalogSnapshot
|
from wf_sources_mcp.catalog.models import CatalogSnapshot
|
||||||
from wf_sources_mcp.connections import (
|
from wf_sources_mcp.connections import (
|
||||||
mcp_source_connection_from_connection_config,
|
mcp_source_connection_from_connection_config,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .aggregate import CombinedCatalog, snapshot_from_specs
|
||||||
from .entries import (
|
from .entries import (
|
||||||
CatalogNodeEntry,
|
CatalogNodeEntry,
|
||||||
CatalogPromptEntry,
|
CatalogPromptEntry,
|
||||||
@@ -15,8 +16,10 @@ __all__ = [
|
|||||||
"CatalogPromptEntry",
|
"CatalogPromptEntry",
|
||||||
"CatalogResourceEntry",
|
"CatalogResourceEntry",
|
||||||
"CatalogSnapshot",
|
"CatalogSnapshot",
|
||||||
|
"CombinedCatalog",
|
||||||
"DiscoveredPrompt",
|
"DiscoveredPrompt",
|
||||||
"DiscoveredResource",
|
"DiscoveredResource",
|
||||||
"DiscoveredTool",
|
"DiscoveredTool",
|
||||||
"dump_catalog_snapshot",
|
"dump_catalog_snapshot",
|
||||||
|
"snapshot_from_specs",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wf_authoring import NodeCatalog, NodeSpec
|
||||||
|
from wf_sources_mcp.ids import parse_connection_id
|
||||||
|
|
||||||
|
from .entries import (
|
||||||
|
CatalogNodeEntry,
|
||||||
|
CatalogPromptEntry,
|
||||||
|
CatalogResourceEntry,
|
||||||
|
DiscoveredPrompt,
|
||||||
|
DiscoveredResource,
|
||||||
|
)
|
||||||
|
from .models import CatalogSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _qualify_local_name(connection_id: str, local_name: str) -> str:
|
||||||
|
"""Qualify a source-local catalog name without depending on wf_mcp shims."""
|
||||||
|
parse_connection_id(connection_id)
|
||||||
|
if not local_name:
|
||||||
|
raise ValueError("local catalog name must not be empty")
|
||||||
|
return f"{connection_id}.{local_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_from_specs(
|
||||||
|
connection_id: str,
|
||||||
|
*,
|
||||||
|
specs: dict[str, NodeSpec[Any, Any]],
|
||||||
|
tool_display_names: dict[str, str | None] | None = None,
|
||||||
|
resources: list[DiscoveredResource] | None = None,
|
||||||
|
prompts: list[DiscoveredPrompt] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
fetched_at_epoch_ms: int,
|
||||||
|
max_age_seconds: int,
|
||||||
|
) -> CatalogSnapshot:
|
||||||
|
from wf_sources_mcp.sdk.converters import (
|
||||||
|
workflow_output_schema_from_mcp_tool_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = NodeCatalog.from_specs(*specs.values())
|
||||||
|
nodes = [
|
||||||
|
CatalogNodeEntry(
|
||||||
|
qualified_name=entry.name
|
||||||
|
if entry.name.startswith(f"{connection_id}.")
|
||||||
|
else _qualify_local_name(connection_id, entry.name),
|
||||||
|
connection_id=connection_id,
|
||||||
|
local_name=entry.name.removeprefix(f"{connection_id}."),
|
||||||
|
title=(tool_display_names or {}).get(
|
||||||
|
entry.name.removeprefix(f"{connection_id}."),
|
||||||
|
entry.display_name,
|
||||||
|
),
|
||||||
|
description=entry.description,
|
||||||
|
outcomes=entry.outcomes,
|
||||||
|
input_schema=entry.input_schema,
|
||||||
|
output_schema=workflow_output_schema_from_mcp_tool_schema(
|
||||||
|
entry.output_schema
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for entry in catalog.entries()
|
||||||
|
]
|
||||||
|
resource_entries = [
|
||||||
|
CatalogResourceEntry(
|
||||||
|
qualified_name=_qualify_local_name(connection_id, resource.name),
|
||||||
|
connection_id=connection_id,
|
||||||
|
local_name=resource.name,
|
||||||
|
title=resource.title,
|
||||||
|
uri=resource.uri,
|
||||||
|
description=resource.description,
|
||||||
|
mime_type=resource.mime_type,
|
||||||
|
metadata=resource.metadata,
|
||||||
|
)
|
||||||
|
for resource in resources or []
|
||||||
|
]
|
||||||
|
prompt_entries = [
|
||||||
|
CatalogPromptEntry(
|
||||||
|
qualified_name=_qualify_local_name(connection_id, prompt.name),
|
||||||
|
connection_id=connection_id,
|
||||||
|
local_name=prompt.name,
|
||||||
|
title=prompt.title,
|
||||||
|
description=prompt.description,
|
||||||
|
arguments=prompt.arguments,
|
||||||
|
metadata=prompt.metadata,
|
||||||
|
)
|
||||||
|
for prompt in prompts or []
|
||||||
|
]
|
||||||
|
return CatalogSnapshot(
|
||||||
|
connection_id=connection_id,
|
||||||
|
fetched_at_epoch_ms=fetched_at_epoch_ms,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
nodes=nodes,
|
||||||
|
resources=resource_entries,
|
||||||
|
prompts=prompt_entries,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CombinedCatalog:
|
||||||
|
snapshots: dict[str, CatalogSnapshot] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def entries(self) -> list[CatalogNodeEntry]:
|
||||||
|
result: list[CatalogNodeEntry] = []
|
||||||
|
for snapshot in self.snapshots.values():
|
||||||
|
result.extend(snapshot.nodes)
|
||||||
|
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||||
|
|
||||||
|
def resource_entries(self) -> list[CatalogResourceEntry]:
|
||||||
|
result: list[CatalogResourceEntry] = []
|
||||||
|
for snapshot in self.snapshots.values():
|
||||||
|
result.extend(snapshot.resources)
|
||||||
|
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||||
|
|
||||||
|
def prompt_entries(self) -> list[CatalogPromptEntry]:
|
||||||
|
result: list[CatalogPromptEntry] = []
|
||||||
|
for snapshot in self.snapshots.values():
|
||||||
|
result.extend(snapshot.prompts)
|
||||||
|
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||||
|
|
||||||
|
def find_resource(self, qualified_name: str) -> CatalogResourceEntry | None:
|
||||||
|
for entry in self.resource_entries():
|
||||||
|
if entry.qualified_name == qualified_name:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_prompt(self, qualified_name: str) -> CatalogPromptEntry | None:
|
||||||
|
for entry in self.prompt_entries():
|
||||||
|
if entry.qualified_name == qualified_name:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def as_payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"qualified_name": entry.qualified_name,
|
||||||
|
"connection_id": entry.connection_id,
|
||||||
|
"local_name": entry.local_name,
|
||||||
|
"title": entry.title,
|
||||||
|
"description": entry.description,
|
||||||
|
"outcomes": list(entry.outcomes),
|
||||||
|
"input_schema": entry.input_schema,
|
||||||
|
"output_schema": entry.output_schema,
|
||||||
|
}
|
||||||
|
for entry in self.entries()
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"qualified_name": entry.qualified_name,
|
||||||
|
"connection_id": entry.connection_id,
|
||||||
|
"local_name": entry.local_name,
|
||||||
|
"title": entry.title,
|
||||||
|
"uri": entry.uri,
|
||||||
|
"description": entry.description,
|
||||||
|
"mime_type": entry.mime_type,
|
||||||
|
"metadata": entry.metadata,
|
||||||
|
}
|
||||||
|
for entry in self.resource_entries()
|
||||||
|
],
|
||||||
|
"prompts": [
|
||||||
|
{
|
||||||
|
"qualified_name": entry.qualified_name,
|
||||||
|
"connection_id": entry.connection_id,
|
||||||
|
"local_name": entry.local_name,
|
||||||
|
"title": entry.title,
|
||||||
|
"description": entry.description,
|
||||||
|
"arguments": entry.arguments,
|
||||||
|
"metadata": entry.metadata,
|
||||||
|
}
|
||||||
|
for entry in self.prompt_entries()
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"connection_id": snapshot.connection_id,
|
||||||
|
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
|
||||||
|
"max_age_seconds": snapshot.max_age_seconds,
|
||||||
|
"metadata": snapshot.metadata,
|
||||||
|
}
|
||||||
|
for snapshot in sorted(
|
||||||
|
self.snapshots.values(),
|
||||||
|
key=lambda snapshot: snapshot.connection_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CombinedCatalog", "snapshot_from_specs"]
|
||||||
@@ -203,3 +203,12 @@ def test_runtime_shims_reexport_wf_sources_mcp_runtime() -> None:
|
|||||||
assert OldPersistentMcpSession is PersistentMcpSession
|
assert OldPersistentMcpSession is PersistentMcpSession
|
||||||
assert OldPersistentSessionFactory is PersistentSessionFactory
|
assert OldPersistentSessionFactory is PersistentSessionFactory
|
||||||
assert old_connection_runtime_fingerprint is connection_runtime_fingerprint
|
assert old_connection_runtime_fingerprint is connection_runtime_fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_mcp_broker_catalog_shim_reexports_wf_sources_mcp_catalog() -> None:
|
||||||
|
from wf_mcp.broker.catalog import CombinedCatalog as CompatCombinedCatalog
|
||||||
|
from wf_mcp.broker.catalog import snapshot_from_specs as compat_snapshot_from_specs
|
||||||
|
from wf_sources_mcp.catalog import CombinedCatalog, snapshot_from_specs
|
||||||
|
|
||||||
|
assert CompatCombinedCatalog is CombinedCatalog
|
||||||
|
assert compat_snapshot_from_specs is snapshot_from_specs
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec
|
||||||
|
from wf_sources_mcp.catalog import (
|
||||||
|
CombinedCatalog,
|
||||||
|
DiscoveredPrompt,
|
||||||
|
DiscoveredResource,
|
||||||
|
snapshot_from_specs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EchoInput(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _echo(payload: EchoInput) -> EchoOutput:
|
||||||
|
return EchoOutput(text=payload.message)
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_spec() -> NodeSpec[EchoInput, EchoOutput]:
|
||||||
|
return NodeSpec(
|
||||||
|
name="echo",
|
||||||
|
input_model=EchoInput,
|
||||||
|
output_model=EchoOutput,
|
||||||
|
outcomes=("ok",),
|
||||||
|
fn=_echo,
|
||||||
|
description="Echo message",
|
||||||
|
is_async=True,
|
||||||
|
input_schema_contract={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"message": {"type": "string"}},
|
||||||
|
"required": ["message"],
|
||||||
|
},
|
||||||
|
output_schema_contract={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_from_specs_qualifies_nodes_resources_and_prompts() -> None:
|
||||||
|
snapshot = snapshot_from_specs(
|
||||||
|
"demo.default",
|
||||||
|
specs={"echo": _echo_spec()},
|
||||||
|
tool_display_names={"echo": "Echo Tool"},
|
||||||
|
resources=[
|
||||||
|
DiscoveredResource(
|
||||||
|
uri="demo://docs/guide",
|
||||||
|
name="guide",
|
||||||
|
title="Guide",
|
||||||
|
description="Read me",
|
||||||
|
mime_type="text/markdown",
|
||||||
|
metadata={"kind": "doc"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
prompts=[
|
||||||
|
DiscoveredPrompt(
|
||||||
|
name="summarize",
|
||||||
|
title="Summarize",
|
||||||
|
description="Summarize text",
|
||||||
|
arguments=[{"name": "topic"}],
|
||||||
|
metadata={"kind": "prompt"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
metadata={"source": "test"},
|
||||||
|
fetched_at_epoch_ms=123,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.connection_id == "demo.default"
|
||||||
|
assert snapshot.nodes[0].qualified_name == "demo.default.echo"
|
||||||
|
assert snapshot.nodes[0].local_name == "echo"
|
||||||
|
assert snapshot.nodes[0].title == "Echo Tool"
|
||||||
|
assert snapshot.resources[0].qualified_name == "demo.default.guide"
|
||||||
|
assert snapshot.resources[0].uri == "demo://docs/guide"
|
||||||
|
assert snapshot.prompts[0].qualified_name == "demo.default.summarize"
|
||||||
|
assert snapshot.prompts[0].arguments == [{"name": "topic"}]
|
||||||
|
assert snapshot.metadata == {"source": "test"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_from_specs_preserves_already_qualified_node_name() -> None:
|
||||||
|
spec = dataclasses.replace(_echo_spec(), name="demo.default.echo")
|
||||||
|
|
||||||
|
snapshot = snapshot_from_specs(
|
||||||
|
"demo.default",
|
||||||
|
specs={"demo.default.echo": spec},
|
||||||
|
fetched_at_epoch_ms=123,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.nodes[0].qualified_name == "demo.default.echo"
|
||||||
|
assert snapshot.nodes[0].local_name == "echo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_catalog_sorts_entries_and_serializes_payload() -> None:
|
||||||
|
first = snapshot_from_specs(
|
||||||
|
"zeta.default",
|
||||||
|
specs={"echo": _echo_spec()},
|
||||||
|
resources=[DiscoveredResource(uri="zeta://guide", name="guide", title=None, description=None)],
|
||||||
|
prompts=[DiscoveredPrompt(name="prompt", title=None, description=None)],
|
||||||
|
metadata={"order": "second"},
|
||||||
|
fetched_at_epoch_ms=2,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
second = snapshot_from_specs(
|
||||||
|
"alpha.default",
|
||||||
|
specs={"echo": _echo_spec()},
|
||||||
|
resources=[DiscoveredResource(uri="alpha://guide", name="guide", title=None, description=None)],
|
||||||
|
prompts=[DiscoveredPrompt(name="prompt", title=None, description=None)],
|
||||||
|
metadata={"order": "first"},
|
||||||
|
fetched_at_epoch_ms=1,
|
||||||
|
max_age_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = CombinedCatalog(
|
||||||
|
snapshots={
|
||||||
|
first.connection_id: first,
|
||||||
|
second.connection_id: second,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = catalog.as_payload()
|
||||||
|
|
||||||
|
assert [entry.qualified_name for entry in catalog.entries()] == [
|
||||||
|
"alpha.default.echo",
|
||||||
|
"zeta.default.echo",
|
||||||
|
]
|
||||||
|
assert catalog.find_resource("alpha.default.guide") is not None
|
||||||
|
assert catalog.find_prompt("zeta.default.prompt") is not None
|
||||||
|
assert [node["qualified_name"] for node in payload["nodes"]] == [
|
||||||
|
"alpha.default.echo",
|
||||||
|
"zeta.default.echo",
|
||||||
|
]
|
||||||
|
assert [item["connection_id"] for item in payload["connections"]] == [
|
||||||
|
"alpha.default",
|
||||||
|
"zeta.default",
|
||||||
|
]
|
||||||
@@ -46,6 +46,7 @@ def test_wf_sources_mcp_does_not_import_frontend_mcp_modules() -> None:
|
|||||||
def test_wf_sources_mcp_does_not_import_wf_mcp_catalog_dtos() -> None:
|
def test_wf_sources_mcp_does_not_import_wf_mcp_catalog_dtos() -> None:
|
||||||
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
|
root = Path(__file__).resolve().parents[2] / "src" / "wf_sources_mcp"
|
||||||
forbidden = {
|
forbidden = {
|
||||||
|
"wf_mcp.broker.catalog",
|
||||||
"wf_mcp.capabilities",
|
"wf_mcp.capabilities",
|
||||||
"wf_mcp.catalog",
|
"wf_mcp.catalog",
|
||||||
"wf_mcp.catalog.models",
|
"wf_mcp.catalog.models",
|
||||||
|
|||||||
Reference in New Issue
Block a user