Capability sources, add default source under wf.
This commit is contained in:
@@ -0,0 +1,790 @@
|
|||||||
|
# Capability Sources 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:** Replace the ad hoc broker/proxy/admin/stdlib split with a source registry that can project workflow node specs, MCP tools, prompts, and resources to the right surfaces.
|
||||||
|
|
||||||
|
**Architecture:** Add `CapabilitySource` as the source registry model, keep `SpecSource` behavior as a compatibility projection while the rest of the system migrates, then move stdlib and admin capabilities into explicit `wf.std`, `wf.mcp`, and `wf.admin` sources. Broker/proxy servers should project capabilities from sources instead of defining separate duplicate admin tools.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14, dataclasses, Pydantic models already used by node specs, FastMCP/MCP server decorators, pytest, ruff, basedpyright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Create `src/wf_mcp/broker/service/capability_sources.py`: canonical source model, visibility flags, permission flags, capability buckets, projection helpers.
|
||||||
|
- Modify `src/wf_mcp/broker/service/sources.py`: either delegate to the new model or become a small compatibility import.
|
||||||
|
- Modify `src/wf_mcp/broker/service/core.py`: store `capability_sources`, derive node-spec resolution from them, keep `spec_sources` and `specs_by_connection` as compatibility views if needed.
|
||||||
|
- Modify `src/wf_mcp/broker/service/builtins.py`: register `wf.std` stdlib specs and `wf.mcp` runtime specs through capability sources.
|
||||||
|
- Modify `src/wf_mcp/broker/tools.py`: project broker MCP tools from `wf.admin` source definitions.
|
||||||
|
- Create `src/wf_mcp/broker/admin_capabilities.py`: one reusable definition of broker/admin tool capabilities.
|
||||||
|
- Modify `src/wf_mcp/transparent_proxy/admin.py` and `src/wf_mcp/transparent_proxy/runtime.py`: project proxy admin tools from the same `wf.admin` capability definitions when admin MCP exposure is enabled.
|
||||||
|
- Modify `src/wf_mcp/shared/names.py`: move admin namespace toward `wf.admin` and use `LdaNamespace` where dotted names should be preserved.
|
||||||
|
- Modify `tests/wf_mcp/test_service.py`, `tests/wf_mcp/test_broker_server.py`, and `tests/wf_mcp/test_transparent_proxy.py`: prove projections and defaults.
|
||||||
|
- Modify `docs/wf_mcp_capability_sources.md`: update once implementation names are final.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Canonical Capability Source Model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/wf_mcp/broker/service/capability_sources.py`
|
||||||
|
- Modify: `src/wf_mcp/broker/service/sources.py`
|
||||||
|
- Test: `tests/wf_mcp/test_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for source visibility and buckets**
|
||||||
|
|
||||||
|
Add tests in `tests/wf_mcp/test_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_service_sources_have_visibility_and_capability_buckets() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "source_shape_store"))
|
||||||
|
|
||||||
|
std_source = service.capability_sources["wf.std"]
|
||||||
|
mcp_source = service.capability_sources["wf.mcp"]
|
||||||
|
|
||||||
|
assert std_source.id == "wf.std"
|
||||||
|
assert std_source.kind == "system"
|
||||||
|
assert std_source.visibility.planner is True
|
||||||
|
assert std_source.visibility.mcp_client is True
|
||||||
|
assert std_source.visibility.admin_dashboard is True
|
||||||
|
assert "wf.std.runtime_error" in std_source.capabilities.node_specs
|
||||||
|
assert std_source.capabilities.tools == {}
|
||||||
|
|
||||||
|
assert mcp_source.id == "wf.mcp"
|
||||||
|
assert mcp_source.visibility.planner is True
|
||||||
|
assert mcp_source.permissions.calls_upstream is True
|
||||||
|
assert "wf.mcp.call_tool" in mcp_source.capabilities.node_specs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused failing test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_service_sources_have_visibility_and_capability_buckets -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail because `capability_sources` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the source model**
|
||||||
|
|
||||||
|
Create `src/wf_mcp/broker/service/capability_sources.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
|
SourceKind = Literal["system", "connection"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceVisibility:
|
||||||
|
planner: bool = False
|
||||||
|
mcp_client: bool = False
|
||||||
|
admin_dashboard: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourcePermissions:
|
||||||
|
safe_for_workflow: bool = False
|
||||||
|
calls_upstream: bool = False
|
||||||
|
mutates_config: bool = False
|
||||||
|
mutates_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CapabilityBuckets:
|
||||||
|
tools: dict[str, Any] = field(default_factory=dict)
|
||||||
|
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
|
prompts: dict[str, Any] = field(default_factory=dict)
|
||||||
|
resources: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CapabilitySource:
|
||||||
|
id: str
|
||||||
|
kind: SourceKind
|
||||||
|
capabilities: CapabilityBuckets = field(default_factory=CapabilityBuckets)
|
||||||
|
enabled: bool = True
|
||||||
|
visibility: SourceVisibility = field(default_factory=SourceVisibility)
|
||||||
|
permissions: SourcePermissions = field(default_factory=SourcePermissions)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
def as_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"visibility": {
|
||||||
|
"planner": self.visibility.planner,
|
||||||
|
"mcp_client": self.visibility.mcp_client,
|
||||||
|
"admin_dashboard": self.visibility.admin_dashboard,
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"safe_for_workflow": self.permissions.safe_for_workflow,
|
||||||
|
"calls_upstream": self.permissions.calls_upstream,
|
||||||
|
"mutates_config": self.permissions.mutates_config,
|
||||||
|
"mutates_auth": self.permissions.mutates_auth,
|
||||||
|
},
|
||||||
|
"description": self.description,
|
||||||
|
"tool_count": len(self.capabilities.tools),
|
||||||
|
"node_spec_count": len(self.capabilities.node_specs),
|
||||||
|
"prompt_count": len(self.capabilities.prompts),
|
||||||
|
"resource_count": len(self.capabilities.resources),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Keep `SpecSource` as compatibility wrapper**
|
||||||
|
|
||||||
|
Modify `src/wf_mcp/broker/service/sources.py` so existing code can still import `SpecSource` while new code can move to `CapabilitySource`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
|
from .capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourceKind,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SpecSource:
|
||||||
|
id: str
|
||||||
|
kind: SourceKind
|
||||||
|
specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
|
visible: bool = True
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
def as_capability_source(self) -> CapabilitySource:
|
||||||
|
return CapabilitySource(
|
||||||
|
id=self.id,
|
||||||
|
kind=self.kind,
|
||||||
|
capabilities=CapabilityBuckets(node_specs=self.specs),
|
||||||
|
visibility=SourceVisibility(
|
||||||
|
planner=self.visible,
|
||||||
|
mcp_client=False,
|
||||||
|
admin_dashboard=True,
|
||||||
|
),
|
||||||
|
permissions=SourcePermissions(safe_for_workflow=self.kind == "system"),
|
||||||
|
description=self.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_status(self) -> dict[str, Any]:
|
||||||
|
return self.as_capability_source().as_status()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_service_sources_have_visibility_and_capability_buckets -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail until service stores `capability_sources`; pass after Task 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Make `WfMcpService` Store Capability Sources
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/broker/service/core.py`
|
||||||
|
- Modify: `src/wf_mcp/broker/service/specs.py`
|
||||||
|
- Test: `tests/wf_mcp/test_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing service projection tests**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_service_spec_views_are_derived_from_capability_sources() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "source_view_store"))
|
||||||
|
|
||||||
|
assert "wf.std" in service.capability_sources
|
||||||
|
assert "wf.std" in service.spec_sources
|
||||||
|
assert "wf.std" in service.specs_by_connection
|
||||||
|
assert (
|
||||||
|
service.specs_by_connection["wf.std"]["wf.std.runtime_error"]
|
||||||
|
is service.capability_sources["wf.std"].capabilities.node_specs[
|
||||||
|
"wf.std.runtime_error"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_service_spec_views_are_derived_from_capability_sources -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail because compatibility views are not derived from capability sources.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update service fields and registration**
|
||||||
|
|
||||||
|
In `src/wf_mcp/broker/service/core.py`, replace the stored `spec_sources` field with canonical capability storage and add derived views:
|
||||||
|
|
||||||
|
```python
|
||||||
|
capability_sources: dict[str, CapabilitySource] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spec_sources(self) -> dict[str, SpecSource]:
|
||||||
|
return {
|
||||||
|
source.id: SpecSource(
|
||||||
|
id=source.id,
|
||||||
|
kind=source.kind,
|
||||||
|
specs=source.capabilities.node_specs,
|
||||||
|
visible=source.enabled and source.visibility.planner,
|
||||||
|
description=source.description,
|
||||||
|
)
|
||||||
|
for source in self.capability_sources.values()
|
||||||
|
if source.capabilities.node_specs
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def specs_by_connection(self) -> dict[str, dict[str, NodeSpec[Any, Any]]]:
|
||||||
|
return {
|
||||||
|
source.id: source.capabilities.node_specs
|
||||||
|
for source in self.capability_sources.values()
|
||||||
|
if source.capabilities.node_specs
|
||||||
|
}
|
||||||
|
|
||||||
|
def register_capability_source(self, source: CapabilitySource) -> None:
|
||||||
|
self.capability_sources[source.id] = source
|
||||||
|
|
||||||
|
def register_spec_source(self, source: SpecSource) -> None:
|
||||||
|
self.register_capability_source(source.as_capability_source())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update spec resolution**
|
||||||
|
|
||||||
|
In `src/wf_mcp/broker/service/specs.py`, make `get_qualified_spec` read capability sources:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from .capability_sources import CapabilitySource
|
||||||
|
|
||||||
|
|
||||||
|
def get_qualified_spec(
|
||||||
|
sources: Mapping[str, CapabilitySource],
|
||||||
|
qualified_name: str,
|
||||||
|
) -> NodeSpec[Any, Any]:
|
||||||
|
source_id, _ = qualified_name.rsplit(".", 1)
|
||||||
|
source = sources.get(source_id)
|
||||||
|
if (
|
||||||
|
source is None
|
||||||
|
or not source.enabled
|
||||||
|
or qualified_name not in source.capabilities.node_specs
|
||||||
|
):
|
||||||
|
raise KeyError(f"unknown qualified node {qualified_name!r}")
|
||||||
|
return source.capabilities.node_specs[qualified_name]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused service tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Move All Authoring Ops Into `wf.std`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/broker/service/builtins.py`
|
||||||
|
- Test: `tests/wf_mcp/test_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing `wf.std` inventory test**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_wf_std_source_contains_authoring_ops() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "stdlib_source_store"))
|
||||||
|
specs = service.capability_sources["wf.std"].capabilities.node_specs
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"wf.std.coalesce",
|
||||||
|
"wf.std.default_if_none",
|
||||||
|
"wf.std.constant",
|
||||||
|
"wf.std.pick_key",
|
||||||
|
"wf.std.truthy",
|
||||||
|
"wf.std.runtime_error",
|
||||||
|
"wf.std.first_item",
|
||||||
|
"wf.std.first_item_or_none",
|
||||||
|
"wf.std.first_item_maybe",
|
||||||
|
"wf.std.last_item",
|
||||||
|
"wf.std.last_item_or_none",
|
||||||
|
"wf.std.length",
|
||||||
|
"wf.std.is_empty",
|
||||||
|
}
|
||||||
|
assert expected <= set(specs)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_wf_std_source_contains_authoring_ops -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail because only `runtime_error` is currently registered.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register stdlib ops**
|
||||||
|
|
||||||
|
In `src/wf_mcp/broker/service/builtins.py`, import `wf_authoring.ops` symbols and qualify each one under `wf.std`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_authoring import (
|
||||||
|
coalesce,
|
||||||
|
constant,
|
||||||
|
default_if_none,
|
||||||
|
first_item,
|
||||||
|
first_item_maybe,
|
||||||
|
first_item_or_none,
|
||||||
|
is_empty,
|
||||||
|
last_item,
|
||||||
|
last_item_or_none,
|
||||||
|
length,
|
||||||
|
pick_key,
|
||||||
|
runtime_error,
|
||||||
|
truthy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
|
||||||
|
specs = [
|
||||||
|
coalesce,
|
||||||
|
default_if_none,
|
||||||
|
constant,
|
||||||
|
pick_key,
|
||||||
|
truthy,
|
||||||
|
runtime_error,
|
||||||
|
first_item,
|
||||||
|
first_item_or_none,
|
||||||
|
first_item_maybe,
|
||||||
|
last_item,
|
||||||
|
last_item_or_none,
|
||||||
|
length,
|
||||||
|
is_empty,
|
||||||
|
]
|
||||||
|
qualified_specs = [
|
||||||
|
qualify_spec(BUILTIN_CONNECTION_ID, _strip_authoring_prefix(spec))
|
||||||
|
for spec in specs
|
||||||
|
]
|
||||||
|
return {spec.name: spec for spec in qualified_specs}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_authoring_prefix(spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
|
||||||
|
name = spec.name.removeprefix("authoring.")
|
||||||
|
return NodeSpec(
|
||||||
|
name=name,
|
||||||
|
input_model=spec.input_model,
|
||||||
|
output_model=spec.output_model,
|
||||||
|
outcomes=spec.outcomes,
|
||||||
|
fn=spec.fn,
|
||||||
|
description=spec.description,
|
||||||
|
is_async=spec.is_async,
|
||||||
|
accepts_context=spec.accepts_context,
|
||||||
|
input_schema_contract=spec.input_schema_contract,
|
||||||
|
output_schema_contract=spec.output_schema_contract,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run service tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add `wf.admin` Source Without MCP Exposure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/wf_mcp/broker/admin_capabilities.py`
|
||||||
|
- Modify: `src/wf_mcp/broker/service/core.py`
|
||||||
|
- Test: `tests/wf_mcp/test_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing admin source test**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_wf_admin_source_exists_but_is_not_planner_visible() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "admin_source_store"))
|
||||||
|
source = service.capability_sources["wf.admin"]
|
||||||
|
|
||||||
|
assert source.kind == "system"
|
||||||
|
assert source.visibility.planner is False
|
||||||
|
assert source.visibility.mcp_client is False
|
||||||
|
assert source.visibility.admin_dashboard is True
|
||||||
|
assert source.permissions.mutates_config is True
|
||||||
|
assert "wf.admin.list_sources" in source.capabilities.tools
|
||||||
|
assert "wf.admin.disable_source" in source.capabilities.tools
|
||||||
|
assert "wf.admin" not in service.get_planner_catalog().snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py::test_wf_admin_source_exists_but_is_not_planner_visible -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail because `wf.admin` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Define admin capability objects**
|
||||||
|
|
||||||
|
Create `src/wf_mcp/broker/admin_capabilities.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from .service.capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN_SOURCE_ID = "wf.admin"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AdminTool:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
handler_name: str
|
||||||
|
mutates_config: bool = False
|
||||||
|
mutates_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def admin_source() -> CapabilitySource:
|
||||||
|
tools: dict[str, AdminTool] = {
|
||||||
|
"wf.admin.list_sources": AdminTool(
|
||||||
|
name="wf.admin.list_sources",
|
||||||
|
description="List broker capability sources.",
|
||||||
|
handler_name="list_sources",
|
||||||
|
),
|
||||||
|
"wf.admin.disable_source": AdminTool(
|
||||||
|
name="wf.admin.disable_source",
|
||||||
|
description="Disable a capability source.",
|
||||||
|
handler_name="disable_source",
|
||||||
|
mutates_config=True,
|
||||||
|
),
|
||||||
|
"wf.admin.enable_source": AdminTool(
|
||||||
|
name="wf.admin.enable_source",
|
||||||
|
description="Enable a capability source.",
|
||||||
|
handler_name="enable_source",
|
||||||
|
mutates_config=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return CapabilitySource(
|
||||||
|
id=ADMIN_SOURCE_ID,
|
||||||
|
kind="system",
|
||||||
|
capabilities=CapabilityBuckets(tools=tools),
|
||||||
|
visibility=SourceVisibility(
|
||||||
|
planner=False,
|
||||||
|
mcp_client=False,
|
||||||
|
admin_dashboard=True,
|
||||||
|
),
|
||||||
|
permissions=SourcePermissions(
|
||||||
|
safe_for_workflow=False,
|
||||||
|
calls_upstream=False,
|
||||||
|
mutates_config=True,
|
||||||
|
mutates_auth=True,
|
||||||
|
),
|
||||||
|
description="Privileged broker administration capabilities.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Install admin source**
|
||||||
|
|
||||||
|
In `WfMcpService.__post_init__`, register `admin_source()` after builtin sources:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..admin_capabilities import admin_source
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
self.register_capability_source(admin_source())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_service.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Project Broker Admin Tools From `wf.admin`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/broker/tools.py`
|
||||||
|
- Test: `tests/wf_mcp/test_broker_server.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add test proving default broker exposes current public tools but has source-backed metadata**
|
||||||
|
|
||||||
|
Add to `tests/wf_mcp/test_broker_server.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_broker_admin_tools_are_backed_by_wf_admin_source() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "broker_admin_source"))
|
||||||
|
server = create_broker_server(service)
|
||||||
|
|
||||||
|
tools = asyncio.run(server.list_tools())
|
||||||
|
tool_names = {tool.name for tool in tools}
|
||||||
|
|
||||||
|
assert "list_spec_sources" in tool_names
|
||||||
|
assert "get_planner_catalog" in tool_names
|
||||||
|
assert "wf.admin.list_sources" in service.capability_sources[
|
||||||
|
"wf.admin"
|
||||||
|
].capabilities.tools
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused broker test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_broker_server.py::test_broker_admin_tools_are_backed_by_wf_admin_source -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass after Task 4; fail before Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Keep current tool names as compatibility exports**
|
||||||
|
|
||||||
|
Do not rename public broker MCP tools in this task. Keep:
|
||||||
|
|
||||||
|
```text
|
||||||
|
list_connections
|
||||||
|
get_connection_statuses
|
||||||
|
refresh_connection_catalog
|
||||||
|
get_catalog
|
||||||
|
get_planner_catalog
|
||||||
|
list_spec_sources
|
||||||
|
read_broker_resource
|
||||||
|
render_broker_prompt
|
||||||
|
invoke_broker_method
|
||||||
|
call_broker_tool
|
||||||
|
get_broker_events
|
||||||
|
```
|
||||||
|
|
||||||
|
Add comments in `src/wf_mcp/broker/tools.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# These MCP tool names are compatibility exports. Their capability metadata
|
||||||
|
# belongs to the wf.admin source; future admin-enabled servers can project
|
||||||
|
# dotted wf.admin.* names from that source.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused broker tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_broker_server.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Normalize Transparent Proxy Admin Naming Strategy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/shared/names.py`
|
||||||
|
- Modify: `src/wf_mcp/transparent_proxy/runtime.py`
|
||||||
|
- Test: `tests/wf_mcp/test_names.py`
|
||||||
|
- Test: `tests/wf_mcp/test_transparent_proxy.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add naming tests for admin namespace**
|
||||||
|
|
||||||
|
Add to `tests/wf_mcp/test_names.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_admin_namespace_is_distinct_from_wf_mcp_runtime_source() -> None:
|
||||||
|
assert ADMIN_NAMESPACE == "wf.admin"
|
||||||
|
assert is_admin_tool_name("wf.admin.list_connections") is True
|
||||||
|
assert is_admin_tool_name("wf.mcp.call_tool") is False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused naming test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_names.py::test_admin_namespace_is_distinct_from_wf_mcp_runtime_source -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail because `ADMIN_NAMESPACE` is currently `wf.mcp`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update namespace constants**
|
||||||
|
|
||||||
|
In `src/wf_mcp/shared/names.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADMIN_NAMESPACE = "wf.admin"
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_tool_name(proxy_name: str) -> bool:
|
||||||
|
return proxy_name.startswith(f"{ADMIN_NAMESPACE}.") or proxy_name.startswith(
|
||||||
|
f"{ADMIN_NAMESPACE}_"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `LdaNamespace(ADMIN_NAMESPACE)` in `src/wf_mcp/transparent_proxy/runtime.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..shared.names import ADMIN_NAMESPACE, LdaNamespace
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
admin.add_transform(LdaNamespace(ADMIN_NAMESPACE))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update transparent proxy tests**
|
||||||
|
|
||||||
|
In `tests/wf_mcp/test_transparent_proxy.py`, update expected admin names from `wf.mcp_*` to dotted `wf.admin.*` if `LdaNamespace` preserves dotted names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert "wf.admin.list_connections" in names
|
||||||
|
assert "wf.admin.get_connection_statuses" in names
|
||||||
|
assert "wf.admin.list_proxy_tools" in names
|
||||||
|
assert "wf.admin.get_proxy_tool" in names
|
||||||
|
```
|
||||||
|
|
||||||
|
Call tools by the new names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
connections_result = await client.call_tool("wf.admin.list_connections")
|
||||||
|
proxy_tools_result = await client.call_tool("wf.admin.list_proxy_tools")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run transparent proxy tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest tests\wf_mcp\test_names.py tests\wf_mcp\test_transparent_proxy.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass if `LdaNamespace` preserves dotted names. If FastMCP still emits underscore names, keep `wf.admin_*` as compatibility and document that dotted projection needs a deeper transform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update Docs And Full Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/wf_mcp_capability_sources.md`
|
||||||
|
- Modify: `docs/wf_mcp_architecture.md`
|
||||||
|
- Test: full verification commands
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update docs with implemented names**
|
||||||
|
|
||||||
|
In `docs/wf_mcp_capability_sources.md`, update the migration section to mark implemented pieces:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Implemented Shape
|
||||||
|
|
||||||
|
- `CapabilitySource` owns source metadata and capability buckets.
|
||||||
|
- `wf.std` owns workflow stdlib node specs.
|
||||||
|
- `wf.mcp` owns workflow MCP runtime node specs.
|
||||||
|
- `wf.admin` owns privileged admin capability metadata.
|
||||||
|
- Broker and proxy MCP tool projection remains compatibility-first while the
|
||||||
|
admin projection stabilizes.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full pytest**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with pytest pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass, live-only tests may skip when env is absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run ruff**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run ruff check src tests examples main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `All checks passed!`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run basedpyright errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run basedpyright src tests examples main.py --level error
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `0 errors`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
Spec coverage:
|
||||||
|
|
||||||
|
- Capability source model is covered by Tasks 1 and 2.
|
||||||
|
- `wf.std` migration is covered by Task 3.
|
||||||
|
- `wf.admin` privileged source is covered by Task 4.
|
||||||
|
- Broker projection is covered by Task 5.
|
||||||
|
- Transparent proxy naming and admin namespace separation is covered by Task 6.
|
||||||
|
- Documentation and verification are covered by Task 7.
|
||||||
|
|
||||||
|
Known deliberate scope limits:
|
||||||
|
|
||||||
|
- Task 5 keeps current broker MCP public tool names as compatibility exports.
|
||||||
|
- Task 6 attempts dotted transparent-proxy admin names via `LdaNamespace`; if FastMCP still forces underscore naming, this plan keeps `wf.admin_*` compatibility and defers deeper transform work.
|
||||||
|
- Source enable/disable runtime behavior is not implemented in this plan beyond introducing `enabled`; it should be the next plan after the registry shape lands.
|
||||||
|
|
||||||
|
Placeholder scan:
|
||||||
|
|
||||||
|
- No `TBD`, `TODO`, or unspecified implementation steps remain.
|
||||||
|
- Each task includes exact paths, test names, commands, and expected outcomes.
|
||||||
|
|
||||||
|
Type consistency:
|
||||||
|
|
||||||
|
- `CapabilitySource`, `CapabilityBuckets`, `SourceVisibility`, and
|
||||||
|
`SourcePermissions` are introduced once and reused consistently.
|
||||||
|
- Compatibility views are named `spec_sources` and `specs_by_connection`.
|
||||||
@@ -71,9 +71,10 @@ Workflow runtime helpers for interacting with MCP backends.
|
|||||||
|
|
||||||
Expected capabilities:
|
Expected capabilities:
|
||||||
|
|
||||||
- `node_specs`: `wf.mcp.call_tool`, `wf.mcp.read_resource`,
|
- `node_specs`: currently `wf.mcp.call_tool`.
|
||||||
|
- Near-term node specs may include `wf.mcp.read_resource` and
|
||||||
`wf.mcp.get_prompt`.
|
`wf.mcp.get_prompt`.
|
||||||
- Advanced/escape-hatch node specs may exist later:
|
- Advanced/escape-hatch node specs may include:
|
||||||
`wf.mcp.invoke_method`, `wf.mcp.send_notification`.
|
`wf.mcp.invoke_method`, `wf.mcp.send_notification`.
|
||||||
- `prompts/resources`: docs for building MCP-backed workflows.
|
- `prompts/resources`: docs for building MCP-backed workflows.
|
||||||
|
|
||||||
@@ -175,6 +176,32 @@ This is the preferred place for source toggling:
|
|||||||
Dashboard operations should use `wf.admin` capability definitions or Python
|
Dashboard operations should use `wf.admin` capability definitions or Python
|
||||||
control APIs, not ad hoc tool functions copied across broker and proxy modes.
|
control APIs, not ad hoc tool functions copied across broker and proxy modes.
|
||||||
|
|
||||||
|
## Implemented Shape
|
||||||
|
|
||||||
|
The code now has the first capability-source layer in place.
|
||||||
|
|
||||||
|
- `CapabilitySource` owns source metadata, visibility, permissions, and
|
||||||
|
capability buckets.
|
||||||
|
- `WfMcpService.capability_sources` is the canonical in-memory registry.
|
||||||
|
- `spec_sources` and `specs_by_connection` are compatibility views derived from
|
||||||
|
`capability_sources`.
|
||||||
|
- `wf.std` owns current `wf_authoring.ops` workflow node specs under
|
||||||
|
`wf.std.*`.
|
||||||
|
- `wf.mcp` owns workflow MCP runtime node specs, currently
|
||||||
|
`wf.mcp.call_tool`.
|
||||||
|
- `wf.admin` owns privileged admin capability metadata and is not planner-visible
|
||||||
|
or MCP-client-visible by default.
|
||||||
|
- Transparent proxy admin tools now use dotted `wf.admin.*` names through
|
||||||
|
`LdaNamespace`.
|
||||||
|
- `wf.admin` and `wf.mcp` are reserved connection ids.
|
||||||
|
- Planner catalog, `list_available_specs()`, and workflow spec resolution respect
|
||||||
|
source `enabled` and `visibility.planner`.
|
||||||
|
|
||||||
|
Broker MCP tools still expose compatibility names such as `list_connections` and
|
||||||
|
`get_planner_catalog`. Their metadata belongs to `wf.admin`, but dotted
|
||||||
|
`wf.admin.*` broker tool projection is intentionally deferred until admin MCP
|
||||||
|
exposure is explicit.
|
||||||
|
|
||||||
## Current Code Mapping
|
## Current Code Mapping
|
||||||
|
|
||||||
Current code has several useful pieces but the boundaries are blurred.
|
Current code has several useful pieces but the boundaries are blurred.
|
||||||
@@ -183,14 +210,13 @@ Current code has several useful pieces but the boundaries are blurred.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `wf_authoring.ops` | reusable workflow node specs | `wf.std.node_specs` |
|
| `wf_authoring.ops` | reusable workflow node specs | `wf.std.node_specs` |
|
||||||
| `wf_mcp.broker.service.builtins` | local workflow specs | `wf.std`, `wf.mcp` |
|
| `wf_mcp.broker.service.builtins` | local workflow specs | `wf.std`, `wf.mcp` |
|
||||||
| `wf_mcp.broker.tools` | broker MCP admin/control tools | `wf.admin.tools` |
|
| `wf_mcp.broker.tools` | compatibility broker MCP admin/control tools | `wf.admin.tools` |
|
||||||
| `wf_mcp.transparent_proxy.admin` | proxy MCP admin/control tools | `wf.admin.tools` |
|
| `wf_mcp.transparent_proxy.admin` | dotted proxy MCP admin/control tools | `wf.admin.tools` |
|
||||||
| discovered MCP tools | upstream tools and workflow wrappers | connection source |
|
| discovered MCP tools | upstream tools and workflow wrappers | connection source |
|
||||||
| broker resources/prompts | catalog/status/planning context | likely `wf.admin` or docs sources |
|
| broker resources/prompts | catalog/status/planning context | likely `wf.admin` or docs sources |
|
||||||
|
|
||||||
The current `SpecSource` is a first step, but it only models workflow node specs.
|
`SpecSource` is now a compatibility wrapper. New source behavior should be added
|
||||||
It should evolve into `CapabilitySource`, or be replaced by it, so tools,
|
to `CapabilitySource` unless there is a specific compatibility reason not to.
|
||||||
prompts, resources, and node specs share one source registry.
|
|
||||||
|
|
||||||
## Naming Rules
|
## Naming Rules
|
||||||
|
|
||||||
@@ -201,25 +227,18 @@ Use source ids consistently:
|
|||||||
- `wf.admin.*` for privileged control/admin capabilities.
|
- `wf.admin.*` for privileged control/admin capabilities.
|
||||||
- `<connection_id>.*` for upstream connection capabilities.
|
- `<connection_id>.*` for upstream connection capabilities.
|
||||||
|
|
||||||
Avoid using `wf.mcp_*` for admin tools. That name currently comes from
|
Avoid using `wf.mcp_*` for admin tools. `wf.mcp` is reserved for workflow MCP
|
||||||
FastMCP's `Namespace` transform using underscores, but semantically it makes
|
runtime helpers. Transparent proxy admin tools now use dotted `wf.admin.*`
|
||||||
admin look like MCP workflow runtime. If a custom namespace transform can
|
names.
|
||||||
preserve dotted names, prefer `wf.admin.list_sources` over
|
|
||||||
`wf.mcp_list_sources`.
|
|
||||||
|
|
||||||
## Migration Path
|
## Migration Path
|
||||||
|
|
||||||
1. Keep the current tests green while making `SpecSource` canonical for node
|
1. Add explicit admin MCP exposure controls for broker mode.
|
||||||
specs.
|
2. Project broker admin tools from `wf.admin` only when admin MCP exposure is
|
||||||
2. Add a `CapabilitySource` shape that can hold `tools`, `node_specs`,
|
enabled.
|
||||||
`prompts`, and `resources`.
|
3. Add source-level enable/disable operations backed by `wf.admin`.
|
||||||
3. Move all `wf_authoring.ops` registrations into the `wf.std` source.
|
4. Add persisted source policy so source visibility survives process restart.
|
||||||
4. Move broker and transparent-proxy admin operations into one `wf.admin`
|
5. Add system prompts/resources for `wf.std` and `wf.mcp` manuals.
|
||||||
source definition.
|
|
||||||
5. Make broker and proxy servers project admin tools from `wf.admin` only when
|
|
||||||
admin MCP exposure is explicitly enabled.
|
|
||||||
6. Add source-level enable/disable behavior and make every projection respect it.
|
|
||||||
7. Add system prompts/resources for `wf.std` and `wf.mcp` manuals.
|
|
||||||
|
|
||||||
The implementation should avoid having broker mode and transparent proxy mode
|
The implementation should avoid having broker mode and transparent proxy mode
|
||||||
define separate copies of the same admin/control capabilities.
|
define separate copies of the same admin/control capabilities.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .service.capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_SOURCE_ID = "wf.admin"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AdminTool:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
handler_name: str
|
||||||
|
mutates_config: bool = False
|
||||||
|
mutates_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def admin_source() -> CapabilitySource:
|
||||||
|
"""Return metadata for privileged broker administration tools."""
|
||||||
|
tools = {
|
||||||
|
tool.name: tool
|
||||||
|
for tool in (
|
||||||
|
AdminTool(
|
||||||
|
name="wf.admin.list_sources",
|
||||||
|
description="List broker capability sources.",
|
||||||
|
handler_name="list_sources",
|
||||||
|
),
|
||||||
|
AdminTool(
|
||||||
|
name="wf.admin.disable_source",
|
||||||
|
description="Disable a broker capability source.",
|
||||||
|
handler_name="disable_source",
|
||||||
|
mutates_config=True,
|
||||||
|
),
|
||||||
|
AdminTool(
|
||||||
|
name="wf.admin.enable_source",
|
||||||
|
description="Enable a broker capability source.",
|
||||||
|
handler_name="enable_source",
|
||||||
|
mutates_config=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return CapabilitySource(
|
||||||
|
id=ADMIN_SOURCE_ID,
|
||||||
|
kind="system",
|
||||||
|
capabilities=CapabilityBuckets(tools=tools),
|
||||||
|
visibility=SourceVisibility(
|
||||||
|
planner=False,
|
||||||
|
mcp_client=False,
|
||||||
|
admin_dashboard=True,
|
||||||
|
),
|
||||||
|
permissions=SourcePermissions(
|
||||||
|
safe_for_workflow=False,
|
||||||
|
calls_upstream=False,
|
||||||
|
mutates_config=True,
|
||||||
|
mutates_auth=True,
|
||||||
|
),
|
||||||
|
description="Privileged broker administration capabilities.",
|
||||||
|
)
|
||||||
@@ -4,7 +4,24 @@ from typing import Any, Protocol
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from wf_authoring import NodeReturn, NodeSpec, node, runtime_error
|
from wf_authoring import (
|
||||||
|
NodeReturn,
|
||||||
|
NodeSpec,
|
||||||
|
coalesce,
|
||||||
|
constant,
|
||||||
|
default_if_none,
|
||||||
|
first_item,
|
||||||
|
first_item_maybe,
|
||||||
|
first_item_or_none,
|
||||||
|
is_empty,
|
||||||
|
last_item,
|
||||||
|
last_item_or_none,
|
||||||
|
length,
|
||||||
|
node,
|
||||||
|
pick_key,
|
||||||
|
runtime_error,
|
||||||
|
truthy,
|
||||||
|
)
|
||||||
|
|
||||||
from .sources import SpecSource
|
from .sources import SpecSource
|
||||||
from .specs import qualify_spec
|
from .specs import qualify_spec
|
||||||
@@ -16,6 +33,24 @@ MCP_SOURCE_ID = "wf.mcp"
|
|||||||
"""Internal source id for broker MCP utility node specs."""
|
"""Internal source id for broker MCP utility node specs."""
|
||||||
|
|
||||||
|
|
||||||
|
AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = (
|
||||||
|
coalesce,
|
||||||
|
default_if_none,
|
||||||
|
constant,
|
||||||
|
pick_key,
|
||||||
|
truthy,
|
||||||
|
runtime_error,
|
||||||
|
first_item,
|
||||||
|
first_item_or_none,
|
||||||
|
first_item_maybe,
|
||||||
|
last_item,
|
||||||
|
last_item_or_none,
|
||||||
|
length,
|
||||||
|
is_empty,
|
||||||
|
)
|
||||||
|
"""Existing authoring ops that are also exposed through the workflow stdlib."""
|
||||||
|
|
||||||
|
|
||||||
class ToolCaller(Protocol):
|
class ToolCaller(Protocol):
|
||||||
"""Small service boundary needed by the broker-local MCP utility nodes."""
|
"""Small service boundary needed by the broker-local MCP utility nodes."""
|
||||||
|
|
||||||
@@ -56,11 +91,8 @@ class McpCallToolOutput(BaseModel):
|
|||||||
def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
|
def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
|
||||||
"""Return built-in NodeSpecs available to raw broker workflow plans."""
|
"""Return built-in NodeSpecs available to raw broker workflow plans."""
|
||||||
specs = [
|
specs = [
|
||||||
node(
|
node(spec, name=spec.name.removeprefix("authoring."))
|
||||||
runtime_error,
|
for spec in AUTHORING_STD_SPECS
|
||||||
name="runtime_error",
|
|
||||||
description="Fail the current workflow branch with a runtime error.",
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
qualified_specs = [qualify_spec(BUILTIN_CONNECTION_ID, spec) for spec in specs]
|
qualified_specs = [qualify_spec(BUILTIN_CONNECTION_ID, spec) for spec in specs]
|
||||||
return {spec.name: spec for spec in qualified_specs}
|
return {spec.name: spec for spec in qualified_specs}
|
||||||
@@ -96,12 +128,15 @@ def builtin_sources(service: ToolCaller) -> dict[str, SpecSource]:
|
|||||||
id=BUILTIN_CONNECTION_ID,
|
id=BUILTIN_CONNECTION_ID,
|
||||||
kind="system",
|
kind="system",
|
||||||
specs=builtin_specs(),
|
specs=builtin_specs(),
|
||||||
|
mcp_client_visible=True,
|
||||||
|
safe_for_workflow=True,
|
||||||
description="Workflow standard-library nodes.",
|
description="Workflow standard-library nodes.",
|
||||||
),
|
),
|
||||||
MCP_SOURCE_ID: SpecSource(
|
MCP_SOURCE_ID: SpecSource(
|
||||||
id=MCP_SOURCE_ID,
|
id=MCP_SOURCE_ID,
|
||||||
kind="system",
|
kind="system",
|
||||||
specs=mcp_specs(service),
|
specs=mcp_specs(service),
|
||||||
|
calls_upstream=True,
|
||||||
description="Broker MCP utility nodes.",
|
description="Broker MCP utility nodes.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
|
SourceKind = Literal["system", "connection"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceVisibility:
|
||||||
|
planner: bool = False
|
||||||
|
mcp_client: bool = False
|
||||||
|
admin_dashboard: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourcePermissions:
|
||||||
|
safe_for_workflow: bool = False
|
||||||
|
calls_upstream: bool = False
|
||||||
|
mutates_config: bool = False
|
||||||
|
mutates_auth: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CapabilityBuckets:
|
||||||
|
tools: dict[str, Any] = field(default_factory=dict)
|
||||||
|
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
|
prompts: dict[str, Any] = field(default_factory=dict)
|
||||||
|
resources: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CapabilitySource:
|
||||||
|
id: str
|
||||||
|
kind: SourceKind
|
||||||
|
capabilities: CapabilityBuckets = field(default_factory=CapabilityBuckets)
|
||||||
|
enabled: bool = True
|
||||||
|
visibility: SourceVisibility = field(default_factory=SourceVisibility)
|
||||||
|
permissions: SourcePermissions = field(default_factory=SourcePermissions)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
def as_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"visibility": {
|
||||||
|
"planner": self.visibility.planner,
|
||||||
|
"mcp_client": self.visibility.mcp_client,
|
||||||
|
"admin_dashboard": self.visibility.admin_dashboard,
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"safe_for_workflow": self.permissions.safe_for_workflow,
|
||||||
|
"calls_upstream": self.permissions.calls_upstream,
|
||||||
|
"mutates_config": self.permissions.mutates_config,
|
||||||
|
"mutates_auth": self.permissions.mutates_auth,
|
||||||
|
},
|
||||||
|
"description": self.description,
|
||||||
|
"tool_count": len(self.capabilities.tools),
|
||||||
|
"node_spec_count": len(self.capabilities.node_specs),
|
||||||
|
"prompt_count": len(self.capabilities.prompts),
|
||||||
|
"resource_count": len(self.capabilities.resources),
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from wf_authoring import NodeSpec, build_async_registry
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from wf_authoring import NodeReturn, NodeSpec, build_async_registry
|
||||||
from wf_core import NodeUse, Workflow, execute_workflow_async
|
from wf_core import NodeUse, Workflow, execute_workflow_async
|
||||||
|
|
||||||
from ...connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
from ...connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
||||||
@@ -19,12 +21,21 @@ from ...models import (
|
|||||||
)
|
)
|
||||||
from ...sdk import BackendAdapter
|
from ...sdk import BackendAdapter
|
||||||
from ...shared.errors import error_payload
|
from ...shared.errors import error_payload
|
||||||
|
from ...shared.names import RESERVED_CONNECTION_IDS
|
||||||
from ...storage import Store
|
from ...storage import Store
|
||||||
|
from ...workflow.wrappers import _model_from_schema
|
||||||
from ..catalog import CombinedCatalog, snapshot_from_specs
|
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 ..events import McpEvent, make_event
|
from ..events import McpEvent, make_event
|
||||||
|
from ..admin_capabilities import admin_source
|
||||||
from .adapters import require_adapter
|
from .adapters import require_adapter
|
||||||
from .builtins import builtin_sources
|
from .builtins import builtin_sources
|
||||||
|
from .capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
from .sources import SpecSource
|
from .sources import SpecSource
|
||||||
from .specs import get_qualified_spec, qualify_spec
|
from .specs import get_qualified_spec, qualify_spec
|
||||||
|
|
||||||
@@ -35,7 +46,7 @@ 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)
|
||||||
spec_sources: dict[str, SpecSource] = field(default_factory=dict)
|
capability_sources: dict[str, CapabilitySource] = field(default_factory=dict)
|
||||||
events: list[McpEvent] = field(default_factory=list)
|
events: list[McpEvent] = field(default_factory=list)
|
||||||
include_builtin_specs: bool = True
|
include_builtin_specs: bool = True
|
||||||
|
|
||||||
@@ -44,15 +55,46 @@ class WfMcpService:
|
|||||||
if self.include_builtin_specs:
|
if self.include_builtin_specs:
|
||||||
for source in builtin_sources(self).values():
|
for source in builtin_sources(self).values():
|
||||||
self.register_spec_source(source)
|
self.register_spec_source(source)
|
||||||
|
self.register_capability_source(admin_source())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spec_sources(self) -> dict[str, SpecSource]:
|
||||||
|
"""Compatibility view of node-spec capability sources."""
|
||||||
|
return {
|
||||||
|
source.id: SpecSource(
|
||||||
|
id=source.id,
|
||||||
|
kind=source.kind,
|
||||||
|
specs=dict(source.capabilities.node_specs),
|
||||||
|
visible=source.enabled and source.visibility.planner,
|
||||||
|
mcp_client_visible=source.enabled and source.visibility.mcp_client,
|
||||||
|
admin_dashboard_visible=(
|
||||||
|
source.enabled and source.visibility.admin_dashboard
|
||||||
|
),
|
||||||
|
safe_for_workflow=source.permissions.safe_for_workflow,
|
||||||
|
calls_upstream=source.permissions.calls_upstream,
|
||||||
|
mutates_config=source.permissions.mutates_config,
|
||||||
|
mutates_auth=source.permissions.mutates_auth,
|
||||||
|
description=source.description,
|
||||||
|
)
|
||||||
|
for source in self.capability_sources.values()
|
||||||
|
if source.capabilities.node_specs
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def specs_by_connection(self) -> dict[str, dict[str, NodeSpec[Any, Any]]]:
|
def specs_by_connection(self) -> dict[str, dict[str, NodeSpec[Any, Any]]]:
|
||||||
"""Compatibility view of source specs keyed by source id."""
|
"""Compatibility view of source specs keyed by source id."""
|
||||||
return {source.id: source.specs for source in self.spec_sources.values()}
|
return {
|
||||||
|
source.id: dict(source.capabilities.node_specs)
|
||||||
|
for source in self.capability_sources.values()
|
||||||
|
if source.capabilities.node_specs
|
||||||
|
}
|
||||||
|
|
||||||
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:
|
||||||
|
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._record_event(
|
self._record_event(
|
||||||
make_event(
|
make_event(
|
||||||
"connection_registered",
|
"connection_registered",
|
||||||
@@ -90,13 +132,23 @@ class WfMcpService:
|
|||||||
)
|
)
|
||||||
for spec in specs
|
for spec in specs
|
||||||
}
|
}
|
||||||
self.register_spec_source(
|
existing_source = self.capability_sources.get(connection_id)
|
||||||
SpecSource(
|
if existing_source is not None:
|
||||||
id=connection_id,
|
# Catalog refreshes replace discovered specs, not operator policy.
|
||||||
kind="connection",
|
existing_source.capabilities.node_specs = qualified_specs
|
||||||
specs=qualified_specs,
|
else:
|
||||||
description=f"Specs discovered or registered for {connection_id}.",
|
self.register_spec_source(
|
||||||
)
|
SpecSource(
|
||||||
|
id=connection_id,
|
||||||
|
kind="connection",
|
||||||
|
specs=qualified_specs,
|
||||||
|
enabled=self.connections.get(connection_id).enabled,
|
||||||
|
mcp_client_visible=True,
|
||||||
|
calls_upstream=True,
|
||||||
|
description=(
|
||||||
|
f"Specs discovered or registered for {connection_id}."
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
snapshot = snapshot_from_specs(
|
snapshot = snapshot_from_specs(
|
||||||
connection_id,
|
connection_id,
|
||||||
@@ -123,21 +175,43 @@ class WfMcpService:
|
|||||||
|
|
||||||
def get_planner_catalog(self) -> CombinedCatalog:
|
def get_planner_catalog(self) -> CombinedCatalog:
|
||||||
"""Return all planner-visible specs, including broker-local sources."""
|
"""Return all planner-visible specs, including broker-local sources."""
|
||||||
snapshots = dict(self.get_catalog().snapshots)
|
snapshots: dict[str, CatalogSnapshot] = {}
|
||||||
fetched_at_epoch_ms = int(time.time() * 1000)
|
fetched_at_epoch_ms = int(time.time() * 1000)
|
||||||
for source in self.spec_sources.values():
|
for source in self.capability_sources.values():
|
||||||
if not source.visible or source.kind != "system":
|
if not source.enabled or not source.visibility.planner:
|
||||||
continue
|
continue
|
||||||
|
stored_snapshot = self.store.load_catalog(source.id)
|
||||||
snapshots[source.id] = snapshot_from_specs(
|
snapshots[source.id] = snapshot_from_specs(
|
||||||
source.id,
|
source.id,
|
||||||
specs=source.specs,
|
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={
|
metadata={
|
||||||
"kind": source.kind,
|
"kind": source.kind,
|
||||||
"description": source.description,
|
"description": source.description,
|
||||||
},
|
}
|
||||||
fetched_at_epoch_ms=fetched_at_epoch_ms,
|
if stored_snapshot is None
|
||||||
max_age_seconds=self.default_catalog_max_age_seconds,
|
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)
|
return CombinedCatalog(snapshots=snapshots)
|
||||||
|
|
||||||
def list_spec_sources(self) -> list[dict[str, Any]]:
|
def list_spec_sources(self) -> list[dict[str, Any]]:
|
||||||
@@ -145,9 +219,12 @@ class WfMcpService:
|
|||||||
return [
|
return [
|
||||||
source.as_status()
|
source.as_status()
|
||||||
for source in sorted(
|
for source in sorted(
|
||||||
self.spec_sources.values(),
|
self.capability_sources.values(),
|
||||||
key=lambda source: source.id,
|
key=lambda source: source.id,
|
||||||
)
|
)
|
||||||
|
if source.capabilities.node_specs
|
||||||
|
and source.enabled
|
||||||
|
and source.visibility.planner
|
||||||
]
|
]
|
||||||
|
|
||||||
def list_available_specs(self) -> list[CatalogNodeEntry]:
|
def list_available_specs(self) -> list[CatalogNodeEntry]:
|
||||||
@@ -407,10 +484,7 @@ class WfMcpService:
|
|||||||
)
|
)
|
||||||
snapshot = snapshot_from_specs(
|
snapshot = snapshot_from_specs(
|
||||||
connection_id,
|
connection_id,
|
||||||
specs=self.spec_sources.get(
|
specs=self.specs_by_connection.get(connection_id, {}),
|
||||||
connection_id,
|
|
||||||
SpecSource(id=connection_id, kind="connection"),
|
|
||||||
).specs,
|
|
||||||
tool_display_names={
|
tool_display_names={
|
||||||
tool.name: tool.title for tool in capabilities.tools
|
tool.name: tool.title for tool in capabilities.tools
|
||||||
},
|
},
|
||||||
@@ -495,12 +569,81 @@ class WfMcpService:
|
|||||||
def list_events(self) -> list[McpEvent]:
|
def list_events(self) -> list[McpEvent]:
|
||||||
return list(self.events)
|
return list(self.events)
|
||||||
|
|
||||||
|
def register_capability_source(self, source: CapabilitySource) -> None:
|
||||||
|
"""Register a capability source as canonical service state."""
|
||||||
|
self.capability_sources[source.id] = source
|
||||||
|
|
||||||
def register_spec_source(self, source: SpecSource) -> None:
|
def register_spec_source(self, source: SpecSource) -> None:
|
||||||
"""Register a planner source."""
|
"""Register a legacy spec source through the capability model."""
|
||||||
self.spec_sources[source.id] = source
|
self.register_capability_source(source.as_capability_source())
|
||||||
|
|
||||||
|
def _hydrate_connection_source_from_snapshot(
|
||||||
|
self,
|
||||||
|
connection: ConnectionConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Restore planner-visible connection specs from a stored catalog snapshot."""
|
||||||
|
if connection.id in self.capability_sources:
|
||||||
|
return
|
||||||
|
|
||||||
|
snapshot = self.store.load_catalog(connection.id)
|
||||||
|
if snapshot is None or not snapshot.nodes:
|
||||||
|
return
|
||||||
|
|
||||||
|
specs = {
|
||||||
|
entry.qualified_name: self._spec_from_snapshot_entry(entry)
|
||||||
|
for entry in snapshot.nodes
|
||||||
|
}
|
||||||
|
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=f"Specs restored from catalog for {connection.id}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _spec_from_snapshot_entry(
|
||||||
|
self,
|
||||||
|
entry: CatalogNodeEntry,
|
||||||
|
) -> NodeSpec[Any, Any]:
|
||||||
|
"""Rebuild an executable tool wrapper from a stored catalog node entry."""
|
||||||
|
model_prefix = entry.qualified_name.replace(".", "_").replace("-", "_")
|
||||||
|
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
|
||||||
|
output_model = _model_from_schema(f"{model_prefix}_Output", entry.output_schema)
|
||||||
|
|
||||||
|
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
|
||||||
|
result = await self.call_tool(
|
||||||
|
entry.connection_id,
|
||||||
|
entry.local_name,
|
||||||
|
arguments=payload.model_dump(),
|
||||||
|
)
|
||||||
|
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=entry.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.spec_sources, qualified_name)
|
return get_qualified_spec(self.capability_sources, qualified_name)
|
||||||
|
|
||||||
def _record_event(self, event: McpEvent) -> None:
|
def _record_event(self, event: McpEvent) -> None:
|
||||||
self.events.append(event)
|
self.events.append(event)
|
||||||
|
|||||||
@@ -1,33 +1,59 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any
|
||||||
|
|
||||||
from wf_authoring import NodeSpec
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
SpecSourceKind = Literal["connection", "system"]
|
from .capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourceKind,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SpecSource:
|
class SpecSource:
|
||||||
"""Planner-visible collection of workflow node specs.
|
"""Compatibility wrapper for planner node specs.
|
||||||
|
|
||||||
Connection sources come from proxied MCP servers. System sources are local broker
|
Visibility and permissions stay explicit so callers do not infer source
|
||||||
capabilities, such as workflow stdlib nodes or service-bound MCP control nodes.
|
semantics from the legacy ``kind`` field during the capability-source move.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
kind: SpecSourceKind
|
kind: SourceKind
|
||||||
specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
visible: bool = True
|
visible: bool = True
|
||||||
|
mcp_client_visible: bool = False
|
||||||
|
admin_dashboard_visible: bool = True
|
||||||
|
safe_for_workflow: bool = False
|
||||||
|
calls_upstream: bool = False
|
||||||
|
mutates_config: bool = False
|
||||||
|
mutates_auth: bool = False
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
def as_capability_source(self) -> CapabilitySource:
|
||||||
|
return CapabilitySource(
|
||||||
|
id=self.id,
|
||||||
|
kind=self.kind,
|
||||||
|
capabilities=CapabilityBuckets(node_specs=dict(self.specs)),
|
||||||
|
enabled=self.enabled,
|
||||||
|
visibility=SourceVisibility(
|
||||||
|
planner=self.visible,
|
||||||
|
mcp_client=self.mcp_client_visible,
|
||||||
|
admin_dashboard=self.admin_dashboard_visible,
|
||||||
|
),
|
||||||
|
permissions=SourcePermissions(
|
||||||
|
safe_for_workflow=self.safe_for_workflow,
|
||||||
|
calls_upstream=self.calls_upstream,
|
||||||
|
mutates_config=self.mutates_config,
|
||||||
|
mutates_auth=self.mutates_auth,
|
||||||
|
),
|
||||||
|
description=self.description,
|
||||||
|
)
|
||||||
|
|
||||||
def as_status(self) -> dict[str, Any]:
|
def as_status(self) -> dict[str, Any]:
|
||||||
"""Return a compact payload suitable for UI and debugging surfaces."""
|
return self.as_capability_source().as_status()
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"kind": self.kind,
|
|
||||||
"visible": self.visible,
|
|
||||||
"description": self.description,
|
|
||||||
"spec_count": len(self.specs),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from wf_authoring import NodeSpec
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
from ...connections import qualify_node_name
|
from ...connections import qualify_node_name
|
||||||
from .sources import SpecSource
|
from .capability_sources import CapabilitySource
|
||||||
|
|
||||||
|
|
||||||
def qualify_spec(connection_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
|
def qualify_spec(connection_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
|
||||||
@@ -27,12 +26,14 @@ def qualify_spec(connection_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any,
|
|||||||
|
|
||||||
|
|
||||||
def get_qualified_spec(
|
def get_qualified_spec(
|
||||||
spec_sources: Mapping[str, SpecSource],
|
sources: Mapping[str, CapabilitySource],
|
||||||
qualified_name: str,
|
qualified_name: str,
|
||||||
) -> NodeSpec[Any, Any]:
|
) -> NodeSpec[Any, Any]:
|
||||||
"""Resolve a namespaced node spec from planner-visible sources."""
|
"""Resolve a namespaced node spec from enabled planner-visible sources."""
|
||||||
source_id, _ = qualified_name.rsplit(".", 1)
|
for source in sources.values():
|
||||||
source = spec_sources.get(source_id)
|
if not source.enabled or not source.visibility.planner:
|
||||||
if source is None or qualified_name not in source.specs:
|
continue
|
||||||
raise KeyError(f"unknown qualified node {qualified_name!r}")
|
spec = source.capabilities.node_specs.get(qualified_name)
|
||||||
return source.specs[qualified_name]
|
if spec is not None:
|
||||||
|
return spec
|
||||||
|
raise KeyError(f"unknown qualified node {qualified_name!r}")
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from .service import WfMcpService
|
|||||||
def register_broker_tools(server: FastMCP, service: WfMcpService) -> None:
|
def register_broker_tools(server: FastMCP, service: WfMcpService) -> None:
|
||||||
"""Register broker tool handlers on a FastMCP server."""
|
"""Register broker tool handlers on a FastMCP server."""
|
||||||
|
|
||||||
|
# These MCP tool names are compatibility exports. Their capability metadata
|
||||||
|
# belongs to the wf.admin source; future admin-enabled servers can project
|
||||||
|
# dotted wf.admin.* names from that source.
|
||||||
@server.tool()
|
@server.tool()
|
||||||
async def list_connections() -> list[dict[str, Any]]:
|
async def list_connections() -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import re
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from .models import BrokerConfig, ConnectionConfig
|
from .models import BrokerConfig, ConnectionConfig
|
||||||
|
from .shared.names import RESERVED_CONNECTION_IDS
|
||||||
|
|
||||||
_NAMESPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*$")
|
_NAMESPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*$")
|
||||||
_SUPPORTED_TRANSPORTS = {"stdio", "http", "streamable-http", "streamable_http", "sse"}
|
_SUPPORTED_TRANSPORTS = {"stdio", "http", "streamable-http", "streamable_http", "sse"}
|
||||||
_RESERVED_CONNECTION_IDS = {"wf.mcp"}
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyConfigError(ValueError):
|
class ProxyConfigError(ValueError):
|
||||||
"""Raised when a broker config cannot safely run as a transparent proxy."""
|
"""Raised when a broker config cannot safely run as a transparent proxy."""
|
||||||
|
|
||||||
@@ -51,7 +49,7 @@ def _validate_connection_ids(
|
|||||||
if not connection_id:
|
if not connection_id:
|
||||||
errors.append("connection id must not be empty")
|
errors.append("connection id must not be empty")
|
||||||
continue
|
continue
|
||||||
if connection_id in _RESERVED_CONNECTION_IDS:
|
if connection_id in RESERVED_CONNECTION_IDS:
|
||||||
errors.append(f"connection id {connection_id!r} is reserved by wf-mcp")
|
errors.append(f"connection id {connection_id!r} is reserved by wf-mcp")
|
||||||
if "_" in connection_id:
|
if "_" in connection_id:
|
||||||
errors.append(
|
errors.append(
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from fastmcp.server.transforms import Namespace
|
from fastmcp.server.transforms import Namespace
|
||||||
|
|
||||||
ADMIN_NAMESPACE = "wf.mcp"
|
ADMIN_NAMESPACE = "wf.admin"
|
||||||
|
RESERVED_CONNECTION_IDS = frozenset({ADMIN_NAMESPACE, "wf.mcp"})
|
||||||
|
"""Source ids reserved by wf-mcp system capabilities."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -41,10 +43,14 @@ def parse_namespaced_tool_name(
|
|||||||
|
|
||||||
|
|
||||||
def is_admin_tool_name(proxy_name: str) -> bool:
|
def is_admin_tool_name(proxy_name: str) -> bool:
|
||||||
return proxy_name.startswith(f"{ADMIN_NAMESPACE}_")
|
return proxy_name.startswith(f"{ADMIN_NAMESPACE}.") or proxy_name.startswith(
|
||||||
|
f"{ADMIN_NAMESPACE}_"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LdaNamespace(Namespace):
|
class LdaNamespace(Namespace):
|
||||||
def __init__(self, prefix: str) -> None:
|
def __init__(self, prefix: str) -> None:
|
||||||
super().__init__(prefix)
|
super().__init__(prefix)
|
||||||
self._name_prefix = f"{prefix}." # some good stuff
|
# FastMCP's public Namespace transform uses underscores; override its
|
||||||
|
# private prefix so admin tools keep their dotted wf.admin.* names.
|
||||||
|
self._name_prefix = f"{prefix}."
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from fastmcp.server.transforms.search import BM25SearchTransform
|
|||||||
|
|
||||||
from ..control import BrokerConfigManager, ConfigMutationError
|
from ..control import BrokerConfigManager, ConfigMutationError
|
||||||
from ..models import BrokerConfig
|
from ..models import BrokerConfig
|
||||||
from ..shared.names import ADMIN_NAMESPACE
|
from ..shared.names import ADMIN_NAMESPACE, LdaNamespace
|
||||||
from ..proxy_config import broker_config_to_fastmcp_config
|
from ..proxy_config import broker_config_to_fastmcp_config
|
||||||
from ..proxy_validation import validate_transparent_proxy_config
|
from ..proxy_validation import validate_transparent_proxy_config
|
||||||
from .admin import create_proxy_admin_server
|
from .admin import create_proxy_admin_server
|
||||||
@@ -24,17 +24,17 @@ from .tools import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ADMIN_TOOL_NAMES = [
|
_ADMIN_TOOL_NAMES = [
|
||||||
f"{ADMIN_NAMESPACE}_list_connections",
|
f"{ADMIN_NAMESPACE}.list_connections",
|
||||||
f"{ADMIN_NAMESPACE}_get_connection_statuses",
|
f"{ADMIN_NAMESPACE}.get_connection_statuses",
|
||||||
f"{ADMIN_NAMESPACE}_get_config",
|
f"{ADMIN_NAMESPACE}.get_config",
|
||||||
f"{ADMIN_NAMESPACE}_reload_config",
|
f"{ADMIN_NAMESPACE}.reload_config",
|
||||||
f"{ADMIN_NAMESPACE}_list_proxy_tools",
|
f"{ADMIN_NAMESPACE}.list_proxy_tools",
|
||||||
f"{ADMIN_NAMESPACE}_get_proxy_tool",
|
f"{ADMIN_NAMESPACE}.get_proxy_tool",
|
||||||
f"{ADMIN_NAMESPACE}_add_connection",
|
f"{ADMIN_NAMESPACE}.add_connection",
|
||||||
f"{ADMIN_NAMESPACE}_update_connection",
|
f"{ADMIN_NAMESPACE}.update_connection",
|
||||||
f"{ADMIN_NAMESPACE}_enable_connection",
|
f"{ADMIN_NAMESPACE}.enable_connection",
|
||||||
f"{ADMIN_NAMESPACE}_disable_connection",
|
f"{ADMIN_NAMESPACE}.disable_connection",
|
||||||
f"{ADMIN_NAMESPACE}_remove_connection",
|
f"{ADMIN_NAMESPACE}.remove_connection",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class TransparentProxyRuntime:
|
|||||||
self.server.providers[:] = [self.server.local_provider]
|
self.server.providers[:] = [self.server.local_provider]
|
||||||
|
|
||||||
admin = create_proxy_admin_server(self)
|
admin = create_proxy_admin_server(self)
|
||||||
admin.add_transform(Namespace(ADMIN_NAMESPACE))
|
admin.add_transform(LdaNamespace(ADMIN_NAMESPACE))
|
||||||
self.server.mount(admin)
|
self.server.mount(admin)
|
||||||
|
|
||||||
mounted_connections: list[str] = []
|
mounted_connections: list[str] = []
|
||||||
|
|||||||
@@ -97,6 +97,20 @@ def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
|
|||||||
assert "wf.std" in source_ids
|
assert "wf.std" in source_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_admin_tools_are_backed_by_wf_admin_source() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "broker_admin_source"))
|
||||||
|
server = create_broker_server(service)
|
||||||
|
|
||||||
|
tools = asyncio.run(server.list_tools())
|
||||||
|
tool_names = {tool.name for tool in tools}
|
||||||
|
|
||||||
|
assert "list_spec_sources" in tool_names
|
||||||
|
assert "get_planner_catalog" in tool_names
|
||||||
|
assert "wf.admin.list_sources" in service.capability_sources[
|
||||||
|
"wf.admin"
|
||||||
|
].capabilities.tools
|
||||||
|
|
||||||
|
|
||||||
def test_build_service_from_config_registers_connections() -> None:
|
def test_build_service_from_config_registers_connections() -> None:
|
||||||
config = BrokerConfig(
|
config = BrokerConfig(
|
||||||
store_root=local_temp_root() / "broker_config_store",
|
store_root=local_temp_root() / "broker_config_store",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from wf_mcp.shared.names import (
|
from wf_mcp.shared.names import (
|
||||||
|
ADMIN_NAMESPACE,
|
||||||
is_admin_tool_name,
|
is_admin_tool_name,
|
||||||
namespaced_tool_name,
|
namespaced_tool_name,
|
||||||
parse_namespaced_tool_name,
|
parse_namespaced_tool_name,
|
||||||
@@ -23,5 +24,11 @@ def test_namespaced_tool_names_are_reversible_with_known_connections() -> None:
|
|||||||
|
|
||||||
def test_namespaced_tool_parser_rejects_unknown_and_admin_names() -> None:
|
def test_namespaced_tool_parser_rejects_unknown_and_admin_names() -> None:
|
||||||
assert parse_namespaced_tool_name("missing_echo", {"everything.default"}) is None
|
assert parse_namespaced_tool_name("missing_echo", {"everything.default"}) is None
|
||||||
assert is_admin_tool_name("wf.mcp_list_connections") is True
|
assert is_admin_tool_name("wf.admin.list_connections") is True
|
||||||
assert is_admin_tool_name("everything.default_echo") is False
|
assert is_admin_tool_name("everything.default_echo") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_namespace_is_distinct_from_wf_mcp_runtime_source() -> None:
|
||||||
|
assert ADMIN_NAMESPACE == "wf.admin"
|
||||||
|
assert is_admin_tool_name("wf.admin.list_connections") is True
|
||||||
|
assert is_admin_tool_name("wf.mcp.call_tool") is False
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from wf_core import END, RunStatus
|
from wf_authoring import NodeSpec
|
||||||
|
from wf_core import END, NodeUse, RunStatus
|
||||||
from wf_mcp.broker import WfMcpService
|
from wf_mcp.broker import WfMcpService
|
||||||
|
from wf_mcp.broker.service.capability_sources import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan
|
from wf_mcp.models import AuthRecord, ConnectionConfig, RawWorkflowPlan
|
||||||
from wf_mcp.shared.errors import error_payload
|
from wf_mcp.shared.errors import error_payload
|
||||||
from wf_mcp.storage import FileStore
|
from wf_mcp.storage import FileStore
|
||||||
@@ -18,6 +24,36 @@ from .test_support import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single_echo_plan(plan_name: str, node_name: str) -> RawWorkflowPlan:
|
||||||
|
return RawWorkflowPlan(
|
||||||
|
name=plan_name,
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
state_schema={"fields": {"echoed": {"type": "string"}}},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"echoed": {"type": "string"}},
|
||||||
|
"required": ["echoed"],
|
||||||
|
},
|
||||||
|
start="echo",
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"id": "echo",
|
||||||
|
"type": "node",
|
||||||
|
"node": node_name,
|
||||||
|
"in_map": {"input.text": "text"},
|
||||||
|
"out_map": {"echoed": "state.echoed"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
{"from": "echo", "outcome": "ok", "to": END},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_service_builds_namespaced_catalog() -> None:
|
def test_service_builds_namespaced_catalog() -> None:
|
||||||
service = WfMcpService(store=FileStore(local_temp_root() / "catalog_store"))
|
service = WfMcpService(store=FileStore(local_temp_root() / "catalog_store"))
|
||||||
service.register_connection(
|
service.register_connection(
|
||||||
@@ -34,6 +70,21 @@ def test_service_builds_namespaced_catalog() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_rejects_reserved_connection_ids() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "reserved_ids_store"))
|
||||||
|
|
||||||
|
for connection_id in ("wf.admin", "wf.mcp"):
|
||||||
|
try:
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id=connection_id, server="wf", account="reserved")
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert connection_id in str(exc)
|
||||||
|
assert "reserved by wf-mcp" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"expected {connection_id!r} to be rejected")
|
||||||
|
|
||||||
|
|
||||||
def test_service_installs_builtin_stdlib_specs_by_default() -> None:
|
def test_service_installs_builtin_stdlib_specs_by_default() -> None:
|
||||||
service = WfMcpService(store=FileStore(local_temp_root() / "builtin_store"))
|
service = WfMcpService(store=FileStore(local_temp_root() / "builtin_store"))
|
||||||
|
|
||||||
@@ -47,6 +98,95 @@ def test_service_installs_builtin_stdlib_specs_by_default() -> None:
|
|||||||
assert all(source["kind"] == "system" for source in sources)
|
assert all(source["kind"] == "system" for source in sources)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_std_source_contains_authoring_ops() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "stdlib_source_store"))
|
||||||
|
specs = service.capability_sources["wf.std"].capabilities.node_specs
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"wf.std.coalesce",
|
||||||
|
"wf.std.default_if_none",
|
||||||
|
"wf.std.constant",
|
||||||
|
"wf.std.pick_key",
|
||||||
|
"wf.std.truthy",
|
||||||
|
"wf.std.runtime_error",
|
||||||
|
"wf.std.first_item",
|
||||||
|
"wf.std.first_item_or_none",
|
||||||
|
"wf.std.first_item_maybe",
|
||||||
|
"wf.std.last_item",
|
||||||
|
"wf.std.last_item_or_none",
|
||||||
|
"wf.std.length",
|
||||||
|
"wf.std.is_empty",
|
||||||
|
}
|
||||||
|
assert set(specs) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_sources_have_visibility_and_capability_buckets() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "source_shape_store"))
|
||||||
|
|
||||||
|
std_source = service.capability_sources["wf.std"]
|
||||||
|
mcp_source = service.capability_sources["wf.mcp"]
|
||||||
|
|
||||||
|
assert std_source.id == "wf.std"
|
||||||
|
assert std_source.kind == "system"
|
||||||
|
assert std_source.visibility.planner is True
|
||||||
|
assert std_source.visibility.mcp_client is True
|
||||||
|
assert std_source.visibility.admin_dashboard is True
|
||||||
|
assert "wf.std.runtime_error" in std_source.capabilities.node_specs
|
||||||
|
assert not std_source.capabilities.tools
|
||||||
|
|
||||||
|
assert mcp_source.id == "wf.mcp"
|
||||||
|
assert mcp_source.visibility.planner is True
|
||||||
|
assert mcp_source.permissions.calls_upstream is True
|
||||||
|
assert "wf.mcp.call_tool" in mcp_source.capabilities.node_specs
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_admin_source_exists_but_is_not_planner_visible() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "admin_source_store"))
|
||||||
|
source = service.capability_sources["wf.admin"]
|
||||||
|
|
||||||
|
assert source.kind == "system"
|
||||||
|
assert source.visibility.planner is False
|
||||||
|
assert source.visibility.mcp_client is False
|
||||||
|
assert source.visibility.admin_dashboard is True
|
||||||
|
assert source.permissions.safe_for_workflow is False
|
||||||
|
assert source.permissions.calls_upstream is False
|
||||||
|
assert source.permissions.mutates_config is True
|
||||||
|
assert source.permissions.mutates_auth is True
|
||||||
|
assert "wf.admin.list_sources" in source.capabilities.tools
|
||||||
|
assert "wf.admin.disable_source" in source.capabilities.tools
|
||||||
|
assert "wf.admin.enable_source" in source.capabilities.tools
|
||||||
|
assert "wf.admin" not in service.get_planner_catalog().snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_spec_views_are_derived_from_capability_sources() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "source_view_store"))
|
||||||
|
|
||||||
|
assert "wf.std" in service.capability_sources
|
||||||
|
assert "wf.std" in service.spec_sources
|
||||||
|
assert "wf.std" in service.specs_by_connection
|
||||||
|
assert (
|
||||||
|
service.spec_sources["wf.std"].specs
|
||||||
|
is not service.capability_sources["wf.std"].capabilities.node_specs
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
service.specs_by_connection["wf.std"]
|
||||||
|
is not service.capability_sources["wf.std"].capabilities.node_specs
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
service.specs_by_connection["wf.std"]["wf.std.runtime_error"]
|
||||||
|
is service.capability_sources["wf.std"].capabilities.node_specs[
|
||||||
|
"wf.std.runtime_error"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
service.spec_sources["wf.std"].specs.clear()
|
||||||
|
service.specs_by_connection["wf.std"].clear()
|
||||||
|
assert (
|
||||||
|
"wf.std.runtime_error"
|
||||||
|
in service.capability_sources["wf.std"].capabilities.node_specs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_service_can_disable_builtin_stdlib_specs() -> None:
|
def test_service_can_disable_builtin_stdlib_specs() -> None:
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(local_temp_root() / "no_builtin_store"),
|
store=FileStore(local_temp_root() / "no_builtin_store"),
|
||||||
@@ -58,6 +198,36 @@ def test_service_can_disable_builtin_stdlib_specs() -> None:
|
|||||||
assert service.list_spec_sources() == []
|
assert service.list_spec_sources() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_list_spec_sources_excludes_hidden_sources() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "hidden_list_store"))
|
||||||
|
hidden_echo_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,
|
||||||
|
)
|
||||||
|
service.register_capability_source(
|
||||||
|
CapabilitySource(
|
||||||
|
id="hidden.source",
|
||||||
|
kind="system",
|
||||||
|
capabilities=CapabilityBuckets(
|
||||||
|
node_specs={"hidden.source.echo_tool": hidden_echo_tool}
|
||||||
|
),
|
||||||
|
visibility=SourceVisibility(planner=False, admin_dashboard=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
source_ids = {source["id"] for source in service.list_spec_sources()}
|
||||||
|
|
||||||
|
assert "hidden.source" not in source_ids
|
||||||
|
|
||||||
|
|
||||||
def test_service_catalog_split_keeps_system_specs_out_of_backend_catalog() -> None:
|
def test_service_catalog_split_keeps_system_specs_out_of_backend_catalog() -> None:
|
||||||
service = WfMcpService(store=FileStore(local_temp_root() / "planner_store"))
|
service = WfMcpService(store=FileStore(local_temp_root() / "planner_store"))
|
||||||
|
|
||||||
@@ -65,14 +235,44 @@ def test_service_catalog_split_keeps_system_specs_out_of_backend_catalog() -> No
|
|||||||
planner_payload = service.get_planner_catalog().as_payload()
|
planner_payload = service.get_planner_catalog().as_payload()
|
||||||
|
|
||||||
assert backend_payload["nodes"] == []
|
assert backend_payload["nodes"] == []
|
||||||
assert [node["qualified_name"] for node in planner_payload["nodes"]] == [
|
planner_node_names = {node["qualified_name"] for node in planner_payload["nodes"]}
|
||||||
"wf.mcp.call_tool",
|
assert "wf.mcp.call_tool" in planner_node_names
|
||||||
"wf.std.runtime_error",
|
assert "wf.std.runtime_error" in planner_node_names
|
||||||
]
|
available_names = {entry.qualified_name for entry in service.list_available_specs()}
|
||||||
assert [entry.qualified_name for entry in service.list_available_specs()] == [
|
assert "wf.mcp.call_tool" in available_names
|
||||||
"wf.mcp.call_tool",
|
assert "wf.std.runtime_error" in available_names
|
||||||
"wf.std.runtime_error",
|
|
||||||
]
|
|
||||||
|
def test_service_hydrates_planner_specs_from_stored_catalog() -> None:
|
||||||
|
store = local_temp_root() / "restart_planner_store"
|
||||||
|
shutil.rmtree(store, ignore_errors=True)
|
||||||
|
first_service = WfMcpService(store=FileStore(store))
|
||||||
|
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(store))
|
||||||
|
second_service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
second_service.register_adapter("demo", FakeAdapter())
|
||||||
|
|
||||||
|
planner_names = {
|
||||||
|
node["qualified_name"]
|
||||||
|
for node in second_service.get_planner_catalog().as_payload()["nodes"]
|
||||||
|
}
|
||||||
|
run = asyncio.run(
|
||||||
|
second_service.run_workflow_from_plan(
|
||||||
|
_single_echo_plan("hydrated_plan", "demo.personal.echo_tool"),
|
||||||
|
{"text": "hello"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "demo.personal.echo_tool" in planner_names
|
||||||
|
assert run.status == RunStatus.COMPLETED
|
||||||
|
assert run.output["echoed"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
def test_service_compiles_and_runs_raw_plan() -> None:
|
def test_service_compiles_and_runs_raw_plan() -> None:
|
||||||
@@ -132,6 +332,237 @@ def test_service_compiles_and_runs_raw_plan() -> None:
|
|||||||
assert "workflow_run_completed" in event_kinds
|
assert "workflow_run_completed" in event_kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_resolves_registered_spec_with_dotted_local_name() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "dotted_spec_store"))
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
dotted_echo_tool = NodeSpec(
|
||||||
|
name="foo.bar",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", dotted_echo_tool)
|
||||||
|
|
||||||
|
plan = RawWorkflowPlan(
|
||||||
|
name="dotted_local_name_plan",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
state_schema={"fields": {"echoed": {"type": "string"}}},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"echoed": {"type": "string"}},
|
||||||
|
"required": ["echoed"],
|
||||||
|
},
|
||||||
|
start="echo",
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"id": "echo",
|
||||||
|
"type": "node",
|
||||||
|
"node": "demo.personal.foo.bar",
|
||||||
|
"in_map": {"input.text": "text"},
|
||||||
|
"out_map": {"echoed": "state.echoed"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
{"from": "echo", "outcome": "ok", "to": END},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow = service.compile_plan(plan)
|
||||||
|
run = asyncio.run(service.run_workflow_from_plan(plan, {"text": "hello"}))
|
||||||
|
|
||||||
|
assert workflow.name == "dotted_local_name_plan"
|
||||||
|
first_node = workflow.nodes[0]
|
||||||
|
assert isinstance(first_node, NodeUse)
|
||||||
|
assert first_node.node == "demo.personal.foo.bar"
|
||||||
|
assert run.status == RunStatus.COMPLETED
|
||||||
|
assert run.output["echoed"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_does_not_resolve_specs_hidden_from_planner() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "hidden_spec_store"))
|
||||||
|
hidden_echo_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,
|
||||||
|
)
|
||||||
|
service.register_capability_source(
|
||||||
|
CapabilitySource(
|
||||||
|
id="hidden.source",
|
||||||
|
kind="system",
|
||||||
|
capabilities=CapabilityBuckets(
|
||||||
|
node_specs={"hidden.source.echo_tool": hidden_echo_tool}
|
||||||
|
),
|
||||||
|
visibility=SourceVisibility(planner=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = RawWorkflowPlan(
|
||||||
|
name="hidden_source_plan",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
state_schema={"fields": {"echoed": {"type": "string"}}},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"echoed": {"type": "string"}},
|
||||||
|
"required": ["echoed"],
|
||||||
|
},
|
||||||
|
start="echo",
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"id": "echo",
|
||||||
|
"type": "node",
|
||||||
|
"node": "hidden.source.echo_tool",
|
||||||
|
"in_map": {"input.text": "text"},
|
||||||
|
"out_map": {"echoed": "state.echoed"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
{"from": "echo", "outcome": "ok", "to": END},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.compile_plan(plan)
|
||||||
|
except KeyError as exc:
|
||||||
|
assert "hidden.source.echo_tool" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected planner-hidden spec resolution to fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_excludes_disabled_connection_specs_from_planner_catalog() -> None:
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "disabled_connection_spec_store")
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", echo_tool)
|
||||||
|
service.capability_sources["demo.personal"].enabled = False
|
||||||
|
|
||||||
|
planner_payload = service.get_planner_catalog().as_payload()
|
||||||
|
planner_names = [
|
||||||
|
node["qualified_name"] for node in planner_payload["nodes"]
|
||||||
|
]
|
||||||
|
available_names = [
|
||||||
|
entry.qualified_name for entry in service.list_available_specs()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "demo.personal.echo_tool" not in planner_names
|
||||||
|
assert "demo.personal.echo_tool" not in available_names
|
||||||
|
assert "demo.personal" not in service.get_planner_catalog().snapshots
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.compile_plan(
|
||||||
|
_single_echo_plan("disabled_connection_plan", "demo.personal.echo_tool")
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
assert "demo.personal.echo_tool" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected disabled connection spec resolution to fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_preserves_disabled_connection_source_on_reregistration() -> None:
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "disabled_reregister_spec_store")
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", echo_tool)
|
||||||
|
service.capability_sources["demo.personal"].enabled = False
|
||||||
|
|
||||||
|
service.register_specs("demo.personal", finalize_tool)
|
||||||
|
|
||||||
|
source = service.capability_sources["demo.personal"]
|
||||||
|
assert source.enabled is False
|
||||||
|
assert "demo.personal.finalize_tool" in source.capabilities.node_specs
|
||||||
|
assert "demo.personal.echo_tool" not in source.capabilities.node_specs
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_excludes_planner_hidden_connection_specs_from_planner_catalog() -> None:
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "hidden_connection_spec_store")
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", echo_tool)
|
||||||
|
service.capability_sources["demo.personal"].visibility = SourceVisibility(
|
||||||
|
planner=False,
|
||||||
|
mcp_client=True,
|
||||||
|
admin_dashboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
planner_payload = service.get_planner_catalog().as_payload()
|
||||||
|
planner_names = [
|
||||||
|
node["qualified_name"] for node in planner_payload["nodes"]
|
||||||
|
]
|
||||||
|
available_names = [
|
||||||
|
entry.qualified_name for entry in service.list_available_specs()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "demo.personal.echo_tool" not in planner_names
|
||||||
|
assert "demo.personal.echo_tool" not in available_names
|
||||||
|
assert "demo.personal" not in service.get_planner_catalog().snapshots
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.compile_plan(
|
||||||
|
_single_echo_plan("hidden_connection_plan", "demo.personal.echo_tool")
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
assert "demo.personal.echo_tool" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError(
|
||||||
|
"expected planner-hidden connection spec resolution to fail"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_preserves_planner_hidden_connection_source_on_reregistration() -> None:
|
||||||
|
service = WfMcpService(
|
||||||
|
store=FileStore(local_temp_root() / "hidden_reregister_spec_store")
|
||||||
|
)
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_specs("demo.personal", echo_tool)
|
||||||
|
service.capability_sources["demo.personal"].visibility = SourceVisibility(
|
||||||
|
planner=False,
|
||||||
|
mcp_client=True,
|
||||||
|
admin_dashboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
service.register_specs("demo.personal", finalize_tool)
|
||||||
|
|
||||||
|
source = service.capability_sources["demo.personal"]
|
||||||
|
assert source.visibility.planner is False
|
||||||
|
assert source.visibility.mcp_client is True
|
||||||
|
assert source.visibility.admin_dashboard is True
|
||||||
|
assert "demo.personal.finalize_tool" in source.capabilities.node_specs
|
||||||
|
assert "demo.personal.echo_tool" not in source.capabilities.node_specs
|
||||||
|
|
||||||
|
|
||||||
def test_service_refreshes_catalog_from_adapter() -> None:
|
def test_service_refreshes_catalog_from_adapter() -> None:
|
||||||
service = WfMcpService(store=FileStore(local_temp_root() / "adapter_store"))
|
service = WfMcpService(store=FileStore(local_temp_root() / "adapter_store"))
|
||||||
service.register_connection(
|
service.register_connection(
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
|||||||
async with client:
|
async with client:
|
||||||
tools = await client.list_tools()
|
tools = await client.list_tools()
|
||||||
names = [tool.name for tool in tools]
|
names = [tool.name for tool in tools]
|
||||||
assert "wf.mcp_list_connections" in names
|
assert "wf.admin.list_connections" in names
|
||||||
assert "wf.mcp_get_connection_statuses" in names
|
assert "wf.admin.get_connection_statuses" in names
|
||||||
assert "wf.mcp_list_proxy_tools" in names
|
assert "wf.admin.list_proxy_tools" in names
|
||||||
assert "wf.mcp_get_proxy_tool" in names
|
assert "wf.admin.get_proxy_tool" in names
|
||||||
assert "fixture.personal_echo_tool" in names
|
assert "fixture.personal_echo_tool" in names
|
||||||
|
|
||||||
connections_result = await client.call_tool("wf.mcp_list_connections")
|
connections_result = await client.call_tool("wf.admin.list_connections")
|
||||||
assert _structured(connections_result) == {
|
assert _structured(connections_result) == {
|
||||||
"result": [
|
"result": [
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
|||||||
)
|
)
|
||||||
assert _structured(result) == {"echoed": "hello"}
|
assert _structured(result) == {"echoed": "hello"}
|
||||||
|
|
||||||
proxy_tools_result = await client.call_tool("wf.mcp_list_proxy_tools")
|
proxy_tools_result = await client.call_tool("wf.admin.list_proxy_tools")
|
||||||
proxy_tools_payload = _structured(proxy_tools_result)
|
proxy_tools_payload = _structured(proxy_tools_result)
|
||||||
proxy_tools = proxy_tools_payload["tools"]
|
proxy_tools = proxy_tools_payload["tools"]
|
||||||
assert proxy_tools_payload["nextCursor"] is None
|
assert proxy_tools_payload["nextCursor"] is None
|
||||||
@@ -84,7 +84,7 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
|||||||
assert proxy_tools[0]["enabled"] is True
|
assert proxy_tools[0]["enabled"] is True
|
||||||
|
|
||||||
proxy_tool_result = await client.call_tool(
|
proxy_tool_result = await client.call_tool(
|
||||||
"wf.mcp_get_proxy_tool",
|
"wf.admin.get_proxy_tool",
|
||||||
{"proxy_name": "fixture.personal_echo_tool"},
|
{"proxy_name": "fixture.personal_echo_tool"},
|
||||||
)
|
)
|
||||||
proxy_tool = _structured(proxy_tool_result)
|
proxy_tool = _structured(proxy_tool_result)
|
||||||
@@ -130,6 +130,12 @@ def test_transparent_proxy_rejects_invalid_connection_config() -> None:
|
|||||||
account="mcp",
|
account="mcp",
|
||||||
metadata={"transport": "stdio", "command": sys.executable},
|
metadata={"transport": "stdio", "command": sys.executable},
|
||||||
),
|
),
|
||||||
|
ConnectionConfig(
|
||||||
|
id="wf.admin",
|
||||||
|
server="wf",
|
||||||
|
account="admin",
|
||||||
|
metadata={"transport": "stdio", "command": sys.executable},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,6 +149,7 @@ def test_transparent_proxy_rejects_invalid_connection_config() -> None:
|
|||||||
assert "connection id 'bad_scope.personal' must not contain '_'" in message
|
assert "connection id 'bad_scope.personal' must not contain '_'" in message
|
||||||
assert "fixture.http: http transport requires metadata.url" in message
|
assert "fixture.http: http transport requires metadata.url" in message
|
||||||
assert "connection id 'wf.mcp' is reserved by wf-mcp" in message
|
assert "connection id 'wf.mcp' is reserved by wf-mcp" in message
|
||||||
|
assert "connection id 'wf.admin' is reserved by wf-mcp" in message
|
||||||
|
|
||||||
|
|
||||||
def test_transparent_proxy_can_expose_resources_and_prompts_as_tools() -> None:
|
def test_transparent_proxy_can_expose_resources_and_prompts_as_tools() -> None:
|
||||||
@@ -202,9 +209,9 @@ def test_transparent_proxy_can_collapse_upstream_tools_behind_search() -> None:
|
|||||||
tools = await client.list_tools()
|
tools = await client.list_tools()
|
||||||
names = [tool.name for tool in tools]
|
names = [tool.name for tool in tools]
|
||||||
assert "search_tools" in names
|
assert "search_tools" in names
|
||||||
assert "wf.mcp_list_connections" in names
|
assert "wf.admin.list_connections" in names
|
||||||
assert "wf.mcp_get_connection_statuses" in names
|
assert "wf.admin.get_connection_statuses" in names
|
||||||
assert "wf.mcp_list_proxy_tools" in names
|
assert "wf.admin.list_proxy_tools" in names
|
||||||
assert "fixture.personal_echo_tool" not in names
|
assert "fixture.personal_echo_tool" not in names
|
||||||
|
|
||||||
search_result = await client.call_tool(
|
search_result = await client.call_tool(
|
||||||
@@ -247,7 +254,7 @@ def test_transparent_proxy_proxy_tool_listing_supports_filters_and_cursor() -> N
|
|||||||
client = create_transparent_proxy_client(config)
|
client = create_transparent_proxy_client(config)
|
||||||
async with client:
|
async with client:
|
||||||
first_page_result = await client.call_tool(
|
first_page_result = await client.call_tool(
|
||||||
"wf.mcp_list_proxy_tools",
|
"wf.admin.list_proxy_tools",
|
||||||
{"limit": 1},
|
{"limit": 1},
|
||||||
)
|
)
|
||||||
first_page = _structured(first_page_result)
|
first_page = _structured(first_page_result)
|
||||||
@@ -256,7 +263,7 @@ def test_transparent_proxy_proxy_tool_listing_supports_filters_and_cursor() -> N
|
|||||||
assert first_page["total"] == 2
|
assert first_page["total"] == 2
|
||||||
|
|
||||||
second_page_result = await client.call_tool(
|
second_page_result = await client.call_tool(
|
||||||
"wf.mcp_list_proxy_tools",
|
"wf.admin.list_proxy_tools",
|
||||||
{"limit": 1, "cursor": first_page["nextCursor"]},
|
{"limit": 1, "cursor": first_page["nextCursor"]},
|
||||||
)
|
)
|
||||||
second_page = _structured(second_page_result)
|
second_page = _structured(second_page_result)
|
||||||
@@ -267,7 +274,7 @@ def test_transparent_proxy_proxy_tool_listing_supports_filters_and_cursor() -> N
|
|||||||
)
|
)
|
||||||
|
|
||||||
filtered_result = await client.call_tool(
|
filtered_result = await client.call_tool(
|
||||||
"wf.mcp_list_proxy_tools",
|
"wf.admin.list_proxy_tools",
|
||||||
{
|
{
|
||||||
"connection_id": "fixture.personal",
|
"connection_id": "fixture.personal",
|
||||||
"query": "echo",
|
"query": "echo",
|
||||||
@@ -308,7 +315,7 @@ def test_transparent_proxy_admin_tools_mutate_config_file() -> None:
|
|||||||
client = create_transparent_proxy_client(config, config_path=config_path)
|
client = create_transparent_proxy_client(config, config_path=config_path)
|
||||||
async with client:
|
async with client:
|
||||||
add_result = await client.call_tool(
|
add_result = await client.call_tool(
|
||||||
"wf.mcp_add_connection",
|
"wf.admin.add_connection",
|
||||||
{
|
{
|
||||||
"connection_id": "fixture.work",
|
"connection_id": "fixture.work",
|
||||||
"server": "fixture",
|
"server": "fixture",
|
||||||
@@ -329,7 +336,7 @@ def test_transparent_proxy_admin_tools_mutate_config_file() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
disable_result = await client.call_tool(
|
disable_result = await client.call_tool(
|
||||||
"wf.mcp_disable_connection",
|
"wf.admin.disable_connection",
|
||||||
{"connection_id": "fixture.work"},
|
{"connection_id": "fixture.work"},
|
||||||
)
|
)
|
||||||
assert _structured(disable_result) == {
|
assert _structured(disable_result) == {
|
||||||
@@ -340,7 +347,7 @@ def test_transparent_proxy_admin_tools_mutate_config_file() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_result = await client.call_tool(
|
update_result = await client.call_tool(
|
||||||
"wf.mcp_update_connection",
|
"wf.admin.update_connection",
|
||||||
{
|
{
|
||||||
"connection_id": "fixture.work",
|
"connection_id": "fixture.work",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
@@ -357,11 +364,11 @@ def test_transparent_proxy_admin_tools_mutate_config_file() -> None:
|
|||||||
"requires_reload": True,
|
"requires_reload": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
config_result = await client.call_tool("wf.mcp_get_config")
|
config_result = await client.call_tool("wf.admin.get_config")
|
||||||
assert "fixture.work" in str(_structured(config_result))
|
assert "fixture.work" in str(_structured(config_result))
|
||||||
|
|
||||||
remove_result = await client.call_tool(
|
remove_result = await client.call_tool(
|
||||||
"wf.mcp_remove_connection",
|
"wf.admin.remove_connection",
|
||||||
{"connection_id": "fixture.work"},
|
{"connection_id": "fixture.work"},
|
||||||
)
|
)
|
||||||
assert _structured(remove_result) == {
|
assert _structured(remove_result) == {
|
||||||
@@ -402,7 +409,7 @@ def test_transparent_proxy_admin_reload_remounts_connections() -> None:
|
|||||||
assert "fixture.personal_echo_tool" not in initial_names
|
assert "fixture.personal_echo_tool" not in initial_names
|
||||||
|
|
||||||
await client.call_tool(
|
await client.call_tool(
|
||||||
"wf.mcp_add_connection",
|
"wf.admin.add_connection",
|
||||||
{
|
{
|
||||||
"connection_id": "fixture.personal",
|
"connection_id": "fixture.personal",
|
||||||
"server": "fixture",
|
"server": "fixture",
|
||||||
@@ -419,7 +426,7 @@ def test_transparent_proxy_admin_reload_remounts_connections() -> None:
|
|||||||
before_reload_names = [tool.name for tool in before_reload_tools]
|
before_reload_names = [tool.name for tool in before_reload_tools]
|
||||||
assert "fixture.personal_echo_tool" not in before_reload_names
|
assert "fixture.personal_echo_tool" not in before_reload_names
|
||||||
|
|
||||||
reload_result = await client.call_tool("wf.mcp_reload_config")
|
reload_result = await client.call_tool("wf.admin.reload_config")
|
||||||
assert _structured(reload_result) == {
|
assert _structured(reload_result) == {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"reloaded": True,
|
"reloaded": True,
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
{
|
{
|
||||||
"id": "everything.default",
|
"id": "everything.default",
|
||||||
"server": "everything",
|
"server": "everything",
|
||||||
"account": "default",
|
"account": "demo",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
|
|||||||
Reference in New Issue
Block a user