content access
This commit is contained in:
@@ -34,56 +34,44 @@ The remaining class still has two kinds of code:
|
|||||||
| `save_auth`, `load_auth` | Delegate to `UpstreamTransportService` | Compatibility facade | Keep until admin/control surfaces use upstream service directly. |
|
| `save_auth`, `load_auth` | Delegate to `UpstreamTransportService` | Compatibility facade | Keep until admin/control surfaces use upstream service directly. |
|
||||||
| `register_specs` | Delegates to `SourceCatalogService` with event fanout callback | Thin coordinator | Acceptable for now; later expose `source_catalog.register_specs(..., record_catalog_change_events=events.record_catalog_change_events)` through a smaller service bundle. |
|
| `register_specs` | Delegates to `SourceCatalogService` with event fanout callback | Thin coordinator | Acceptable for now; later expose `source_catalog.register_specs(..., record_catalog_change_events=events.record_catalog_change_events)` through a smaller service bundle. |
|
||||||
| Catalog/source list/inspect methods | Delegate to `SourceCatalogService` | Compatibility facade | Keep while admin/MCP surfaces use `WfMcpService`; new code should call `source_catalog`. |
|
| Catalog/source list/inspect methods | Delegate to `SourceCatalogService` | Compatibility facade | Keep while admin/MCP surfaces use `WfMcpService`; new code should call `source_catalog`. |
|
||||||
| `workflow_artifact_catalog_entry` | Calls pure `wf_artifacts.artifact_catalog_entry()` | Misplaced pure projection | Move this out of `WfMcpService`/operation context later; `wf_api` can call `wf_artifacts` directly or use a small artifact projection protocol. |
|
| `workflow_artifact_catalog_entry` | Removed from `WfMcpService` | Completed cleanup | `wf_api.artifacts` now calls `wf_artifacts.artifact_catalog_entry()` directly. |
|
||||||
| `read_resource` | Handles local docs special-case, records event, else forwards upstream | Mixed real responsibility | Extract next into a focused resource/prompt access service. |
|
| `read_resource` | Delegates to `ContentAccessService` | Compatibility facade | Keep for admin/MCP callers; new code should call `content_access`. |
|
||||||
| `invoke_method`, `send_notification` | Lookup connection, forward upstream | Compatibility facade | Could move with resource/prompt access or leave as upstream delegates. |
|
| `invoke_method`, `send_notification` | Lookup connection, forward upstream | Compatibility facade | Could move with resource/prompt access or leave as upstream delegates. |
|
||||||
| `render_prompt` | Handles local docs special-case, records event, else forwards upstream | Mixed real responsibility | Extract next with `read_resource`. |
|
| `render_prompt` | Delegates to `ContentAccessService` | Compatibility facade | Keep for admin/MCP callers; new code should call `content_access`. |
|
||||||
| `refresh_connection_catalog` | Lookup connection, call upstream refresh with source/event callbacks | Thin coordinator | Could move to `UpstreamTransportService` if it accepts connection id + connection lookup, or stay as facade. Not as urgent as resources/prompts. |
|
| `refresh_connection_catalog` | Lookup connection, call upstream refresh with source/event callbacks | Thin coordinator | Could move to `UpstreamTransportService` if it accepts connection id + connection lookup, or stay as facade. Not as urgent as resources/prompts. |
|
||||||
| Runtime methods | Delegate to `WorkflowRuntimeService` | Compatibility facade | Keep while `wf_api` runtime adapter points through service; new internals should use `workflow_runtime`. |
|
| Runtime methods | Delegate to `WorkflowRuntimeService` | Compatibility facade | Keep while `wf_api` runtime adapter points through service; new internals should use `workflow_runtime`. |
|
||||||
| `list_events` | Delegates to `BrokerEventRecorder` | Compatibility facade | Keep for admin/tests; new code should call `events`. |
|
| `list_events` | Delegates to `BrokerEventRecorder` | Compatibility facade | Keep for admin/tests; new code should call `events`. |
|
||||||
| `register_capability_source` | Directly mutates `source_catalog.capability_sources` | Compatibility facade | Replace implementation with `source_catalog.register_capability_source(source)` to make ownership explicit. |
|
| `register_capability_source` | Delegates to `SourceCatalogService` | Compatibility facade | Keep for old service callers; source ownership is explicit. |
|
||||||
| `_get_qualified_spec` | Delegates to `SourceCatalogService` | Compatibility/private shim | Keep only for tests/legacy paths; new code should call `source_catalog.get_qualified_spec`. |
|
| `_get_qualified_spec` | Delegates to `SourceCatalogService` | Compatibility/private shim | Keep only for tests/legacy paths; new code should call `source_catalog.get_qualified_spec`. |
|
||||||
| `_record_event`, `_record_catalog_change_events` | Delegate to `BrokerEventRecorder` | Compatibility/private shim | Remove when remaining `WfMcpService` methods stop needing local event helpers. |
|
| `_record_event`, `_record_catalog_change_events` | Delegate to `BrokerEventRecorder` | Compatibility/private shim | Remove when remaining `WfMcpService` methods stop needing local event helpers. |
|
||||||
|
|
||||||
## Best Next Slice
|
## Best Next Slice (Completed)
|
||||||
|
|
||||||
Extract a resource/prompt access service, tentatively named
|
Extract a resource/prompt access service, tentatively named
|
||||||
`BrokerContentAccessService` or `ResourcePromptAccessService`.
|
`BrokerContentAccessService` or `ResourcePromptAccessService`.
|
||||||
|
|
||||||
Why this is next:
|
**Done:** `ContentAccessService` now owns `read_resource` and `render_prompt`.
|
||||||
|
`WfMcpService` delegates both methods. See plan:
|
||||||
- `read_resource` and `render_prompt` are the last methods with real branching logic in
|
`docs/superpowers/plans/2026-06-02-wfmcpservice-resource-prompt-access-extraction.md`.
|
||||||
`WfMcpService`.
|
|
||||||
- They combine three dependencies: `SourceCatalogService`, `ConnectionService`, and
|
|
||||||
`UpstreamTransportService`.
|
|
||||||
- They also record local documentation events directly. That makes the boundary useful
|
|
||||||
and testable, not just mechanical delegation.
|
|
||||||
|
|
||||||
Proposed ownership:
|
|
||||||
|
|
||||||
- `read_resource(qualified_name) -> dict[str, Any]`
|
|
||||||
- `render_prompt(qualified_name, arguments=None) -> dict[str, Any]`
|
|
||||||
- Optional: `invoke_method` and `send_notification` if the slice wants all
|
|
||||||
connection-id-to-upstream forwarding in one place.
|
|
||||||
|
|
||||||
Keep out of scope for that slice:
|
Keep out of scope for that slice:
|
||||||
|
|
||||||
- Catalog refresh. It has different semantics and should stay with upstream/catalog
|
- Catalog refresh. It has different semantics and should stay with upstream/catalog
|
||||||
coordination until there is a clearer need.
|
coordination until there is a clearer need.
|
||||||
- Workflow runtime methods. They are already clean delegates.
|
- Workflow runtime methods. They are already clean delegates.
|
||||||
- `workflow_artifact_catalog_entry`. It is not broker content access; handle it in a
|
- `workflow_artifact_catalog_entry`. Completed: `wf_api.artifacts` now calls the pure
|
||||||
separate small cleanup.
|
`wf_artifacts` projection directly.
|
||||||
|
|
||||||
## Small Cleanup Before Or After Next Slice
|
## Small Cleanup Before Or After Next Slice
|
||||||
|
|
||||||
These are safe, low-risk cleanups:
|
These are safe, low-risk cleanups:
|
||||||
|
|
||||||
- Change `register_capability_source()` to delegate to
|
- Completed: `register_capability_source()` delegates to
|
||||||
`self.source_catalog.register_capability_source(source)` instead of mutating the dict.
|
`self.source_catalog.register_capability_source(source)` instead of mutating the dict.
|
||||||
- Move `workflow_artifact_catalog_entry()` out of `WfMcpService` if the `wf_api`
|
- Completed: `workflow_artifact_catalog_entry()` was removed from `WfMcpService`;
|
||||||
operation context can call `wf_artifacts.artifact_catalog_entry()` directly.
|
`wf_api.artifacts` calls `wf_artifacts.artifact_catalog_entry()` directly.
|
||||||
- Add a docstring to `WfMcpService` itself stating that it is now a compatibility
|
- Completed: `WfMcpService` now has a class docstring stating that it is a
|
||||||
coordinator around focused broker services.
|
compatibility coordinator around focused broker services.
|
||||||
|
|
||||||
## Durable API Implication
|
## Durable API Implication
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from .wrapper_hints import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .operation_context import (
|
from .operation_context import (
|
||||||
WorkflowArtifactCataloger,
|
|
||||||
WorkflowEventRecorder,
|
WorkflowEventRecorder,
|
||||||
WorkflowLiveSourceChecker,
|
WorkflowLiveSourceChecker,
|
||||||
WorkflowOperationContext,
|
WorkflowOperationContext,
|
||||||
@@ -62,7 +61,6 @@ __all__ = [
|
|||||||
"WorkflowApi",
|
"WorkflowApi",
|
||||||
"WorkflowArtifactApi",
|
"WorkflowArtifactApi",
|
||||||
"WorkflowCapabilityApi",
|
"WorkflowCapabilityApi",
|
||||||
"WorkflowArtifactCataloger",
|
|
||||||
"WorkflowDeploymentApi",
|
"WorkflowDeploymentApi",
|
||||||
"WorkflowDraftApi",
|
"WorkflowDraftApi",
|
||||||
"WorkflowEventRecorder",
|
"WorkflowEventRecorder",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from wf_artifacts import (
|
|||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
RequiredCapability,
|
RequiredCapability,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
|
artifact_catalog_entry,
|
||||||
create_workflow_artifact_from_plan as build_workflow_artifact_from_plan,
|
create_workflow_artifact_from_plan as build_workflow_artifact_from_plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,9 +57,7 @@ class WorkflowArtifactApi:
|
|||||||
if self.context.artifact_store is None:
|
if self.context.artifact_store is None:
|
||||||
return paged_list_payload("nodes", [], cursor=cursor, limit=limit)
|
return paged_list_payload("nodes", [], cursor=cursor, limit=limit)
|
||||||
entries = [
|
entries = [
|
||||||
self.context.artifacts.workflow_artifact_catalog_entry(artifact).model_dump(
|
artifact_catalog_entry(artifact).model_dump(mode="json")
|
||||||
mode="json"
|
|
||||||
)
|
|
||||||
for artifact in self.context.artifact_store.list_artifacts()
|
for artifact in self.context.artifact_store.list_artifacts()
|
||||||
if kind is None or artifact.kind == kind
|
if kind is None or artifact.kind == kind
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from wf_artifacts import (
|
|||||||
DraftWorkspaceStore,
|
DraftWorkspaceStore,
|
||||||
RunStore,
|
RunStore,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowArtifactCatalogEntry,
|
|
||||||
WorkflowArtifactStore,
|
WorkflowArtifactStore,
|
||||||
WorkflowDeployment,
|
WorkflowDeployment,
|
||||||
)
|
)
|
||||||
@@ -52,16 +51,6 @@ class WorkflowSpecProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class WorkflowArtifactCataloger(Protocol):
|
|
||||||
"""Formats saved workflow artifacts for list/detail surfaces."""
|
|
||||||
|
|
||||||
def workflow_artifact_catalog_entry(
|
|
||||||
self, artifact: WorkflowArtifact
|
|
||||||
) -> WorkflowArtifactCatalogEntry:
|
|
||||||
"""Return the catalog entry representation for one saved artifact."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRuntimeRunner(Protocol):
|
class WorkflowRuntimeRunner(Protocol):
|
||||||
"""Runs and resumes workflow plans using an adapter-owned runtime backend."""
|
"""Runs and resumes workflow plans using an adapter-owned runtime backend."""
|
||||||
|
|
||||||
@@ -119,13 +108,11 @@ class WorkflowOperationContext:
|
|||||||
capability_sources: Mapping[str, CapabilitySource]
|
capability_sources: Mapping[str, CapabilitySource]
|
||||||
events: WorkflowEventRecorder
|
events: WorkflowEventRecorder
|
||||||
specs: WorkflowSpecProvider
|
specs: WorkflowSpecProvider
|
||||||
artifacts: WorkflowArtifactCataloger
|
|
||||||
runtime: WorkflowRuntimeRunner
|
runtime: WorkflowRuntimeRunner
|
||||||
live_sources: WorkflowLiveSourceChecker | None = None
|
live_sources: WorkflowLiveSourceChecker | None = None
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"WorkflowArtifactCataloger",
|
|
||||||
"WorkflowEventRecorder",
|
"WorkflowEventRecorder",
|
||||||
"WorkflowLiveSourceChecker",
|
"WorkflowLiveSourceChecker",
|
||||||
"WorkflowOperationContext",
|
"WorkflowOperationContext",
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wf_mcp.events import McpEvent, make_event
|
||||||
|
|
||||||
|
from .connection_service import ConnectionService
|
||||||
|
from .source_catalog import SourceCatalogService
|
||||||
|
from .upstream_transport import UpstreamTransportService
|
||||||
|
|
||||||
|
EventSink = Callable[[McpEvent], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ContentAccessService:
|
||||||
|
"""Own local-docs-aware resource reads and prompt renders for the broker.
|
||||||
|
|
||||||
|
This service checks SourceCatalogService for local documentation entries
|
||||||
|
before falling back to upstream transport. It is broker-internal, not a
|
||||||
|
protocol-neutral content API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_catalog: SourceCatalogService
|
||||||
|
upstream: UpstreamTransportService
|
||||||
|
connection_service: ConnectionService
|
||||||
|
event_sink: EventSink
|
||||||
|
|
||||||
|
async def read_resource(self, qualified_name: str) -> dict[str, Any]:
|
||||||
|
local_resource = self.source_catalog.local_documentation_resource(
|
||||||
|
qualified_name
|
||||||
|
)
|
||||||
|
if local_resource is not None:
|
||||||
|
self.event_sink(
|
||||||
|
make_event(
|
||||||
|
"resource_read_completed",
|
||||||
|
capability_id=qualified_name,
|
||||||
|
payload={"uri": local_resource.uri, "source": "local"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"uri": local_resource.uri,
|
||||||
|
"mimeType": local_resource.mime_type,
|
||||||
|
"text": local_resource.text,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource = self.source_catalog.get_resource(qualified_name)
|
||||||
|
connection = self.connection_service.get(resource.connection_id)
|
||||||
|
return await self.upstream.read_resource(
|
||||||
|
connection,
|
||||||
|
qualified_name,
|
||||||
|
resource.uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def render_prompt(
|
||||||
|
self,
|
||||||
|
qualified_name: str,
|
||||||
|
*,
|
||||||
|
arguments: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
local_prompt = self.source_catalog.local_documentation_prompt(qualified_name)
|
||||||
|
if local_prompt is not None:
|
||||||
|
self.event_sink(
|
||||||
|
make_event(
|
||||||
|
"prompt_get_completed",
|
||||||
|
capability_id=qualified_name,
|
||||||
|
payload={
|
||||||
|
"argument_keys": sorted((arguments or {}).keys()),
|
||||||
|
"source": "local",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"description": local_prompt.description,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": {
|
||||||
|
"type": "text",
|
||||||
|
"text": local_prompt.text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = self.source_catalog.get_prompt(qualified_name)
|
||||||
|
connection = self.connection_service.get(prompt.connection_id)
|
||||||
|
return await self.upstream.render_prompt(
|
||||||
|
connection,
|
||||||
|
qualified_name,
|
||||||
|
prompt.local_name,
|
||||||
|
arguments,
|
||||||
|
)
|
||||||
@@ -7,10 +7,8 @@ from wf_artifacts import (
|
|||||||
DraftWorkspaceStore,
|
DraftWorkspaceStore,
|
||||||
RunStore,
|
RunStore,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowArtifactCatalogEntry,
|
|
||||||
WorkflowArtifactStore,
|
WorkflowArtifactStore,
|
||||||
WorkflowDeployment,
|
WorkflowDeployment,
|
||||||
artifact_catalog_entry,
|
|
||||||
)
|
)
|
||||||
from wf_authoring import NodeSpec
|
from wf_authoring import NodeSpec
|
||||||
from wf_core import (
|
from wf_core import (
|
||||||
@@ -22,7 +20,7 @@ from wf_platform import (
|
|||||||
CapabilitySource,
|
CapabilitySource,
|
||||||
)
|
)
|
||||||
from ...connections import ConnectionRegistry
|
from ...connections import ConnectionRegistry
|
||||||
from ...events import EventBus, McpEvent, make_event
|
from ...events import EventBus, McpEvent
|
||||||
from ...models import (
|
from ...models import (
|
||||||
AuthRecord,
|
AuthRecord,
|
||||||
CatalogNodeEntry,
|
CatalogNodeEntry,
|
||||||
@@ -35,6 +33,7 @@ from ...models import (
|
|||||||
from ...sdk import BackendAdapter
|
from ...sdk import BackendAdapter
|
||||||
from ...runtime import ToolExecutor
|
from ...runtime import ToolExecutor
|
||||||
from .connection_service import ConnectionService
|
from .connection_service import ConnectionService
|
||||||
|
from .content_access import ContentAccessService
|
||||||
from ...storage import Store
|
from ...storage import Store
|
||||||
from wf_api.saved_subgraphs import SavedSubgraphTree
|
from wf_api.saved_subgraphs import SavedSubgraphTree
|
||||||
from ..admin_capabilities import admin_source
|
from ..admin_capabilities import admin_source
|
||||||
@@ -48,6 +47,14 @@ from .workflow_runtime import WorkflowRuntimeService
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class WfMcpService:
|
class WfMcpService:
|
||||||
|
"""Compatibility coordinator around focused broker services.
|
||||||
|
|
||||||
|
New broker internals should prefer the focused service fields (`source_catalog`,
|
||||||
|
`connection_service`, `upstream`, `workflow_runtime`, `content_access`, and
|
||||||
|
`events`). This facade remains for existing admin, CLI, MCP, and tests that
|
||||||
|
still use the historical service surface.
|
||||||
|
"""
|
||||||
|
|
||||||
store: Store
|
store: Store
|
||||||
default_catalog_max_age_seconds: int = 300
|
default_catalog_max_age_seconds: int = 300
|
||||||
event_bus: EventBus = field(default_factory=EventBus)
|
event_bus: EventBus = field(default_factory=EventBus)
|
||||||
@@ -60,6 +67,7 @@ class WfMcpService:
|
|||||||
connection_service: ConnectionService = field(init=False)
|
connection_service: ConnectionService = field(init=False)
|
||||||
upstream: UpstreamTransportService = field(init=False)
|
upstream: UpstreamTransportService = field(init=False)
|
||||||
source_catalog: SourceCatalogService = field(init=False)
|
source_catalog: SourceCatalogService = field(init=False)
|
||||||
|
content_access: ContentAccessService = field(init=False)
|
||||||
workflow_runtime: WorkflowRuntimeService = field(init=False)
|
workflow_runtime: WorkflowRuntimeService = field(init=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@@ -87,6 +95,12 @@ class WfMcpService:
|
|||||||
default_catalog_max_age_seconds=self.default_catalog_max_age_seconds,
|
default_catalog_max_age_seconds=self.default_catalog_max_age_seconds,
|
||||||
)
|
)
|
||||||
self.connection_service.bind_source_catalog(self.source_catalog)
|
self.connection_service.bind_source_catalog(self.source_catalog)
|
||||||
|
self.content_access = ContentAccessService(
|
||||||
|
source_catalog=self.source_catalog,
|
||||||
|
upstream=self.upstream,
|
||||||
|
connection_service=self.connection_service,
|
||||||
|
event_sink=self.events.record_event,
|
||||||
|
)
|
||||||
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)
|
||||||
@@ -183,13 +197,6 @@ class WfMcpService:
|
|||||||
def list_available_specs(self) -> list[CatalogNodeEntry]:
|
def list_available_specs(self) -> list[CatalogNodeEntry]:
|
||||||
return self.source_catalog.list_available_specs()
|
return self.source_catalog.list_available_specs()
|
||||||
|
|
||||||
def workflow_artifact_catalog_entry(
|
|
||||||
self,
|
|
||||||
artifact: WorkflowArtifact,
|
|
||||||
) -> WorkflowArtifactCatalogEntry:
|
|
||||||
"""Project a saved workflow artifact as a planner catalog entry."""
|
|
||||||
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:
|
||||||
return self.source_catalog.get_connection_snapshot(connection_id)
|
return self.source_catalog.get_connection_snapshot(connection_id)
|
||||||
|
|
||||||
@@ -217,34 +224,7 @@ class WfMcpService:
|
|||||||
return self.source_catalog.get_prompt(qualified_name)
|
return self.source_catalog.get_prompt(qualified_name)
|
||||||
|
|
||||||
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.source_catalog.local_documentation_resource(
|
return await self.content_access.read_resource(qualified_name)
|
||||||
qualified_name
|
|
||||||
)
|
|
||||||
if local_resource is not None:
|
|
||||||
self._record_event(
|
|
||||||
make_event(
|
|
||||||
"resource_read_completed",
|
|
||||||
capability_id=qualified_name,
|
|
||||||
payload={"uri": local_resource.uri, "source": "local"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"contents": [
|
|
||||||
{
|
|
||||||
"uri": local_resource.uri,
|
|
||||||
"mimeType": local_resource.mime_type,
|
|
||||||
"text": local_resource.text,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
resource = self.get_resource(qualified_name)
|
|
||||||
connection = self.connections.get(resource.connection_id)
|
|
||||||
return await self.upstream.read_resource(
|
|
||||||
connection,
|
|
||||||
qualified_name,
|
|
||||||
resource.uri,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def invoke_method(
|
async def invoke_method(
|
||||||
self,
|
self,
|
||||||
@@ -276,38 +256,9 @@ class WfMcpService:
|
|||||||
*,
|
*,
|
||||||
arguments: dict[str, str] | None = None,
|
arguments: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
local_prompt = self.source_catalog.local_documentation_prompt(qualified_name)
|
return await self.content_access.render_prompt(
|
||||||
if local_prompt is not None:
|
|
||||||
self._record_event(
|
|
||||||
make_event(
|
|
||||||
"prompt_get_completed",
|
|
||||||
capability_id=qualified_name,
|
|
||||||
payload={
|
|
||||||
"argument_keys": sorted((arguments or {}).keys()),
|
|
||||||
"source": "local",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"description": local_prompt.description,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": {
|
|
||||||
"type": "text",
|
|
||||||
"text": local_prompt.text,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt = self.get_prompt(qualified_name)
|
|
||||||
connection = self.connections.get(prompt.connection_id)
|
|
||||||
return await self.upstream.render_prompt(
|
|
||||||
connection,
|
|
||||||
qualified_name,
|
qualified_name,
|
||||||
prompt.local_name,
|
arguments=arguments,
|
||||||
arguments,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def refresh_connection_catalog(
|
async def refresh_connection_catalog(
|
||||||
@@ -396,7 +347,7 @@ class WfMcpService:
|
|||||||
|
|
||||||
def register_capability_source(self, source: CapabilitySource) -> None:
|
def register_capability_source(self, source: CapabilitySource) -> None:
|
||||||
"""Register a capability source as canonical service state."""
|
"""Register a capability source as canonical service state."""
|
||||||
self.capability_sources[source.id] = source
|
self.source_catalog.register_capability_source(source)
|
||||||
|
|
||||||
def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
|
def _get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
|
||||||
return self.source_catalog.get_qualified_spec(qualified_name)
|
return self.source_catalog.get_qualified_spec(qualified_name)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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_authoring import NodeSpec
|
||||||
from wf_api.operation_context import (
|
from wf_api.operation_context import (
|
||||||
WorkflowArtifactCataloger,
|
|
||||||
WorkflowEventRecorder,
|
WorkflowEventRecorder,
|
||||||
WorkflowLiveSourceChecker,
|
WorkflowLiveSourceChecker,
|
||||||
WorkflowOperationContext,
|
WorkflowOperationContext,
|
||||||
@@ -56,16 +55,6 @@ class WfMcpWorkflowSpecProvider(WorkflowSpecProvider):
|
|||||||
return self.service.source_catalog.get_qualified_spec(qualified_name)
|
return self.service.source_catalog.get_qualified_spec(qualified_name)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WfMcpWorkflowArtifactCataloger(WorkflowArtifactCataloger):
|
|
||||||
"""Adapter-owned artifact catalog formatter backed by WfMcpService."""
|
|
||||||
|
|
||||||
service: WfMcpService
|
|
||||||
|
|
||||||
def workflow_artifact_catalog_entry(self, artifact):
|
|
||||||
return self.service.workflow_artifact_catalog_entry(artifact)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class WfMcpWorkflowRuntimeRunner(WorkflowRuntimeRunner):
|
class WfMcpWorkflowRuntimeRunner(WorkflowRuntimeRunner):
|
||||||
"""Adapter-owned runtime runner backed by WorkflowRuntimeService."""
|
"""Adapter-owned runtime runner backed by WorkflowRuntimeService."""
|
||||||
@@ -139,14 +128,12 @@ def context_from_service(service: WfMcpService) -> WorkflowOperationContext:
|
|||||||
capability_sources=specs.capability_sources,
|
capability_sources=specs.capability_sources,
|
||||||
events=WfMcpWorkflowEventRecorder(service.events),
|
events=WfMcpWorkflowEventRecorder(service.events),
|
||||||
specs=specs,
|
specs=specs,
|
||||||
artifacts=WfMcpWorkflowArtifactCataloger(service),
|
|
||||||
runtime=WfMcpWorkflowRuntimeRunner(service.workflow_runtime),
|
runtime=WfMcpWorkflowRuntimeRunner(service.workflow_runtime),
|
||||||
live_sources=WfMcpWorkflowLiveSourceChecker(service),
|
live_sources=WfMcpWorkflowLiveSourceChecker(service),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"WfMcpWorkflowArtifactCataloger",
|
|
||||||
"WfMcpWorkflowEventRecorder",
|
"WfMcpWorkflowEventRecorder",
|
||||||
"WfMcpWorkflowLiveSourceChecker",
|
"WfMcpWorkflowLiveSourceChecker",
|
||||||
"WfMcpWorkflowRuntimeRunner",
|
"WfMcpWorkflowRuntimeRunner",
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from wf_mcp.broker.service.content_access import ContentAccessService
|
||||||
|
from wf_mcp.broker.service.connection_service import ConnectionService
|
||||||
|
from wf_mcp.broker.service.events import BrokerEventRecorder
|
||||||
|
from wf_mcp.broker.service.source_catalog import SourceCatalogService
|
||||||
|
from wf_mcp.broker.service.upstream_transport import UpstreamTransportService
|
||||||
|
from wf_mcp.events import EventBus
|
||||||
|
from wf_mcp.storage import FileStore
|
||||||
|
from wf_platform import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
DocumentationPrompt,
|
||||||
|
DocumentationResource,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
from wf_mcp.broker import WfMcpService
|
||||||
|
from wf_mcp.models import ConnectionConfig
|
||||||
|
|
||||||
|
from ..test_support import FakeAdapter, local_temp_root
|
||||||
|
|
||||||
|
|
||||||
|
def _make_content_access(
|
||||||
|
*,
|
||||||
|
store_root: str = "content_access_default",
|
||||||
|
) -> tuple[ContentAccessService, BrokerEventRecorder]:
|
||||||
|
store = FileStore(local_temp_root() / store_root)
|
||||||
|
events = BrokerEventRecorder(EventBus())
|
||||||
|
connection_service = ConnectionService(events=events)
|
||||||
|
upstream = UpstreamTransportService(
|
||||||
|
store=store,
|
||||||
|
event_sink=events.record_event,
|
||||||
|
)
|
||||||
|
source_catalog = SourceCatalogService(
|
||||||
|
store=store,
|
||||||
|
connection_lookup=connection_service.get,
|
||||||
|
connection_list_enabled=connection_service.list_enabled,
|
||||||
|
connection_list_all=connection_service.list_all,
|
||||||
|
tool_executor_for=upstream.tool_executor_for,
|
||||||
|
load_auth=upstream.load_auth,
|
||||||
|
emit_event=events.record_event,
|
||||||
|
)
|
||||||
|
connection_service.bind_source_catalog(source_catalog)
|
||||||
|
_register_local_docs(source_catalog)
|
||||||
|
content_access = ContentAccessService(
|
||||||
|
source_catalog=source_catalog,
|
||||||
|
upstream=upstream,
|
||||||
|
connection_service=connection_service,
|
||||||
|
event_sink=events.record_event,
|
||||||
|
)
|
||||||
|
return content_access, events
|
||||||
|
|
||||||
|
|
||||||
|
def _register_local_docs(source_catalog: SourceCatalogService) -> None:
|
||||||
|
"""Install deterministic local docs without depending on repo Markdown files."""
|
||||||
|
source_catalog.register_capability_source(
|
||||||
|
CapabilitySource(
|
||||||
|
id="test.docs",
|
||||||
|
kind="system",
|
||||||
|
capabilities=CapabilityBuckets(
|
||||||
|
resources={
|
||||||
|
"test.docs.example": DocumentationResource(
|
||||||
|
name="test.docs.example",
|
||||||
|
uri="wf://docs/example",
|
||||||
|
title="Example Doc",
|
||||||
|
description="Test documentation resource.",
|
||||||
|
mime_type="text/markdown",
|
||||||
|
text="# Example",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prompts={
|
||||||
|
"test.docs.guide": DocumentationPrompt(
|
||||||
|
name="test.docs.guide",
|
||||||
|
title="Guide Prompt",
|
||||||
|
description="Test documentation prompt.",
|
||||||
|
text="Use the test docs.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
visibility=SourceVisibility(planner=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_access_reads_local_documentation_resource() -> None:
|
||||||
|
content_access, events = _make_content_access()
|
||||||
|
|
||||||
|
result = asyncio.run(content_access.read_resource("test.docs.example"))
|
||||||
|
|
||||||
|
assert result["contents"][0]["uri"] == "wf://docs/example"
|
||||||
|
assert result["contents"][0]["text"] == "# Example"
|
||||||
|
assert "resource_read_completed" in [e.kind for e in events.list_events()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_access_reads_upstream_resource_with_events() -> None:
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "content_upstream_resource")
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_adapter("demo", FakeAdapter())
|
||||||
|
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.content_access.read_resource("demo.personal.resource.welcome")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["contents"][0]["text"] == "Welcome from the fake adapter resource."
|
||||||
|
event_kinds = [e.kind for e in service.list_events()]
|
||||||
|
assert "resource_read_started" in event_kinds
|
||||||
|
assert "resource_read_completed" in event_kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_access_renders_upstream_prompt_with_events() -> None:
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "content_upstream_prompt")
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_adapter("demo", FakeAdapter())
|
||||||
|
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.content_access.render_prompt(
|
||||||
|
"demo.personal.prompt.summarize",
|
||||||
|
arguments={"text": "hello world"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "hello world" in result["messages"][0]["content"]["text"]
|
||||||
|
event_kinds = [e.kind for e in service.list_events()]
|
||||||
|
assert "prompt_get_started" in event_kinds
|
||||||
|
assert "prompt_get_completed" in event_kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_access_renders_local_documentation_prompt() -> None:
|
||||||
|
content_access, events = _make_content_access(
|
||||||
|
store_root="content_access_local_prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(content_access.render_prompt("test.docs.guide"))
|
||||||
|
|
||||||
|
assert result["description"] == "Test documentation prompt."
|
||||||
|
assert result["messages"][0]["role"] == "user"
|
||||||
|
assert result["messages"][0]["content"]["text"] == "Use the test docs."
|
||||||
|
assert "prompt_get_completed" in [e.kind for e in events.list_events()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_access_raises_on_unknown_resource() -> None:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
content_access, _ = _make_content_access(
|
||||||
|
store_root="content_access_missing_resource"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
asyncio.run(content_access.read_resource("nonexistent.resource"))
|
||||||
Reference in New Issue
Block a user