Capability sources, add default source under wf.

This commit is contained in:
lda
2026-05-10 16:50:53 +07:00 Verified
parent f13bad5754
commit 4999cf38bb
17 changed files with 1739 additions and 129 deletions
@@ -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`.
+42 -23
View File
@@ -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.
+65
View File
@@ -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.",
)
+41 -6
View File
@@ -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),
}
+168 -25
View File
@@ -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)
+40 -14
View File
@@ -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),
}
+11 -10
View File
@@ -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}")
+3
View File
@@ -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 [
+2 -4
View File
@@ -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(
+9 -3
View File
@@ -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 -13
View File
@@ -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] = []
+14
View File
@@ -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",
+8 -1
View File
@@ -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
+440 -9
View File
@@ -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(
+27 -20
View File
@@ -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
View File
@@ -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",