officially add sources
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# talk w/ me
|
||||||
|
|
||||||
|
help me understand. i am tired, be clear
|
||||||
|
|
||||||
|
# pitfalls
|
||||||
|
|
||||||
|
no: assert dict == dict
|
||||||
|
yes: assert dict['field'] == dict['field'] unless we know better
|
||||||
|
|
||||||
|
more later
|
||||||
|
|
||||||
|
# when you move through code, add docstrings / comment weird logic
|
||||||
|
|
||||||
|
# use available MCP tools/skills
|
||||||
|
|
||||||
|
- serena mcp: symbol discovery
|
||||||
|
- context7: docs
|
||||||
|
|
||||||
|
- skills: outside of workspace, request commands
|
||||||
+5
-1
@@ -22,6 +22,9 @@ This repository has three main packages plus examples and tests.
|
|||||||
- `wf_authoring.node`: typed Python function to `NodeSpec`.
|
- `wf_authoring.node`: typed Python function to `NodeSpec`.
|
||||||
- `wf_mcp`: public MCP facade.
|
- `wf_mcp`: public MCP facade.
|
||||||
- `wf-mcp`: CLI script from `pyproject.toml`.
|
- `wf-mcp`: CLI script from `pyproject.toml`.
|
||||||
|
- `wf_mcp.broker.WfMcpService.get_catalog()`: backend MCP catalog snapshots.
|
||||||
|
- `wf_mcp.broker.WfMcpService.get_planner_catalog()`: backend snapshots plus
|
||||||
|
broker-local workflow sources such as `wf.std` and `wf.mcp`.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -57,6 +60,7 @@ need local environment configuration.
|
|||||||
`wf_core.validation`.
|
`wf_core.validation`.
|
||||||
- Add author convenience helpers in `wf_authoring`, not `wf_core`.
|
- Add author convenience helpers in `wf_authoring`, not `wf_core`.
|
||||||
- Add MCP transport/proxy/config behavior in `wf_mcp` concern packages.
|
- Add MCP transport/proxy/config behavior in `wf_mcp` concern packages.
|
||||||
|
- Add broker-local workflow utilities as `WfMcpService` spec sources, not as
|
||||||
|
fake MCP connections.
|
||||||
- Add runnable examples in `examples`.
|
- Add runnable examples in `examples`.
|
||||||
- Add test-only servers or helpers in `tests/fixtures`.
|
- Add test-only servers or helpers in `tests/fixtures`.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,21 @@ relevant concern package directly.
|
|||||||
- `wf_mcp.shared` should stay pure and should not import other `wf_mcp` concern packages.
|
- `wf_mcp.shared` should stay pure and should not import other `wf_mcp` concern packages.
|
||||||
- Root compatibility shims should stay thin: import and re-export only.
|
- Root compatibility shims should stay thin: import and re-export only.
|
||||||
|
|
||||||
|
## Broker Catalogs
|
||||||
|
|
||||||
|
The broker keeps two related catalog views:
|
||||||
|
|
||||||
|
- `get_catalog()` is the backend MCP catalog. It only includes enabled upstream
|
||||||
|
connection snapshots loaded from storage.
|
||||||
|
- `get_planner_catalog()` is the workflow-planning catalog. It includes backend
|
||||||
|
connection snapshots plus broker-local system sources such as `wf.std` and
|
||||||
|
`wf.mcp`.
|
||||||
|
|
||||||
|
Broker-local sources are not fake MCP backend connections. They are registered
|
||||||
|
as service spec sources so raw workflow plans can address nodes like
|
||||||
|
`wf.std.runtime_error` and `wf.mcp.call_tool` without polluting connection status,
|
||||||
|
auth, adapter lookup, or persisted backend catalog snapshots.
|
||||||
|
|
||||||
## Hot Reload
|
## Hot Reload
|
||||||
|
|
||||||
Transparent proxy reload is intentionally isolated in
|
Transparent proxy reload is intentionally isolated in
|
||||||
|
|||||||
@@ -1,14 +1,57 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from wf_authoring import NodeSpec, node, runtime_error
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from wf_authoring import NodeReturn, NodeSpec, node, runtime_error
|
||||||
|
|
||||||
|
from .sources import SpecSource
|
||||||
from .specs import qualify_spec
|
from .specs import qualify_spec
|
||||||
|
|
||||||
BUILTIN_CONNECTION_ID = "wf.std"
|
BUILTIN_CONNECTION_ID = "wf.std"
|
||||||
"""Internal source id for workflow standard-library node specs."""
|
"""Internal source id for workflow standard-library node specs."""
|
||||||
|
|
||||||
|
MCP_SOURCE_ID = "wf.mcp"
|
||||||
|
"""Internal source id for broker MCP utility node specs."""
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCaller(Protocol):
|
||||||
|
"""Small service boundary needed by the broker-local MCP utility nodes."""
|
||||||
|
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
connection_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
*,
|
||||||
|
arguments: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class McpCallToolInput(BaseModel):
|
||||||
|
"""Input for calling a proxied MCP tool from inside a workflow."""
|
||||||
|
|
||||||
|
connection_id: str = Field(description="Connection id that owns the MCP tool.")
|
||||||
|
tool_name: str = Field(description="Local tool name on the upstream MCP server.")
|
||||||
|
arguments: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="JSON-compatible arguments passed to the upstream tool.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class McpCallToolOutput(BaseModel):
|
||||||
|
"""Normalized output returned by a proxied MCP tool call."""
|
||||||
|
|
||||||
|
outcome: str = Field(description="Workflow outcome reported by the upstream tool.")
|
||||||
|
output: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="JSON-compatible tool result payload.",
|
||||||
|
)
|
||||||
|
meta: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Adapter metadata returned with the tool result.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -21,3 +64,44 @@ def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
|
|||||||
]
|
]
|
||||||
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}
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_specs(service: ToolCaller) -> dict[str, NodeSpec[Any, Any]]:
|
||||||
|
"""Return service-bound MCP utility specs available to raw plans."""
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="call_tool",
|
||||||
|
outcomes=("ok", "error"),
|
||||||
|
input_model=McpCallToolInput,
|
||||||
|
output_model=McpCallToolOutput,
|
||||||
|
description="Call a tool on a registered MCP connection.",
|
||||||
|
)
|
||||||
|
async def call_tool(payload: McpCallToolInput) -> NodeReturn[McpCallToolOutput]:
|
||||||
|
result = await service.call_tool(
|
||||||
|
payload.connection_id,
|
||||||
|
payload.tool_name,
|
||||||
|
arguments=payload.arguments,
|
||||||
|
)
|
||||||
|
output = McpCallToolOutput.model_validate(result)
|
||||||
|
return NodeReturn(outcome=output.outcome, output=output)
|
||||||
|
|
||||||
|
qualified_specs = [qualify_spec(MCP_SOURCE_ID, call_tool)]
|
||||||
|
return {spec.name: spec for spec in qualified_specs}
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_sources(service: ToolCaller) -> dict[str, SpecSource]:
|
||||||
|
"""Return all broker-local spec sources."""
|
||||||
|
return {
|
||||||
|
BUILTIN_CONNECTION_ID: SpecSource(
|
||||||
|
id=BUILTIN_CONNECTION_ID,
|
||||||
|
kind="system",
|
||||||
|
specs=builtin_specs(),
|
||||||
|
description="Workflow standard-library nodes.",
|
||||||
|
),
|
||||||
|
MCP_SOURCE_ID: SpecSource(
|
||||||
|
id=MCP_SOURCE_ID,
|
||||||
|
kind="system",
|
||||||
|
specs=mcp_specs(service),
|
||||||
|
description="Broker MCP utility nodes.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
from ...models import (
|
from ...models import (
|
||||||
AuthRecord,
|
AuthRecord,
|
||||||
|
CatalogNodeEntry,
|
||||||
CatalogPromptEntry,
|
CatalogPromptEntry,
|
||||||
CatalogResourceEntry,
|
CatalogResourceEntry,
|
||||||
CatalogSnapshot,
|
CatalogSnapshot,
|
||||||
@@ -23,7 +24,8 @@ 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 .adapters import require_adapter
|
from .adapters import require_adapter
|
||||||
from .builtins import BUILTIN_CONNECTION_ID, builtin_specs
|
from .builtins import builtin_sources
|
||||||
|
from .sources import SpecSource
|
||||||
from .specs import get_qualified_spec, qualify_spec
|
from .specs import get_qualified_spec, qualify_spec
|
||||||
|
|
||||||
|
|
||||||
@@ -33,16 +35,20 @@ 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)
|
||||||
specs_by_connection: dict[str, dict[str, NodeSpec[Any, Any]]] = field(
|
spec_sources: dict[str, SpecSource] = field(default_factory=dict)
|
||||||
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
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Install broker-local standard-library specs when enabled."""
|
"""Install broker-local system specs when enabled."""
|
||||||
if self.include_builtin_specs:
|
if self.include_builtin_specs:
|
||||||
self.specs_by_connection.setdefault(BUILTIN_CONNECTION_ID, builtin_specs())
|
for source in builtin_sources(self).values():
|
||||||
|
self.register_spec_source(source)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def specs_by_connection(self) -> dict[str, dict[str, NodeSpec[Any, Any]]]:
|
||||||
|
"""Compatibility view of source specs keyed by source id."""
|
||||||
|
return {source.id: source.specs for source in self.spec_sources.values()}
|
||||||
|
|
||||||
def register_connection(self, connection: ConnectionConfig) -> None:
|
def register_connection(self, connection: ConnectionConfig) -> None:
|
||||||
parse_connection_id(connection.id)
|
parse_connection_id(connection.id)
|
||||||
@@ -84,7 +90,14 @@ class WfMcpService:
|
|||||||
)
|
)
|
||||||
for spec in specs
|
for spec in specs
|
||||||
}
|
}
|
||||||
self.specs_by_connection[connection_id] = qualified_specs
|
self.register_spec_source(
|
||||||
|
SpecSource(
|
||||||
|
id=connection_id,
|
||||||
|
kind="connection",
|
||||||
|
specs=qualified_specs,
|
||||||
|
description=f"Specs discovered or registered for {connection_id}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
snapshot = snapshot_from_specs(
|
snapshot = snapshot_from_specs(
|
||||||
connection_id,
|
connection_id,
|
||||||
specs=qualified_specs,
|
specs=qualified_specs,
|
||||||
@@ -108,6 +121,39 @@ class WfMcpService:
|
|||||||
snapshots[connection.id] = snapshot
|
snapshots[connection.id] = snapshot
|
||||||
return CombinedCatalog(snapshots=snapshots)
|
return CombinedCatalog(snapshots=snapshots)
|
||||||
|
|
||||||
|
def get_planner_catalog(self) -> CombinedCatalog:
|
||||||
|
"""Return all planner-visible specs, including broker-local sources."""
|
||||||
|
snapshots = dict(self.get_catalog().snapshots)
|
||||||
|
fetched_at_epoch_ms = int(time.time() * 1000)
|
||||||
|
for source in self.spec_sources.values():
|
||||||
|
if not source.visible or source.kind != "system":
|
||||||
|
continue
|
||||||
|
snapshots[source.id] = snapshot_from_specs(
|
||||||
|
source.id,
|
||||||
|
specs=source.specs,
|
||||||
|
metadata={
|
||||||
|
"kind": source.kind,
|
||||||
|
"description": source.description,
|
||||||
|
},
|
||||||
|
fetched_at_epoch_ms=fetched_at_epoch_ms,
|
||||||
|
max_age_seconds=self.default_catalog_max_age_seconds,
|
||||||
|
)
|
||||||
|
return CombinedCatalog(snapshots=snapshots)
|
||||||
|
|
||||||
|
def list_spec_sources(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return planner spec sources without expanding every node schema."""
|
||||||
|
return [
|
||||||
|
source.as_status()
|
||||||
|
for source in sorted(
|
||||||
|
self.spec_sources.values(),
|
||||||
|
key=lambda source: source.id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_available_specs(self) -> list[CatalogNodeEntry]:
|
||||||
|
"""Return planner-visible node catalog entries from every visible source."""
|
||||||
|
return self.get_planner_catalog().entries()
|
||||||
|
|
||||||
def get_connection_snapshot(self, connection_id: str) -> CatalogSnapshot | None:
|
def get_connection_snapshot(self, connection_id: str) -> CatalogSnapshot | None:
|
||||||
self.connections.get(connection_id)
|
self.connections.get(connection_id)
|
||||||
return self.store.load_catalog(connection_id)
|
return self.store.load_catalog(connection_id)
|
||||||
@@ -361,7 +407,10 @@ class WfMcpService:
|
|||||||
)
|
)
|
||||||
snapshot = snapshot_from_specs(
|
snapshot = snapshot_from_specs(
|
||||||
connection_id,
|
connection_id,
|
||||||
specs=self.specs_by_connection.get(connection_id, {}),
|
specs=self.spec_sources.get(
|
||||||
|
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
|
||||||
},
|
},
|
||||||
@@ -446,8 +495,12 @@ class WfMcpService:
|
|||||||
def list_events(self) -> list[McpEvent]:
|
def list_events(self) -> list[McpEvent]:
|
||||||
return list(self.events)
|
return list(self.events)
|
||||||
|
|
||||||
|
def register_spec_source(self, source: SpecSource) -> None:
|
||||||
|
"""Register a planner source."""
|
||||||
|
self.spec_sources[source.id] = source
|
||||||
|
|
||||||
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.specs_by_connection, qualified_name)
|
return get_qualified_spec(self.spec_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)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
|
SpecSourceKind = Literal["connection", "system"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SpecSource:
|
||||||
|
"""Planner-visible collection of workflow node specs.
|
||||||
|
|
||||||
|
Connection sources come from proxied MCP servers. System sources are local broker
|
||||||
|
capabilities, such as workflow stdlib nodes or service-bound MCP control nodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
kind: SpecSourceKind
|
||||||
|
specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
|
||||||
|
visible: bool = True
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
def as_status(self) -> dict[str, Any]:
|
||||||
|
"""Return a compact payload suitable for UI and debugging surfaces."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"visible": self.visible,
|
||||||
|
"description": self.description,
|
||||||
|
"spec_count": len(self.specs),
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
@@ -24,12 +27,12 @@ def qualify_spec(connection_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any,
|
|||||||
|
|
||||||
|
|
||||||
def get_qualified_spec(
|
def get_qualified_spec(
|
||||||
specs_by_connection: dict[str, dict[str, NodeSpec[Any, Any]]],
|
spec_sources: Mapping[str, SpecSource],
|
||||||
qualified_name: str,
|
qualified_name: str,
|
||||||
) -> NodeSpec[Any, Any]:
|
) -> NodeSpec[Any, Any]:
|
||||||
"""Resolve a namespaced node spec from the service's connection cache."""
|
"""Resolve a namespaced node spec from planner-visible sources."""
|
||||||
connection_id, _ = qualified_name.rsplit(".", 1)
|
source_id, _ = qualified_name.rsplit(".", 1)
|
||||||
specs = specs_by_connection.get(connection_id)
|
source = spec_sources.get(source_id)
|
||||||
if specs is None or qualified_name not in specs:
|
if source is None or qualified_name not in source.specs:
|
||||||
raise KeyError(f"unknown qualified node {qualified_name!r}")
|
raise KeyError(f"unknown qualified node {qualified_name!r}")
|
||||||
return specs[qualified_name]
|
return source.specs[qualified_name]
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ def register_broker_tools(server: FastMCP, service: WfMcpService) -> None:
|
|||||||
async def get_catalog() -> dict[str, Any]:
|
async def get_catalog() -> dict[str, Any]:
|
||||||
return service.get_catalog().as_payload()
|
return service.get_catalog().as_payload()
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
async def get_planner_catalog() -> dict[str, Any]:
|
||||||
|
return service.get_planner_catalog().as_payload()
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
async def list_spec_sources() -> list[dict[str, Any]]:
|
||||||
|
return service.list_spec_sources()
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
async def read_broker_resource(qualified_name: str) -> dict[str, Any]:
|
async def read_broker_resource(qualified_name: str) -> dict[str, Any]:
|
||||||
return await service.read_resource(qualified_name)
|
return await service.read_resource(qualified_name)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from wf_mcp.broker import (
|
from wf_mcp.broker import (
|
||||||
WfMcpService,
|
WfMcpService,
|
||||||
@@ -65,6 +66,8 @@ def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
|
|||||||
|
|
||||||
assert "get_connection_statuses" in tool_names
|
assert "get_connection_statuses" in tool_names
|
||||||
assert "refresh_connection_catalog" in tool_names
|
assert "refresh_connection_catalog" in tool_names
|
||||||
|
assert "get_planner_catalog" in tool_names
|
||||||
|
assert "list_spec_sources" in tool_names
|
||||||
assert "invoke_broker_method" in tool_names
|
assert "invoke_broker_method" in tool_names
|
||||||
assert "call_broker_tool" in tool_names
|
assert "call_broker_tool" in tool_names
|
||||||
assert "catalog.all" in resource_names
|
assert "catalog.all" in resource_names
|
||||||
@@ -72,6 +75,27 @@ def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
|
|||||||
assert "status.all" in resource_names
|
assert "status.all" in resource_names
|
||||||
assert "plan_with_catalog" in prompt_names
|
assert "plan_with_catalog" in prompt_names
|
||||||
|
|
||||||
|
_content, planner_catalog_raw = asyncio.run(
|
||||||
|
server.call_tool("get_planner_catalog", {})
|
||||||
|
)
|
||||||
|
planner_catalog = cast(dict[str, Any], cast(object, planner_catalog_raw))
|
||||||
|
planner_names = [
|
||||||
|
node["qualified_name"] for node in planner_catalog["nodes"]
|
||||||
|
]
|
||||||
|
assert "demo.personal.echo_tool" in planner_names
|
||||||
|
assert "wf.mcp.call_tool" in planner_names
|
||||||
|
assert "wf.std.runtime_error" in planner_names
|
||||||
|
|
||||||
|
_content, source_payload_raw = asyncio.run(
|
||||||
|
server.call_tool("list_spec_sources", {})
|
||||||
|
)
|
||||||
|
source_payload = cast(dict[str, Any], cast(object, source_payload_raw))
|
||||||
|
sources = source_payload["result"]
|
||||||
|
source_ids = [source["id"] for source in sources]
|
||||||
|
assert "demo.personal" in source_ids
|
||||||
|
assert "wf.mcp" in source_ids
|
||||||
|
assert "wf.std" in source_ids
|
||||||
|
|
||||||
|
|
||||||
def test_build_service_from_config_registers_connections() -> None:
|
def test_build_service_from_config_registers_connections() -> None:
|
||||||
config = BrokerConfig(
|
config = BrokerConfig(
|
||||||
|
|||||||
@@ -37,8 +37,14 @@ def test_service_builds_namespaced_catalog() -> None:
|
|||||||
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"))
|
||||||
|
|
||||||
assert "wf.std" in service.specs_by_connection
|
assert "wf.std" in service.spec_sources
|
||||||
assert "wf.std.runtime_error" in service.specs_by_connection["wf.std"]
|
assert "wf.std.runtime_error" in service.spec_sources["wf.std"].specs
|
||||||
|
assert "wf.mcp" in service.spec_sources
|
||||||
|
assert "wf.mcp.call_tool" in service.spec_sources["wf.mcp"].specs
|
||||||
|
|
||||||
|
sources = service.list_spec_sources()
|
||||||
|
assert {source["id"] for source in sources} == {"wf.mcp", "wf.std"}
|
||||||
|
assert all(source["kind"] == "system" for source in sources)
|
||||||
|
|
||||||
|
|
||||||
def test_service_can_disable_builtin_stdlib_specs() -> None:
|
def test_service_can_disable_builtin_stdlib_specs() -> None:
|
||||||
@@ -47,7 +53,26 @@ def test_service_can_disable_builtin_stdlib_specs() -> None:
|
|||||||
include_builtin_specs=False,
|
include_builtin_specs=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "wf.std" not in service.specs_by_connection
|
assert "wf.std" not in service.spec_sources
|
||||||
|
assert "wf.mcp" not in service.spec_sources
|
||||||
|
assert service.list_spec_sources() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_catalog_split_keeps_system_specs_out_of_backend_catalog() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "planner_store"))
|
||||||
|
|
||||||
|
backend_payload = service.get_catalog().as_payload()
|
||||||
|
planner_payload = service.get_planner_catalog().as_payload()
|
||||||
|
|
||||||
|
assert backend_payload["nodes"] == []
|
||||||
|
assert [node["qualified_name"] for node in planner_payload["nodes"]] == [
|
||||||
|
"wf.mcp.call_tool",
|
||||||
|
"wf.std.runtime_error",
|
||||||
|
]
|
||||||
|
assert [entry.qualified_name for entry in service.list_available_specs()] == [
|
||||||
|
"wf.mcp.call_tool",
|
||||||
|
"wf.std.runtime_error",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_service_compiles_and_runs_raw_plan() -> None:
|
def test_service_compiles_and_runs_raw_plan() -> None:
|
||||||
@@ -225,7 +250,7 @@ def test_service_wrapped_tool_adapter_model_validates_simple_schema_types() -> N
|
|||||||
service.register_adapter("demo", FakeAdapter())
|
service.register_adapter("demo", FakeAdapter())
|
||||||
|
|
||||||
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
asyncio.run(service.refresh_connection_catalog("demo.personal"))
|
||||||
spec = service.specs_by_connection["demo.personal"]["demo.personal.echo_tool"]
|
spec = service.spec_sources["demo.personal"].specs["demo.personal.echo_tool"]
|
||||||
|
|
||||||
parsed = spec.input_model.model_validate({"text": "hello"})
|
parsed = spec.input_model.model_validate({"text": "hello"})
|
||||||
assert parsed.model_dump() == {"text": "hello"}
|
assert parsed.model_dump() == {"text": "hello"}
|
||||||
@@ -299,6 +324,69 @@ def test_service_records_tool_call_events() -> None:
|
|||||||
assert tool_events[1].payload["outcome"] == "ok"
|
assert tool_events[1].payload["outcome"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_can_call_upstream_tool_through_wf_mcp_system_node() -> None:
|
||||||
|
service = WfMcpService(store=FileStore(local_temp_root() / "system_tool_store"))
|
||||||
|
service.register_connection(
|
||||||
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||||
|
)
|
||||||
|
service.register_adapter("demo", FakeAdapter())
|
||||||
|
|
||||||
|
plan = RawWorkflowPlan(
|
||||||
|
name="system_tool_plan",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connection_id": {"type": "string"},
|
||||||
|
"tool_name": {"type": "string"},
|
||||||
|
"arguments": {"type": "object"},
|
||||||
|
},
|
||||||
|
"required": ["connection_id", "tool_name", "arguments"],
|
||||||
|
},
|
||||||
|
state_schema={
|
||||||
|
"fields": {
|
||||||
|
"tool_result": {"type": "object"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"tool_result": {"type": "object"}},
|
||||||
|
"required": ["tool_result"],
|
||||||
|
},
|
||||||
|
start="call_tool",
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"id": "call_tool",
|
||||||
|
"type": "node",
|
||||||
|
"node": "wf.mcp.call_tool",
|
||||||
|
"in_map": {
|
||||||
|
"input.connection_id": "connection_id",
|
||||||
|
"input.tool_name": "tool_name",
|
||||||
|
"input.arguments": "arguments",
|
||||||
|
},
|
||||||
|
"out_map": {"output": "state.tool_result"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
{"from": "call_tool", "outcome": "ok", "to": END},
|
||||||
|
{"from": "call_tool", "outcome": "error", "to": END},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
run = asyncio.run(
|
||||||
|
service.run_workflow_from_plan(
|
||||||
|
plan,
|
||||||
|
{
|
||||||
|
"connection_id": "demo.personal",
|
||||||
|
"tool_name": "echo_tool",
|
||||||
|
"arguments": {"text": "hello"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert run.status == RunStatus.COMPLETED
|
||||||
|
assert run.output["tool_result"]["echoed"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
def test_service_can_inspect_resources_and_prompts() -> None:
|
def test_service_can_inspect_resources_and_prompts() -> None:
|
||||||
service = WfMcpService(store=FileStore(local_temp_root() / "inspect_store"))
|
service = WfMcpService(store=FileStore(local_temp_root() / "inspect_store"))
|
||||||
service.register_connection(
|
service.register_connection(
|
||||||
|
|||||||
Reference in New Issue
Block a user