Source catalog! wfmcpservice thinning

This commit is contained in:
lda
2026-06-02 13:21:37 +07:00 Verified
parent 55d45226f4
commit 62f880bc5b
9 changed files with 1809 additions and 288 deletions
+3
View File
@@ -118,6 +118,9 @@ implementation state.
- Double-delegation has been removed: CLI and MCP workflow tools construct - Double-delegation has been removed: CLI and MCP workflow tools construct
`WorkflowApi(context_from_service(service))` directly. `WorkflowSurfaceHandlers` `WorkflowApi(context_from_service(service))` directly. `WorkflowSurfaceHandlers`
remains only as a temporary compatibility shim for older imports. remains only as a temporary compatibility shim for older imports.
- `WfMcpService` is being reduced into injected implementation services. Source
registry and catalog projection now live in `SourceCatalogService`; the old
service methods remain as compatibility delegates for MCP broker callers.
Frame stress points remaining for native subgraphs and future fork/gather: Frame stress points remaining for native subgraphs and future fork/gather:
File diff suppressed because it is too large Load Diff
@@ -112,6 +112,10 @@ The extraction seam is clean: `WorkflowSurfaceHandlers` touches ~6 distinct capa
| `adapters` | `dict[str, BackendAdapter]` | `self.service.adapters` | 1 | **MCP-specific** (live source check) | | `adapters` | `dict[str, BackendAdapter]` | `self.service.adapters` | 1 | **MCP-specific** (live source check) |
| `load_auth(connection_id)` | `AuthRecord \| None` | `self.service.load_auth(...)` | 1 | **MCP-specific** (live source check) | | `load_auth(connection_id)` | `AuthRecord \| None` | `self.service.load_auth(...)` | 1 | **MCP-specific** (live source check) |
Source/catalog ownership is now split: `WfMcpService` coordinates broker runtime
state, while `SourceCatalogService` owns capability source maps, planner catalog
projection, snapshot hydration, and local docs lookup.
### WfMcpService Members NOT Used by WorkflowSurfaceHandlers ### WfMcpService Members NOT Used by WorkflowSurfaceHandlers
These members of `WfMcpService` (`src/wf_mcp/broker/service/core.py`) are NOT accessed by `WorkflowSurfaceHandlers`: These members of `WfMcpService` (`src/wf_mcp/broker/service/core.py`) are NOT accessed by `WorkflowSurfaceHandlers`:
+53 -284
View File
@@ -4,8 +4,6 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from pydantic import BaseModel
from wf_artifacts import ( from wf_artifacts import (
DraftWorkspaceStore, DraftWorkspaceStore,
RunStore, RunStore,
@@ -15,7 +13,7 @@ from wf_artifacts import (
WorkflowDeployment, WorkflowDeployment,
artifact_catalog_entry, artifact_catalog_entry,
) )
from wf_authoring import NodeReturn, NodeSpec from wf_authoring import NodeSpec
from wf_core import ( from wf_core import (
NodeUse, NodeUse,
RunState, RunState,
@@ -26,15 +24,9 @@ from wf_core import (
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_api.runtime_dependencies import resolve_runtime_dependencies from wf_api.runtime_dependencies import resolve_runtime_dependencies
from wf_platform import ( from wf_platform import (
CapabilityBuckets,
CapabilitySource, CapabilitySource,
DocumentationPrompt,
DocumentationResource,
SourcePermissions,
SourceVisibility,
page_items,
) )
from ...connections import ConnectionRegistry, parse_connection_id, qualify_node_name from ...connections import ConnectionRegistry, parse_connection_id
from ...events import EventBus, McpEvent, make_event from ...events import EventBus, McpEvent, make_event
from ...models import ( from ...models import (
AuthRecord, AuthRecord,
@@ -50,7 +42,6 @@ from ...runtime import ToolExecutor
from ...shared.errors import error_payload from ...shared.errors import error_payload
from ...shared.names import RESERVED_CONNECTION_IDS from ...shared.names import RESERVED_CONNECTION_IDS
from ...storage import Store from ...storage import Store
from ...workflow.wrappers import _model_from_schema
from wf_api.saved_subgraphs import ( from wf_api.saved_subgraphs import (
SavedSubgraphTree, SavedSubgraphTree,
prepare_saved_subgraphs, prepare_saved_subgraphs,
@@ -61,7 +52,7 @@ from ..catalog import CombinedCatalog, snapshot_from_specs
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
from .adapters import require_adapter from .adapters import require_adapter
from .builtins import builtin_sources from .builtins import builtin_sources
from .specs import get_qualified_spec, qualify_spec from .source_catalog import SourceCatalogService
@dataclass(slots=True) @dataclass(slots=True)
@@ -70,13 +61,13 @@ class WfMcpService:
default_catalog_max_age_seconds: int = 300 default_catalog_max_age_seconds: int = 300
connections: ConnectionRegistry = field(default_factory=ConnectionRegistry) connections: ConnectionRegistry = field(default_factory=ConnectionRegistry)
adapters: dict[str, BackendAdapter] = field(default_factory=dict) adapters: dict[str, BackendAdapter] = field(default_factory=dict)
capability_sources: dict[str, CapabilitySource] = field(default_factory=dict)
event_bus: EventBus = field(default_factory=EventBus) event_bus: EventBus = field(default_factory=EventBus)
include_builtin_specs: bool = True include_builtin_specs: bool = True
artifact_store: WorkflowArtifactStore | None = None artifact_store: WorkflowArtifactStore | None = None
draft_workspace_store: DraftWorkspaceStore | None = None draft_workspace_store: DraftWorkspaceStore | None = None
run_store: RunStore | None = None run_store: RunStore | None = None
tool_executor: ToolExecutor | None = None tool_executor: ToolExecutor | None = None
source_catalog: SourceCatalogService = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Install broker-local system specs when enabled. """Install broker-local system specs when enabled.
@@ -85,17 +76,36 @@ class WfMcpService:
must not guess workflow persistence from the MCP catalog/auth store because must not guess workflow persistence from the MCP catalog/auth store because
CLI, MCP, and future HTTP frontends may share or swap those stores. CLI, MCP, and future HTTP frontends may share or swap those stores.
""" """
self.source_catalog = SourceCatalogService(
store=self.store,
connection_lookup=self.connections.get,
connection_list_enabled=self.connections.list_enabled,
connection_list_all=self.connections.list_all,
tool_executor_for=self._tool_executor_for,
load_auth=self.load_auth,
emit_event=self._record_event,
default_catalog_max_age_seconds=self.default_catalog_max_age_seconds,
)
if self.include_builtin_specs: if self.include_builtin_specs:
for source in builtin_sources().values(): for source in builtin_sources().values():
self.register_capability_source(source) self.register_capability_source(source)
self.register_capability_source(admin_source()) self.register_capability_source(admin_source())
@property
def capability_sources(self) -> dict[str, CapabilitySource]:
"""Compatibility view of source catalog state.
Source ownership is moving into SourceCatalogService. Keep this property
because workflow APIs and existing tests still consume the service facade.
"""
return self.source_catalog.capability_sources
def register_connection(self, connection: ConnectionConfig) -> None: def register_connection(self, connection: ConnectionConfig) -> None:
parse_connection_id(connection.id) parse_connection_id(connection.id)
if connection.id in RESERVED_CONNECTION_IDS: if connection.id in RESERVED_CONNECTION_IDS:
raise ValueError(f"connection id {connection.id!r} is reserved by wf-mcp") raise ValueError(f"connection id {connection.id!r} is reserved by wf-mcp")
self.connections.register(connection) self.connections.register(connection)
self._hydrate_connection_source_from_snapshot(connection) self.source_catalog.hydrate_connection_source_from_snapshot(connection)
self._record_event( self._record_event(
make_event( make_event(
"connection_registered", "connection_registered",
@@ -127,7 +137,7 @@ class WfMcpService:
self.connections.register(connection) self.connections.register(connection)
source = self.capability_sources.get(connection.id) source = self.capability_sources.get(connection.id)
if source is None: if source is None:
self._hydrate_connection_source_from_snapshot(connection) self.source_catalog.hydrate_connection_source_from_snapshot(connection)
else: else:
source.enabled = connection.enabled source.enabled = connection.enabled
@@ -165,113 +175,28 @@ class WfMcpService:
max_age_seconds: int | None = None, max_age_seconds: int | None = None,
emit_change_events: bool = True, emit_change_events: bool = True,
) -> None: ) -> None:
self.connections.get(connection_id) self.source_catalog.register_specs(
qualified_specs = {
qualify_node_name(connection_id, spec.name): qualify_spec(
connection_id, spec
)
for spec in specs
}
existing_source = self.capability_sources.get(connection_id)
if existing_source is not None:
# Catalog refreshes replace discovered specs, not operator policy.
existing_source.capabilities.node_specs = qualified_specs
else:
self.register_capability_source(
CapabilitySource(
id=connection_id,
kind="connection",
capabilities=CapabilityBuckets(node_specs=qualified_specs),
enabled=self.connections.get(connection_id).enabled,
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(calls_upstream=True),
description=(
f"Specs discovered or registered for {connection_id}."
),
)
)
snapshot = snapshot_from_specs(
connection_id, connection_id,
specs=qualified_specs, *specs,
fetched_at_epoch_ms=int(time.time() * 1000), max_age_seconds=max_age_seconds,
max_age_seconds=max_age_seconds or self.default_catalog_max_age_seconds, emit_change_events=emit_change_events,
record_catalog_change_events=lambda source_id, snapshot, reason: (
self._record_catalog_change_events(
source_id,
snapshot,
reason=reason,
)
),
) )
self.store.save_catalog(snapshot)
self._record_event(
make_event(
"specs_registered",
connection_id=connection_id,
payload={"node_count": len(qualified_specs)},
)
)
if emit_change_events:
self._record_catalog_change_events(
connection_id,
snapshot,
reason="specs_registered",
)
def get_catalog(self) -> CombinedCatalog: def get_catalog(self) -> CombinedCatalog:
snapshots: dict[str, CatalogSnapshot] = {} return self.source_catalog.get_catalog()
for connection in self.connections.list_enabled():
snapshot = self.store.load_catalog(connection.id)
if snapshot is not None:
snapshots[connection.id] = snapshot
return CombinedCatalog(snapshots=snapshots)
def get_planner_catalog(self) -> CombinedCatalog: def get_planner_catalog(self) -> CombinedCatalog:
"""Return all planner-visible specs, including broker-local sources.""" return self.source_catalog.get_planner_catalog()
snapshots: dict[str, CatalogSnapshot] = {}
fetched_at_epoch_ms = int(time.time() * 1000)
for source in self.capability_sources.values():
if not source.enabled or not source.visibility.planner:
continue
stored_snapshot = self.store.load_catalog(source.id)
snapshots[source.id] = snapshot_from_specs(
source.id,
specs=source.capabilities.node_specs,
tool_display_names={
entry.local_name: entry.title for entry in stored_snapshot.nodes
}
if stored_snapshot is not None
else None,
metadata={
"kind": source.kind,
"description": source.description,
}
if stored_snapshot is None
else stored_snapshot.metadata,
fetched_at_epoch_ms=(
stored_snapshot.fetched_at_epoch_ms
if stored_snapshot is not None
else fetched_at_epoch_ms
),
max_age_seconds=(
stored_snapshot.max_age_seconds
if stored_snapshot is not None
else self.default_catalog_max_age_seconds
),
)
if stored_snapshot is not None:
# Connection resources/prompts are discovered by the backend catalog,
# while planner node visibility is governed by capability sources.
snapshots[source.id].resources = list(stored_snapshot.resources)
snapshots[source.id].prompts = list(stored_snapshot.prompts)
return CombinedCatalog(snapshots=snapshots)
def list_sources(self) -> list[dict[str, Any]]: def list_sources(self) -> list[dict[str, Any]]:
"""Return every capability source with the names it currently owns.""" return self.source_catalog.list_sources()
return [
source.as_inventory().model_dump(mode="json")
for source in sorted(
self.capability_sources.values(),
key=lambda source: source.id,
)
]
def list_source_summaries( def list_source_summaries(
self, self,
@@ -279,32 +204,13 @@ class WfMcpService:
cursor: str | None = None, cursor: str | None = None,
limit: int = 50, limit: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return compact paged source summaries for progressive discovery.""" return self.source_catalog.list_source_summaries(cursor=cursor, limit=limit)
summaries = [
source.as_status().model_dump(mode="json")
for source in sorted(
self.capability_sources.values(),
key=lambda source: source.id,
)
]
page = page_items(summaries, cursor=cursor, limit=limit)
return {
"sources": list(page.items),
"next_cursor": page.next_cursor,
"total": page.total,
}
def inspect_source(self, source_id: str) -> dict[str, Any]: def inspect_source(self, source_id: str) -> dict[str, Any]:
"""Return the full source inventory for one exact source id.""" return self.source_catalog.inspect_source(source_id)
try:
source = self.capability_sources[source_id]
except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc
return source.as_inventory().model_dump(mode="json")
def list_available_specs(self) -> list[CatalogNodeEntry]: def list_available_specs(self) -> list[CatalogNodeEntry]:
"""Return planner-visible node catalog entries from every visible source.""" return self.source_catalog.list_available_specs()
return self.get_planner_catalog().entries()
def workflow_artifact_catalog_entry( def workflow_artifact_catalog_entry(
self, self,
@@ -314,73 +220,35 @@ class WfMcpService:
return artifact_catalog_entry(artifact) return artifact_catalog_entry(artifact)
def get_connection_snapshot(self, connection_id: str) -> CatalogSnapshot | None: def get_connection_snapshot(self, connection_id: str) -> CatalogSnapshot | None:
self.connections.get(connection_id) return self.source_catalog.get_connection_snapshot(connection_id)
return self.store.load_catalog(connection_id)
def connection_statuses(self) -> list[dict[str, Any]]: def connection_statuses(self) -> list[dict[str, Any]]:
statuses: list[dict[str, Any]] = [] return self.source_catalog.connection_statuses()
for connection in self.connections.list_all():
snapshot = self.store.load_catalog(connection.id)
statuses.append(
{
"connection_id": connection.id,
"server": connection.server,
"account": connection.account,
"enabled": connection.enabled,
"has_snapshot": snapshot is not None,
"fetched_at_epoch_ms": None
if snapshot is None
else snapshot.fetched_at_epoch_ms,
"max_age_seconds": None
if snapshot is None
else snapshot.max_age_seconds,
"node_count": 0 if snapshot is None else len(snapshot.nodes),
"resource_count": 0
if snapshot is None
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
return statuses
def list_resources( def list_resources(
self, self,
*, *,
connection_id: str | None = None, connection_id: str | None = None,
) -> list[CatalogResourceEntry]: ) -> list[CatalogResourceEntry]:
if connection_id is None: return self.source_catalog.list_resources(connection_id=connection_id)
return self.get_catalog().resource_entries()
snapshot = self.get_connection_snapshot(connection_id)
if snapshot is None:
return []
return sorted(snapshot.resources, key=lambda entry: entry.qualified_name)
def list_prompts( def list_prompts(
self, self,
*, *,
connection_id: str | None = None, connection_id: str | None = None,
) -> list[CatalogPromptEntry]: ) -> list[CatalogPromptEntry]:
if connection_id is None: return self.source_catalog.list_prompts(connection_id=connection_id)
return self.get_catalog().prompt_entries()
snapshot = self.get_connection_snapshot(connection_id)
if snapshot is None:
return []
return sorted(snapshot.prompts, key=lambda entry: entry.qualified_name)
def get_resource(self, qualified_name: str) -> CatalogResourceEntry: def get_resource(self, qualified_name: str) -> CatalogResourceEntry:
entry = self.get_catalog().find_resource(qualified_name) return self.source_catalog.get_resource(qualified_name)
if entry is None:
raise KeyError(f"unknown resource {qualified_name!r}")
return entry
def get_prompt(self, qualified_name: str) -> CatalogPromptEntry: def get_prompt(self, qualified_name: str) -> CatalogPromptEntry:
entry = self.get_catalog().find_prompt(qualified_name) return self.source_catalog.get_prompt(qualified_name)
if entry is None:
raise KeyError(f"unknown prompt {qualified_name!r}")
return entry
async def read_resource(self, qualified_name: str) -> dict[str, Any]: async def read_resource(self, qualified_name: str) -> dict[str, Any]:
local_resource = self._local_documentation_resource(qualified_name) local_resource = self.source_catalog.local_documentation_resource(
qualified_name
)
if local_resource is not None: if local_resource is not None:
self._record_event( self._record_event(
make_event( make_event(
@@ -485,7 +353,7 @@ class WfMcpService:
*, *,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
local_prompt = self._local_documentation_prompt(qualified_name) local_prompt = self.source_catalog.local_documentation_prompt(qualified_name)
if local_prompt is not None: if local_prompt is not None:
self._record_event( self._record_event(
make_event( make_event(
@@ -538,28 +406,6 @@ class WfMcpService:
) )
return result return result
def _local_documentation_resource(
self,
qualified_name: str,
) -> DocumentationResource | None:
"""Return a local docs resource from capability sources by qualified name."""
for source in self.capability_sources.values():
resource = source.capabilities.resources.get(qualified_name)
if isinstance(resource, DocumentationResource):
return resource
return None
def _local_documentation_prompt(
self,
qualified_name: str,
) -> DocumentationPrompt | None:
"""Return a local docs prompt from capability sources by qualified name."""
for source in self.capability_sources.values():
prompt = source.capabilities.prompts.get(qualified_name)
if isinstance(prompt, DocumentationPrompt):
return prompt
return None
async def refresh_connection_catalog( async def refresh_connection_catalog(
self, self,
connection_id: str, connection_id: str,
@@ -814,85 +660,8 @@ class WfMcpService:
"""Register a capability source as canonical service state.""" """Register a capability source as canonical service state."""
self.capability_sources[source.id] = source self.capability_sources[source.id] = source
def _hydrate_connection_source_from_snapshot(
self,
connection: ConnectionConfig,
) -> None:
"""Register one connection source, hydrating specs from snapshot if present."""
if connection.id in self.capability_sources:
return
snapshot = self.store.load_catalog(connection.id)
specs = {
entry.qualified_name: self._spec_from_snapshot_entry(entry)
for entry in (() if snapshot is None else snapshot.nodes)
}
description = (
f"Specs restored from catalog for {connection.id}."
if specs
else f"No catalog loaded for {connection.id}."
)
self.register_capability_source(
CapabilitySource(
id=connection.id,
kind="connection",
enabled=connection.enabled,
capabilities=CapabilityBuckets(node_specs=specs),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(calls_upstream=True),
description=description,
)
)
def _spec_from_snapshot_entry(
self,
entry: CatalogNodeEntry,
) -> NodeSpec[Any, Any]:
"""Rebuild an executable tool wrapper from a stored catalog node entry.
Snapshot entries store schema/name metadata, not Python functions. This
helper reconstructs the same generated NodeSpec shape and routes calls
through `_tool_executor_for()`, so hydrated specs use the persistent MCP
runtime when the service has one configured.
"""
model_prefix = entry.qualified_name.replace(".", "_").replace("-", "_")
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
output_schema = entry.output_schema
output_model = _model_from_schema(f"{model_prefix}_Output", output_schema)
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
connection = self.connections.get(entry.connection_id)
auth = self.load_auth(entry.connection_id)
result = await self._tool_executor_for(connection).call_tool(
connection,
auth,
entry.local_name,
payload.model_dump(exclude_unset=True),
)
return NodeReturn(
outcome=result.outcome,
output=output_model.model_validate(result.output),
)
return NodeSpec(
name=entry.qualified_name,
input_model=input_model,
output_model=output_model,
outcomes=entry.outcomes,
fn=invoke_tool,
description=entry.description,
is_async=True,
accepts_context=False,
input_schema_contract=entry.input_schema,
output_schema_contract=output_schema,
)
def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]: def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
return get_qualified_spec(self.capability_sources, qualified_name) return self.source_catalog.get_qualified_spec(qualified_name)
def _record_event(self, event: McpEvent) -> None: def _record_event(self, event: McpEvent) -> None:
self.event_bus.publish(event) self.event_bus.publish(event)
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel
from wf_authoring import NodeReturn, NodeSpec
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
DocumentationPrompt,
DocumentationResource,
SourcePermissions,
SourceVisibility,
page_items,
)
from ...connections import ConnectionConfig, qualify_node_name
from ...events import McpEvent, make_event
from ...models import (
AuthRecord,
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
CatalogSnapshot,
)
from ...runtime import ToolExecutor
from ...storage import Store
from ...workflow.wrappers import _model_from_schema
from ..catalog import CombinedCatalog, snapshot_from_specs
from .specs import get_qualified_spec, qualify_spec
ConnectionLookup = Callable[[str], ConnectionConfig]
ConnectionList = Callable[[], list[ConnectionConfig]]
ToolExecutorLookup = Callable[[ConnectionConfig], ToolExecutor]
AuthLoader = Callable[[str], AuthRecord | None]
EventEmitter = Callable[[McpEvent], None]
@dataclass(slots=True)
class SourceCatalogService:
"""Own service-local capability sources and catalog projections.
This is deliberately still MCP-broker-internal. It knows about stored MCP
catalog snapshots because hydrated workflow NodeSpecs must call back through
the broker's configured tool executor.
"""
store: Store
connection_lookup: ConnectionLookup
connection_list_enabled: ConnectionList
connection_list_all: ConnectionList
tool_executor_for: ToolExecutorLookup
load_auth: AuthLoader
emit_event: EventEmitter
default_catalog_max_age_seconds: int = 300
capability_sources: dict[str, CapabilitySource] = field(default_factory=dict)
def register_capability_source(self, source: CapabilitySource) -> None:
"""Register one source as canonical planner/runtime source state."""
self.capability_sources[source.id] = source
def get_catalog(self) -> CombinedCatalog:
snapshots: dict[str, CatalogSnapshot] = {}
for connection in self.connection_list_enabled():
snapshot = self.store.load_catalog(connection.id)
if snapshot is not None:
snapshots[connection.id] = snapshot
return CombinedCatalog(snapshots=snapshots)
def get_planner_catalog(self) -> CombinedCatalog:
"""Return all planner-visible specs, including broker-local sources."""
snapshots: dict[str, CatalogSnapshot] = {}
fetched_at_epoch_ms = int(time.time() * 1000)
for source in self.capability_sources.values():
if not source.enabled or not source.visibility.planner:
continue
stored_snapshot = self.store.load_catalog(source.id)
snapshots[source.id] = snapshot_from_specs(
source.id,
specs=source.capabilities.node_specs,
tool_display_names={
entry.local_name: entry.title for entry in stored_snapshot.nodes
}
if stored_snapshot is not None
else None,
metadata={
"kind": source.kind,
"description": source.description,
}
if stored_snapshot is None
else stored_snapshot.metadata,
fetched_at_epoch_ms=(
stored_snapshot.fetched_at_epoch_ms
if stored_snapshot is not None
else fetched_at_epoch_ms
),
max_age_seconds=(
stored_snapshot.max_age_seconds
if stored_snapshot is not None
else self.default_catalog_max_age_seconds
),
)
if stored_snapshot is not None:
snapshots[source.id].resources = list(stored_snapshot.resources)
snapshots[source.id].prompts = list(stored_snapshot.prompts)
return CombinedCatalog(snapshots=snapshots)
def list_sources(self) -> list[dict[str, Any]]:
"""Return every capability source with the names it currently owns."""
return [
source.as_inventory().model_dump(mode="json")
for source in sorted(
self.capability_sources.values(),
key=lambda source: source.id,
)
]
def list_source_summaries(
self,
*,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""Return compact paged source summaries for progressive discovery."""
summaries = [
source.as_status().model_dump(mode="json")
for source in sorted(
self.capability_sources.values(),
key=lambda source: source.id,
)
]
page = page_items(summaries, cursor=cursor, limit=limit)
return {
"sources": list(page.items),
"next_cursor": page.next_cursor,
"total": page.total,
}
def inspect_source(self, source_id: str) -> dict[str, Any]:
"""Return the full source inventory for one exact source id."""
try:
source = self.capability_sources[source_id]
except KeyError as exc:
raise KeyError(f"unknown source {source_id!r}") from exc
return source.as_inventory().model_dump(mode="json")
def list_available_specs(self) -> list[CatalogNodeEntry]:
"""Return planner-visible node catalog entries from every visible source."""
return self.get_planner_catalog().entries()
def get_connection_snapshot(self, connection_id: str) -> CatalogSnapshot | None:
self.connection_lookup(connection_id)
return self.store.load_catalog(connection_id)
def connection_statuses(self) -> list[dict[str, Any]]:
statuses: list[dict[str, Any]] = []
for connection in self.connection_list_all():
snapshot = self.store.load_catalog(connection.id)
statuses.append(
{
"connection_id": connection.id,
"server": connection.server,
"account": connection.account,
"enabled": connection.enabled,
"has_snapshot": snapshot is not None,
"fetched_at_epoch_ms": None
if snapshot is None
else snapshot.fetched_at_epoch_ms,
"max_age_seconds": None
if snapshot is None
else snapshot.max_age_seconds,
"node_count": 0 if snapshot is None else len(snapshot.nodes),
"resource_count": 0
if snapshot is None
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
return statuses
def list_resources(
self,
*,
connection_id: str | None = None,
) -> list[CatalogResourceEntry]:
if connection_id is None:
return self.get_catalog().resource_entries()
snapshot = self.get_connection_snapshot(connection_id)
if snapshot is None:
return []
return sorted(snapshot.resources, key=lambda entry: entry.qualified_name)
def list_prompts(
self,
*,
connection_id: str | None = None,
) -> list[CatalogPromptEntry]:
if connection_id is None:
return self.get_catalog().prompt_entries()
snapshot = self.get_connection_snapshot(connection_id)
if snapshot is None:
return []
return sorted(snapshot.prompts, key=lambda entry: entry.qualified_name)
def get_resource(self, qualified_name: str) -> CatalogResourceEntry:
entry = self.get_catalog().find_resource(qualified_name)
if entry is None:
raise KeyError(f"unknown resource {qualified_name!r}")
return entry
def get_prompt(self, qualified_name: str) -> CatalogPromptEntry:
entry = self.get_catalog().find_prompt(qualified_name)
if entry is None:
raise KeyError(f"unknown prompt {qualified_name!r}")
return entry
def hydrate_connection_source_from_snapshot(
self,
connection: ConnectionConfig,
) -> None:
"""Register one connection source, hydrating specs from snapshot if present."""
if connection.id in self.capability_sources:
return
snapshot = self.store.load_catalog(connection.id)
specs = {
entry.qualified_name: self.spec_from_snapshot_entry(entry)
for entry in (() if snapshot is None else snapshot.nodes)
}
description = (
f"Specs restored from catalog for {connection.id}."
if specs
else f"No catalog loaded for {connection.id}."
)
self.register_capability_source(
CapabilitySource(
id=connection.id,
kind="connection",
enabled=connection.enabled,
capabilities=CapabilityBuckets(node_specs=specs),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(calls_upstream=True),
description=description,
)
)
def spec_from_snapshot_entry(
self,
entry: CatalogNodeEntry,
) -> NodeSpec[Any, Any]:
"""Rebuild an executable tool wrapper from a stored catalog node entry.
Snapshot entries store schema/name metadata, not Python functions. This
helper reconstructs the same generated NodeSpec shape and routes calls
through `tool_executor_for()`, so hydrated specs use the persistent MCP
runtime when the service has one configured.
"""
model_prefix = entry.qualified_name.replace(".", "_").replace("-", "_")
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
output_schema = entry.output_schema
output_model = _model_from_schema(f"{model_prefix}_Output", output_schema)
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
connection = self.connection_lookup(entry.connection_id)
auth = self.load_auth(entry.connection_id)
result = await self.tool_executor_for(connection).call_tool(
connection,
auth,
entry.local_name,
payload.model_dump(exclude_unset=True),
)
return NodeReturn(
outcome=result.outcome,
output=output_model.model_validate(result.output),
)
return NodeSpec(
name=entry.qualified_name,
input_model=input_model,
output_model=output_model,
outcomes=entry.outcomes,
fn=invoke_tool,
description=entry.description,
is_async=True,
accepts_context=False,
input_schema_contract=entry.input_schema,
output_schema_contract=output_schema,
)
def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
return get_qualified_spec(self.capability_sources, qualified_name)
def register_specs(
self,
connection_id: str,
*specs: NodeSpec[Any, Any],
max_age_seconds: int | None = None,
emit_change_events: bool = True,
record_catalog_change_events: Callable[
[str, CatalogSnapshot, str],
None,
]
| None = None,
) -> CatalogSnapshot:
self.connection_lookup(connection_id)
qualified_specs = {
qualify_node_name(connection_id, spec.name): qualify_spec(
connection_id, spec
)
for spec in specs
}
existing_source = self.capability_sources.get(connection_id)
if existing_source is not None:
existing_source.capabilities.node_specs = qualified_specs
else:
self.register_capability_source(
CapabilitySource(
id=connection_id,
kind="connection",
capabilities=CapabilityBuckets(node_specs=qualified_specs),
enabled=self.connection_lookup(connection_id).enabled,
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(calls_upstream=True),
description=(
f"Specs discovered or registered for {connection_id}."
),
)
)
snapshot = snapshot_from_specs(
connection_id,
specs=qualified_specs,
fetched_at_epoch_ms=int(time.time() * 1000),
max_age_seconds=max_age_seconds or self.default_catalog_max_age_seconds,
)
self.store.save_catalog(snapshot)
self.emit_event(
make_event(
"specs_registered",
connection_id=connection_id,
payload={"node_count": len(qualified_specs)},
)
)
if emit_change_events and record_catalog_change_events is not None:
record_catalog_change_events(connection_id, snapshot, "specs_registered")
return snapshot
def local_documentation_resource(
self,
qualified_name: str,
) -> DocumentationResource | None:
"""Return a local docs resource from capability sources by qualified name."""
for source in self.capability_sources.values():
resource = source.capabilities.resources.get(qualified_name)
if isinstance(resource, DocumentationResource):
return resource
return None
def local_documentation_prompt(
self,
qualified_name: str,
) -> DocumentationPrompt | None:
"""Return a local docs prompt from capability sources by qualified name."""
for source in self.capability_sources.values():
prompt = source.capabilities.prompts.get(qualified_name)
if isinstance(prompt, DocumentationPrompt):
return prompt
return None
@@ -70,7 +70,7 @@ async def live_source_diagnostics(
""" """
diagnostics: list[DependencyDiagnostic] = [] diagnostics: list[DependencyDiagnostic] = []
for source_id, logical_ref in _required_live_sources(deployment, artifacts).items(): for source_id, logical_ref in _required_live_sources(deployment, artifacts).items():
source = service.capability_sources.get(source_id) source = service.source_catalog.capability_sources.get(source_id)
if ( if (
source is None source is None
or not source.enabled or not source.enabled
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from wf_artifacts import DependencyDiagnostic, WorkflowArtifact, WorkflowDeployment from wf_artifacts import DependencyDiagnostic, WorkflowArtifact, WorkflowDeployment
from wf_authoring import NodeSpec
from wf_api.operation_context import ( from wf_api.operation_context import (
WorkflowArtifactCataloger, WorkflowArtifactCataloger,
WorkflowEventRecorder, WorkflowEventRecorder,
@@ -48,10 +49,10 @@ class WfMcpWorkflowSpecProvider(WorkflowSpecProvider):
@property @property
def capability_sources(self): def capability_sources(self):
return self.service.capability_sources return self.service.source_catalog.capability_sources
def get_qualified_spec(self, qualified_name: str) -> object: def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
return self.service._get_qualified_spec(qualified_name) # noqa: SLF001 return self.service.source_catalog.get_qualified_spec(qualified_name)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
+14
View File
@@ -2,11 +2,25 @@ from __future__ import annotations
import ast import ast
import json import json
import tempfile
from pathlib import Path from pathlib import Path
from wf_api.operation_context import WorkflowOperationContext from wf_api.operation_context import WorkflowOperationContext
from wf_cli.context import load_cli_context from wf_cli.context import load_cli_context
from wf_mcp.broker import WfMcpService
from wf_mcp.broker.service.workflow_operation_context import context_from_service from wf_mcp.broker.service.workflow_operation_context import context_from_service
from wf_mcp.storage import FileStore
def _local_temp_root() -> Path:
return Path(tempfile.mkdtemp())
def test_context_uses_source_catalog_mapping() -> None:
service = WfMcpService(store=FileStore(_local_temp_root() / "context_sources"))
context = context_from_service(service)
assert context.capability_sources is service.source_catalog.capability_sources
def test_wf_api_operation_context_imports_no_wf_mcp() -> None: def test_wf_api_operation_context_imports_no_wf_mcp() -> None:
+205
View File
@@ -6,11 +6,13 @@ import shutil
from wf_authoring import NodeSpec from wf_authoring import NodeSpec
from wf_core import RunStatus from wf_core import RunStatus
from wf_mcp.broker import WfMcpService from wf_mcp.broker import WfMcpService
from wf_mcp.broker.service.source_catalog import SourceCatalogService
from wf_mcp.models import ConnectionConfig from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_platform import ( from wf_platform import (
CapabilityBuckets, CapabilityBuckets,
CapabilitySource, CapabilitySource,
DocumentationResource,
SourceVisibility, SourceVisibility,
) )
@@ -296,3 +298,206 @@ def test_service_hydrates_planner_specs_from_stored_catalog() -> None:
assert "demo.personal.echo_tool" in planner_names assert "demo.personal.echo_tool" in planner_names
assert run.status == RunStatus.COMPLETED assert run.status == RunStatus.COMPLETED
assert run.output["echoed"] == "hello" assert run.output["echoed"] == "hello"
def test_source_catalog_service_registers_and_lists_sources_directly() -> None:
store = FileStore(local_temp_root() / "source_catalog_direct")
def unused_tool_executor(connection: ConnectionConfig):
raise AssertionError("tool executor should not be used by source listing")
catalog = SourceCatalogService(
store=store,
connection_lookup=lambda connection_id: ConnectionConfig(
id=connection_id,
server="demo",
account="personal",
),
connection_list_enabled=lambda: [],
connection_list_all=lambda: [],
tool_executor_for=unused_tool_executor,
load_auth=lambda connection_id: None,
emit_event=lambda event: None,
)
catalog.register_capability_source(
CapabilitySource(
id="demo.personal",
kind="connection",
capabilities=CapabilityBuckets(),
visibility=SourceVisibility(planner=True),
)
)
payload = catalog.list_source_summaries(limit=10)
assert payload["total"] == 1
assert payload["sources"][0]["id"] == "demo.personal"
def test_wfmcpservice_capability_sources_proxy_source_catalog() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "source_catalog_proxy"))
assert service.capability_sources is service.source_catalog.capability_sources
assert "wf.std" in service.source_catalog.capability_sources
def test_source_catalog_service_excludes_hidden_sources_from_planner_catalog() -> None:
def unused_tool_executor(connection: ConnectionConfig):
raise AssertionError("tool executor should not be used by planner listing")
catalog = SourceCatalogService(
store=FileStore(local_temp_root() / "source_catalog_hidden"),
connection_lookup=lambda connection_id: ConnectionConfig(
id=connection_id,
server="demo",
account="personal",
),
connection_list_enabled=lambda: [],
connection_list_all=lambda: [],
tool_executor_for=unused_tool_executor,
load_auth=lambda connection_id: None,
emit_event=lambda event: None,
)
visible_tool = NodeSpec(
name="visible.source.echo_tool",
input_model=echo_tool.input_model,
output_model=echo_tool.output_model,
outcomes=echo_tool.outcomes,
fn=echo_tool.fn,
description=echo_tool.description,
is_async=echo_tool.is_async,
accepts_context=echo_tool.accepts_context,
input_schema_contract=echo_tool.input_schema_contract,
output_schema_contract=echo_tool.output_schema_contract,
)
hidden_tool = NodeSpec(
name="hidden.source.echo_tool",
input_model=echo_tool.input_model,
output_model=echo_tool.output_model,
outcomes=echo_tool.outcomes,
fn=echo_tool.fn,
description=echo_tool.description,
is_async=echo_tool.is_async,
accepts_context=echo_tool.accepts_context,
input_schema_contract=echo_tool.input_schema_contract,
output_schema_contract=echo_tool.output_schema_contract,
)
catalog.register_capability_source(
CapabilitySource(
id="visible.source",
kind="system",
capabilities=CapabilityBuckets(
node_specs={"visible.source.echo_tool": visible_tool}
),
visibility=SourceVisibility(planner=True),
)
)
catalog.register_capability_source(
CapabilitySource(
id="hidden.source",
kind="system",
capabilities=CapabilityBuckets(
node_specs={"hidden.source.echo_tool": hidden_tool}
),
visibility=SourceVisibility(planner=False, admin_dashboard=False),
)
)
planner_names = {
entry.qualified_name for entry in catalog.get_planner_catalog().entries()
}
assert "visible.source.echo_tool" in planner_names
assert "hidden.source.echo_tool" not in planner_names
def test_source_catalog_hydrates_connection_source_from_snapshot_directly() -> None:
root = local_temp_root() / "source_catalog_hydrate_direct"
shutil.rmtree(root, ignore_errors=True)
first_service = WfMcpService(store=FileStore(root))
first_service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
first_service.register_adapter("demo", FakeAdapter())
asyncio.run(first_service.refresh_connection_catalog("demo.personal"))
second_service = WfMcpService(store=FileStore(root))
second_service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
specs = second_service.source_catalog.capability_sources[
"demo.personal"
].capabilities.node_specs
assert "demo.personal.echo_tool" in specs
def test_source_catalog_register_specs_replaces_discovered_specs_directly() -> None:
connection = ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
)
def unused_tool_executor(connection: ConnectionConfig):
raise AssertionError("tool executor should not be used by spec registration")
catalog = SourceCatalogService(
store=FileStore(local_temp_root() / "source_catalog_register_specs"),
connection_lookup=lambda connection_id: connection,
connection_list_enabled=lambda: [connection],
connection_list_all=lambda: [connection],
tool_executor_for=unused_tool_executor,
load_auth=lambda connection_id: None,
emit_event=lambda event: None,
)
catalog.register_capability_source(
CapabilitySource(
id="demo.personal",
kind="connection",
capabilities=CapabilityBuckets(
node_specs={"demo.personal.finalize_tool": finalize_tool}
),
visibility=SourceVisibility(planner=True),
)
)
assert "demo.personal.finalize_tool" in (
catalog.capability_sources["demo.personal"].capabilities.node_specs
)
catalog.register_specs("demo.personal", echo_tool)
specs = catalog.capability_sources["demo.personal"].capabilities.node_specs
assert set(specs) == {"demo.personal.echo_tool"}
assert catalog.store.load_catalog("demo.personal") is not None
def test_source_catalog_finds_local_documentation_resource_directly() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "source_local_docs"))
test_resource = DocumentationResource(
name="test.docs.example",
uri="wf://docs/example",
title="Example Doc",
description="Test documentation resource.",
mime_type="text/markdown",
text="# Example",
)
service.register_capability_source(
CapabilitySource(
id="test.docs",
kind="system",
capabilities=CapabilityBuckets(
resources={"test.docs.example": test_resource}
),
visibility=SourceVisibility(planner=True),
)
)
result = service.source_catalog.local_documentation_resource("test.docs.example")
assert result is not None
assert result.uri == test_resource.uri