wf-mcp reorg big 2 Three more folders joined the battle

This commit is contained in:
lda
2026-05-07 16:08:52 +07:00 Verified
parent 1f79c449cb
commit 496dc78c55
21 changed files with 787 additions and 710 deletions
+10 -11
View File
@@ -3,12 +3,19 @@ from .sdk import (
McpSdkAdapter,
ToolCallResult,
)
from .broker_server import (
from .broker import (
build_service_from_config,
CombinedCatalog,
create_broker_server,
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
load_broker_config,
McpEvent,
make_event,
run_broker_server,
run_transparent_proxy_server,
specs_from_discovered_tools,
WfMcpService,
)
from .capabilities import (
CatalogNodeEntry,
@@ -18,7 +25,6 @@ from .capabilities import (
DiscoveredResource,
DiscoveredTool,
)
from .catalog import CombinedCatalog
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from .control import (
BrokerConfigManager,
@@ -28,12 +34,6 @@ from .control import (
HttpConnectionMetadata,
StdioConnectionMetadata,
)
from .discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
specs_from_discovered_tools,
)
from .events import McpEvent, make_event
from .models import (
AuthRecord,
BrokerConfig,
@@ -53,15 +53,14 @@ from .proxy_config import (
broker_config_to_fastmcp_config,
connection_to_fastmcp_server_config,
)
from .service import WfMcpService
from .store import FileStore, Store
from .storage import FileStore, Store
from .transparent_proxy import (
TransparentProxyRuntime,
create_proxy_admin_server,
create_transparent_proxy_client,
create_transparent_proxy_server,
)
from .wrappers import wrap_discovered_tool
from .workflow import wrap_discovered_tool
__all__ = [
"AuthRecord",
+31
View File
@@ -0,0 +1,31 @@
from .catalog import CombinedCatalog, snapshot_from_specs
from .discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
specs_from_discovered_tools,
)
from .events import McpEvent, make_event
from .server import (
build_service_from_config,
create_broker_server,
load_broker_config,
run_broker_server,
run_transparent_proxy_server,
)
from .service import WfMcpService
__all__ = [
"CombinedCatalog",
"DiscoveredConnectionCapabilities",
"McpEvent",
"WfMcpService",
"build_service_from_config",
"create_broker_server",
"discover_connection_capabilities",
"load_broker_config",
"make_event",
"run_broker_server",
"run_transparent_proxy_server",
"snapshot_from_specs",
"specs_from_discovered_tools",
]
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeCatalog, NodeSpec
from ..capabilities import (
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
DiscoveredPrompt,
DiscoveredResource,
)
from ..connections import qualify_node_name
from ..models import CatalogSnapshot
def snapshot_from_specs(
connection_id: str,
*,
specs: dict[str, NodeSpec[Any, Any]],
tool_display_names: dict[str, str | None] | None = None,
resources: list[DiscoveredResource] | None = None,
prompts: list[DiscoveredPrompt] | None = None,
metadata: dict[str, Any] | None = None,
fetched_at_epoch_ms: int,
max_age_seconds: int,
) -> CatalogSnapshot:
catalog = NodeCatalog.from_specs(*specs.values())
nodes = [
CatalogNodeEntry(
qualified_name=entry.name
if entry.name.startswith(f"{connection_id}.")
else qualify_node_name(connection_id, entry.name),
connection_id=connection_id,
local_name=entry.name.removeprefix(f"{connection_id}."),
title=(tool_display_names or {}).get(
entry.name.removeprefix(f"{connection_id}."),
entry.display_name,
),
description=entry.description,
outcomes=entry.outcomes,
input_schema=entry.input_schema,
output_schema=entry.output_schema,
)
for entry in catalog.entries()
]
resource_entries = [
CatalogResourceEntry(
qualified_name=qualify_node_name(connection_id, resource.name),
connection_id=connection_id,
local_name=resource.name,
title=resource.title,
uri=resource.uri,
description=resource.description,
mime_type=resource.mime_type,
metadata=resource.metadata,
)
for resource in resources or []
]
prompt_entries = [
CatalogPromptEntry(
qualified_name=qualify_node_name(connection_id, prompt.name),
connection_id=connection_id,
local_name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=prompt.arguments,
metadata=prompt.metadata,
)
for prompt in prompts or []
]
return CatalogSnapshot(
connection_id=connection_id,
fetched_at_epoch_ms=fetched_at_epoch_ms,
max_age_seconds=max_age_seconds,
nodes=nodes,
resources=resource_entries,
prompts=prompt_entries,
metadata=metadata or {},
)
@dataclass(slots=True)
class CombinedCatalog:
snapshots: dict[str, CatalogSnapshot] = field(default_factory=dict)
def entries(self) -> list[CatalogNodeEntry]:
result: list[CatalogNodeEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.nodes)
return sorted(result, key=lambda entry: entry.qualified_name)
def resource_entries(self) -> list[CatalogResourceEntry]:
result: list[CatalogResourceEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.resources)
return sorted(result, key=lambda entry: entry.qualified_name)
def prompt_entries(self) -> list[CatalogPromptEntry]:
result: list[CatalogPromptEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.prompts)
return sorted(result, key=lambda entry: entry.qualified_name)
def find_resource(self, qualified_name: str) -> CatalogResourceEntry | None:
for entry in self.resource_entries():
if entry.qualified_name == qualified_name:
return entry
return None
def find_prompt(self, qualified_name: str) -> CatalogPromptEntry | None:
for entry in self.prompt_entries():
if entry.qualified_name == qualified_name:
return entry
return None
def as_payload(self) -> dict[str, Any]:
return {
"nodes": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"title": entry.title,
"description": entry.description,
"outcomes": list(entry.outcomes),
"input_schema": entry.input_schema,
"output_schema": entry.output_schema,
}
for entry in self.entries()
],
"resources": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"title": entry.title,
"uri": entry.uri,
"description": entry.description,
"mime_type": entry.mime_type,
"metadata": entry.metadata,
}
for entry in self.resource_entries()
],
"prompts": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"title": entry.title,
"description": entry.description,
"arguments": entry.arguments,
"metadata": entry.metadata,
}
for entry in self.prompt_entries()
],
"connections": [
{
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"metadata": snapshot.metadata,
}
for snapshot in sorted(
self.snapshots.values(),
key=lambda snapshot: snapshot.connection_id,
)
],
}
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeSpec
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from ..models import AuthRecord, ConnectionConfig
from ..sdk import BackendAdapter
from ..workflow import wrap_discovered_tool
from .events import McpEvent
@dataclass(slots=True)
class DiscoveredConnectionCapabilities:
tools: list[DiscoveredTool] = field(default_factory=list)
resources: list[DiscoveredResource] = field(default_factory=list)
prompts: list[DiscoveredPrompt] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
async def discover_connection_capabilities(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
) -> DiscoveredConnectionCapabilities:
tools = await adapter.list_tools(connection, auth)
resources = await adapter.list_resources(connection, auth)
prompts = await adapter.list_prompts(connection, auth)
metadata = await adapter.get_connection_metadata(connection, auth)
return DiscoveredConnectionCapabilities(
tools=tools,
resources=resources,
prompts=prompts,
metadata=metadata,
)
def specs_from_discovered_tools(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
tools: list[DiscoveredTool],
emit_event: Callable[[McpEvent], None] | None = None,
) -> list[NodeSpec[Any, Any]]:
return [
wrap_discovered_tool(
connection=connection,
auth=auth,
adapter=adapter,
tool=tool,
emit_event=emit_event,
)
for tool in tools
]
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class McpEvent:
kind: str
timestamp_epoch_ms: int
connection_id: str | None = None
capability_id: str | None = None
workflow_name: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
def make_event(
kind: str,
*,
connection_id: str | None = None,
capability_id: str | None = None,
workflow_name: str | None = None,
payload: dict[str, Any] | None = None,
) -> McpEvent:
return McpEvent(
kind=kind,
timestamp_epoch_ms=int(time.time() * 1000),
connection_id=connection_id,
capability_id=capability_id,
workflow_name=workflow_name,
payload=payload or {},
)
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import json
import os
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal
from mcp.server.fastmcp import FastMCP
from ..control import BrokerConfigFile
from ..models import BrokerConfig
from ..sdk import McpSdkAdapter
from ..shared.errors import error_payload
from ..storage import FileStore
from ..transparent_proxy import create_transparent_proxy_server
from .service import WfMcpService
def load_broker_config(path: str | Path) -> BrokerConfig:
config_path = Path(path)
data = json.loads(config_path.read_text(encoding="utf-8"))
return BrokerConfigFile.model_validate(data).to_runtime(config_path=config_path)
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
service = WfMcpService(store=FileStore(config.store_root))
for connection in config.connections:
service.register_connection(connection)
if connection.server not in service.adapters:
service.register_adapter(connection.server, McpSdkAdapter())
return service
def create_broker_server(service: WfMcpService) -> FastMCP:
server = FastMCP(
"wf-mcp-broker",
instructions=(
"A broker MCP server over one or more upstream MCP connections. "
"Use tools for refresh and invocation, resources for snapshots, "
"and prompts for planning against available capabilities."
),
)
@server.tool()
async def list_connections() -> list[dict[str, Any]]:
return [
asdict(connection)
for connection in sorted(
service.connections.list_all(),
key=lambda connection: connection.id,
)
]
@server.tool()
async def get_connection_statuses() -> list[dict[str, Any]]:
return service.connection_statuses()
@server.tool()
async def refresh_connection_catalog(connection_id: str) -> dict[str, Any]:
try:
await service.refresh_connection_catalog(connection_id)
except Exception as exc:
return {
"connection_id": connection_id,
"refreshed": False,
**error_payload(exc),
}
snapshot = service.get_connection_snapshot(connection_id)
if snapshot is None:
return {"connection_id": connection_id, "refreshed": False}
return {
"connection_id": connection_id,
"refreshed": True,
"node_count": len(snapshot.nodes),
"resource_count": len(snapshot.resources),
"prompt_count": len(snapshot.prompts),
}
@server.tool()
async def get_catalog() -> dict[str, Any]:
return service.get_catalog().as_payload()
@server.tool()
async def read_broker_resource(qualified_name: str) -> dict[str, Any]:
return await service.read_resource(qualified_name)
@server.tool()
async def render_broker_prompt(
qualified_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
return await service.render_prompt(qualified_name, arguments=arguments)
@server.tool()
async def invoke_broker_method(
connection_id: str,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
return await service.invoke_method(connection_id, method, params=params)
except Exception as exc:
return {
"connection_id": connection_id,
"method": method,
"ok": False,
**error_payload(exc),
}
@server.tool()
async def call_broker_tool(
connection_id: str,
tool_name: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
return {
"connection_id": connection_id,
"tool_name": tool_name,
"ok": True,
**await service.call_tool(
connection_id,
tool_name,
arguments=arguments,
),
}
except Exception as exc:
return {
"connection_id": connection_id,
"tool_name": tool_name,
"ok": False,
**error_payload(exc),
}
@server.tool()
async def get_broker_events() -> list[dict[str, Any]]:
return [asdict(event) for event in service.list_events()]
@server.resource("wf-mcp://catalog", name="catalog.all")
def catalog_resource() -> str:
return json.dumps(service.get_catalog().as_payload(), indent=2)
@server.resource(
"wf-mcp://connection/{connection_id}/catalog",
name="catalog.connection",
)
def connection_catalog_resource(connection_id: str) -> str:
snapshot = service.get_connection_snapshot(connection_id)
if snapshot is None:
raise KeyError(connection_id)
return json.dumps(
{
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"nodes": [asdict(node) for node in snapshot.nodes],
"resources": [asdict(resource) for resource in snapshot.resources],
"prompts": [asdict(prompt) for prompt in snapshot.prompts],
"metadata": snapshot.metadata,
},
indent=2,
)
@server.resource("wf-mcp://events", name="events.all")
def events_resource() -> str:
return json.dumps([asdict(event) for event in service.list_events()], indent=2)
@server.resource("wf-mcp://status", name="status.all")
def status_resource() -> str:
return json.dumps(service.connection_statuses(), indent=2)
@server.prompt(
name="plan_with_catalog",
description="Provide the broker catalog as planning context.",
)
def plan_with_catalog() -> list[dict[str, str]]:
payload = json.dumps(service.get_catalog().as_payload(), indent=2)
return [
{
"role": "user",
"content": (
"Plan a workflow using this broker catalog. "
"Prefer existing namespaced capabilities.\n\n"
f"{payload}"
),
}
]
return server
def main() -> None:
config_path = os.environ.get("WF_MCP_CONFIG", "wf_mcp.config.json")
transport_env = os.environ.get("WF_MCP_TRANSPORT", "stdio")
run_broker_server(config_path, transport_env)
def normalize_transport(
transport: str,
) -> Literal["stdio", "sse", "streamable-http"]:
match transport:
case "streamable_http" | "streamable-http":
return "streamable-http"
case "stdio":
return "stdio"
case "sse":
return "sse"
case _:
raise ValueError(f"we dont support {transport} yet sry")
def run_broker_server(config_path: str | Path, transport: str = "stdio") -> None:
config = load_broker_config(config_path)
service = build_service_from_config(config)
server = create_broker_server(service)
server.run(transport=normalize_transport(transport))
def run_transparent_proxy_server(
config_path: str | Path,
transport: str = "stdio",
*,
resources_as_tools: bool = False,
prompts_as_tools: bool = False,
search_tools: bool = False,
) -> None:
config = load_broker_config(config_path)
server = create_transparent_proxy_server(
config,
config_path=config_path,
resources_as_tools=resources_as_tools,
prompts_as_tools=prompts_as_tools,
search_tools=search_tools,
)
server.run(transport=normalize_transport(transport), show_banner=False)
if __name__ == "__main__":
main()
@@ -2,8 +2,8 @@ from __future__ import annotations
from collections.abc import Mapping
from ..sdk import BackendAdapter
from ..models import ConnectionConfig
from ...models import ConnectionConfig
from ...sdk import BackendAdapter
def require_adapter(
@@ -7,13 +7,8 @@ from typing import Any
from wf_authoring import NodeSpec, build_async_registry
from wf_core import NodeUse, Workflow, execute_workflow_async
from ..sdk import BackendAdapter
from ..catalog import CombinedCatalog, snapshot_from_specs
from ..connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
from ..shared.errors import error_payload
from ..events import McpEvent, make_event
from ..models import (
from ...connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from ...models import (
AuthRecord,
CatalogPromptEntry,
CatalogResourceEntry,
@@ -21,7 +16,12 @@ from ..models import (
ConnectionConfig,
RawWorkflowPlan,
)
from ..store import Store
from ...sdk import BackendAdapter
from ...shared.errors import error_payload
from ...storage import Store
from ..catalog import CombinedCatalog, snapshot_from_specs
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
from ..events import McpEvent, make_event
from .adapters import require_adapter
from .specs import get_qualified_spec, qualify_spec
@@ -4,7 +4,7 @@ from typing import Any
from wf_authoring import NodeSpec
from ..connections import qualify_node_name
from ...connections import qualify_node_name
def qualify_spec(connection_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
+14 -239
View File
@@ -1,240 +1,15 @@
from __future__ import annotations
from .broker.server import (
build_service_from_config,
create_broker_server,
load_broker_config,
run_broker_server,
run_transparent_proxy_server,
)
import json
import os
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal
from mcp.server.fastmcp import FastMCP
from .control import BrokerConfigFile
from .shared.errors import error_payload
from .sdk import McpSdkAdapter
from .models import BrokerConfig
from .service import WfMcpService
from .store import FileStore
from .transparent_proxy import create_transparent_proxy_server
def load_broker_config(path: str | Path) -> BrokerConfig:
config_path = Path(path)
data = json.loads(config_path.read_text(encoding="utf-8"))
return BrokerConfigFile.model_validate(data).to_runtime(config_path=config_path)
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
service = WfMcpService(store=FileStore(config.store_root))
for connection in config.connections:
service.register_connection(connection)
if connection.server not in service.adapters:
service.register_adapter(connection.server, McpSdkAdapter())
return service
def create_broker_server(service: WfMcpService) -> FastMCP:
server = FastMCP(
"wf-mcp-broker",
instructions=(
"A broker MCP server over one or more upstream MCP connections. "
"Use tools for refresh and invocation, resources for snapshots, "
"and prompts for planning against available capabilities."
),
)
@server.tool()
async def list_connections() -> list[dict[str, Any]]:
return [
asdict(connection)
for connection in sorted(
service.connections.list_all(),
key=lambda connection: connection.id,
)
]
@server.tool()
async def get_connection_statuses() -> list[dict[str, Any]]:
return service.connection_statuses()
@server.tool()
async def refresh_connection_catalog(connection_id: str) -> dict[str, Any]:
try:
await service.refresh_connection_catalog(connection_id)
except Exception as exc:
return {
"connection_id": connection_id,
"refreshed": False,
**error_payload(exc),
}
snapshot = service.get_connection_snapshot(connection_id)
if snapshot is None:
return {"connection_id": connection_id, "refreshed": False}
return {
"connection_id": connection_id,
"refreshed": True,
"node_count": len(snapshot.nodes),
"resource_count": len(snapshot.resources),
"prompt_count": len(snapshot.prompts),
}
@server.tool()
async def get_catalog() -> dict[str, Any]:
return service.get_catalog().as_payload()
@server.tool()
async def read_broker_resource(qualified_name: str) -> dict[str, Any]:
return await service.read_resource(qualified_name)
@server.tool()
async def render_broker_prompt(
qualified_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
return await service.render_prompt(qualified_name, arguments=arguments)
@server.tool()
async def invoke_broker_method(
connection_id: str,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
return await service.invoke_method(connection_id, method, params=params)
except Exception as exc:
return {
"connection_id": connection_id,
"method": method,
"ok": False,
**error_payload(exc),
}
@server.tool()
async def call_broker_tool(
connection_id: str,
tool_name: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
return {
"connection_id": connection_id,
"tool_name": tool_name,
"ok": True,
**await service.call_tool(
connection_id,
tool_name,
arguments=arguments,
),
}
except Exception as exc:
return {
"connection_id": connection_id,
"tool_name": tool_name,
"ok": False,
**error_payload(exc),
}
@server.tool()
async def get_broker_events() -> list[dict[str, Any]]:
return [asdict(event) for event in service.list_events()]
@server.resource("wf-mcp://catalog", name="catalog.all")
def catalog_resource() -> str:
return json.dumps(service.get_catalog().as_payload(), indent=2)
@server.resource(
"wf-mcp://connection/{connection_id}/catalog",
name="catalog.connection",
)
def connection_catalog_resource(connection_id: str) -> str:
snapshot = service.get_connection_snapshot(connection_id)
if snapshot is None:
raise KeyError(connection_id)
return json.dumps(
{
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"nodes": [asdict(node) for node in snapshot.nodes],
"resources": [asdict(resource) for resource in snapshot.resources],
"prompts": [asdict(prompt) for prompt in snapshot.prompts],
"metadata": snapshot.metadata,
},
indent=2,
)
@server.resource("wf-mcp://events", name="events.all")
def events_resource() -> str:
return json.dumps([asdict(event) for event in service.list_events()], indent=2)
@server.resource("wf-mcp://status", name="status.all")
def status_resource() -> str:
return json.dumps(service.connection_statuses(), indent=2)
@server.prompt(
name="plan_with_catalog",
description="Provide the broker catalog as planning context.",
)
def plan_with_catalog() -> list[dict[str, str]]:
payload = json.dumps(service.get_catalog().as_payload(), indent=2)
return [
{
"role": "user",
"content": (
"Plan a workflow using this broker catalog. "
"Prefer existing namespaced capabilities.\n\n"
f"{payload}"
),
}
]
return server
def main() -> None:
config_path = os.environ.get("WF_MCP_CONFIG", "wf_mcp.config.json")
transport_env = os.environ.get("WF_MCP_TRANSPORT", "stdio")
run_broker_server(config_path, transport_env)
def normalize_transport(
transport: str,
) -> Literal["stdio", "sse", "streamable-http"]:
match transport:
case "streamable_http" | "streamable-http":
return "streamable-http"
case "stdio":
return "stdio"
case "sse":
return "sse"
case _:
raise ValueError(f"we dont support {transport} yet sry")
def run_broker_server(config_path: str | Path, transport: str = "stdio") -> None:
config = load_broker_config(config_path)
service = build_service_from_config(config)
server = create_broker_server(service)
server.run(transport=normalize_transport(transport))
def run_transparent_proxy_server(
config_path: str | Path,
transport: str = "stdio",
*,
resources_as_tools: bool = False,
prompts_as_tools: bool = False,
search_tools: bool = False,
) -> None:
config = load_broker_config(config_path)
server = create_transparent_proxy_server(
config,
config_path=config_path,
resources_as_tools=resources_as_tools,
prompts_as_tools=prompts_as_tools,
search_tools=search_tools,
)
server.run(transport=normalize_transport(transport), show_banner=False)
if __name__ == "__main__":
main()
__all__ = [
"build_service_from_config",
"create_broker_server",
"load_broker_config",
"run_broker_server",
"run_transparent_proxy_server",
]
+2 -170
View File
@@ -1,171 +1,3 @@
from __future__ import annotations
from .broker.catalog import CombinedCatalog, snapshot_from_specs
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeCatalog, NodeSpec
from .capabilities import (
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
DiscoveredPrompt,
DiscoveredResource,
)
from .connections import qualify_node_name
from .models import CatalogSnapshot
def snapshot_from_specs(
connection_id: str,
*,
specs: dict[str, NodeSpec[Any, Any]],
tool_display_names: dict[str, str | None] | None = None,
resources: list[DiscoveredResource] | None = None,
prompts: list[DiscoveredPrompt] | None = None,
metadata: dict[str, Any] | None = None,
fetched_at_epoch_ms: int,
max_age_seconds: int,
) -> CatalogSnapshot:
catalog = NodeCatalog.from_specs(*specs.values())
nodes = [
CatalogNodeEntry(
qualified_name=entry.name
if entry.name.startswith(f"{connection_id}.")
else qualify_node_name(connection_id, entry.name),
connection_id=connection_id,
local_name=entry.name.removeprefix(f"{connection_id}."),
title=(tool_display_names or {}).get(
entry.name.removeprefix(f"{connection_id}."),
entry.display_name,
),
description=entry.description,
outcomes=entry.outcomes,
input_schema=entry.input_schema,
output_schema=entry.output_schema,
)
for entry in catalog.entries()
]
resource_entries = [
CatalogResourceEntry(
qualified_name=qualify_node_name(connection_id, resource.name),
connection_id=connection_id,
local_name=resource.name,
title=resource.title,
uri=resource.uri,
description=resource.description,
mime_type=resource.mime_type,
metadata=resource.metadata,
)
for resource in resources or []
]
prompt_entries = [
CatalogPromptEntry(
qualified_name=qualify_node_name(connection_id, prompt.name),
connection_id=connection_id,
local_name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=prompt.arguments,
metadata=prompt.metadata,
)
for prompt in prompts or []
]
return CatalogSnapshot(
connection_id=connection_id,
fetched_at_epoch_ms=fetched_at_epoch_ms,
max_age_seconds=max_age_seconds,
nodes=nodes,
resources=resource_entries,
prompts=prompt_entries,
metadata=metadata or {},
)
@dataclass(slots=True)
class CombinedCatalog:
snapshots: dict[str, CatalogSnapshot] = field(default_factory=dict)
def entries(self) -> list[CatalogNodeEntry]:
result: list[CatalogNodeEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.nodes)
return sorted(result, key=lambda entry: entry.qualified_name)
def resource_entries(self) -> list[CatalogResourceEntry]:
result: list[CatalogResourceEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.resources)
return sorted(result, key=lambda entry: entry.qualified_name)
def prompt_entries(self) -> list[CatalogPromptEntry]:
result: list[CatalogPromptEntry] = []
for snapshot in self.snapshots.values():
result.extend(snapshot.prompts)
return sorted(result, key=lambda entry: entry.qualified_name)
def find_resource(self, qualified_name: str) -> CatalogResourceEntry | None:
for entry in self.resource_entries():
if entry.qualified_name == qualified_name:
return entry
return None
def find_prompt(self, qualified_name: str) -> CatalogPromptEntry | None:
for entry in self.prompt_entries():
if entry.qualified_name == qualified_name:
return entry
return None
def as_payload(self) -> dict[str, Any]:
return {
"nodes": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"title": entry.title,
"description": entry.description,
"outcomes": list(entry.outcomes),
"input_schema": entry.input_schema,
"output_schema": entry.output_schema,
}
for entry in self.entries()
],
"resources": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"title": entry.title,
"uri": entry.uri,
"description": entry.description,
"mime_type": entry.mime_type,
"metadata": entry.metadata,
}
for entry in self.resource_entries()
],
"prompts": [
{
"qualified_name": entry.qualified_name,
"connection_id": entry.connection_id,
"local_name": entry.local_name,
"title": entry.title,
"description": entry.description,
"arguments": entry.arguments,
"metadata": entry.metadata,
}
for entry in self.prompt_entries()
],
"connections": [
{
"connection_id": snapshot.connection_id,
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
"max_age_seconds": snapshot.max_age_seconds,
"metadata": snapshot.metadata,
}
for snapshot in sorted(
self.snapshots.values(),
key=lambda snapshot: snapshot.connection_id,
)
],
}
__all__ = ["CombinedCatalog", "snapshot_from_specs"]
+10 -58
View File
@@ -1,59 +1,11 @@
from __future__ import annotations
from .broker.discovery import (
DiscoveredConnectionCapabilities,
discover_connection_capabilities,
specs_from_discovered_tools,
)
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from wf_authoring import NodeSpec
from .capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from .sdk import BackendAdapter
from .events import McpEvent
from .models import AuthRecord, ConnectionConfig
from .wrappers import wrap_discovered_tool
@dataclass(slots=True)
class DiscoveredConnectionCapabilities:
tools: list[DiscoveredTool] = field(default_factory=list)
resources: list[DiscoveredResource] = field(default_factory=list)
prompts: list[DiscoveredPrompt] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
async def discover_connection_capabilities(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
) -> DiscoveredConnectionCapabilities:
tools = await adapter.list_tools(connection, auth)
resources = await adapter.list_resources(connection, auth)
prompts = await adapter.list_prompts(connection, auth)
metadata = await adapter.get_connection_metadata(connection, auth)
return DiscoveredConnectionCapabilities(
tools=tools,
resources=resources,
prompts=prompts,
metadata=metadata,
)
def specs_from_discovered_tools(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
tools: list[DiscoveredTool],
emit_event: Callable[[McpEvent], None] | None = None,
) -> list[NodeSpec[Any, Any]]:
return [
wrap_discovered_tool(
connection=connection,
auth=auth,
adapter=adapter,
tool=tool,
emit_event=emit_event,
)
for tool in tools
]
__all__ = [
"DiscoveredConnectionCapabilities",
"discover_connection_capabilities",
"specs_from_discovered_tools",
]
+2 -32
View File
@@ -1,33 +1,3 @@
from __future__ import annotations
from .broker.events import McpEvent, make_event
import time
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class McpEvent:
kind: str
timestamp_epoch_ms: int
connection_id: str | None = None
capability_id: str | None = None
workflow_name: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
def make_event(
kind: str,
*,
connection_id: str | None = None,
capability_id: str | None = None,
workflow_name: str | None = None,
payload: dict[str, Any] | None = None,
) -> McpEvent:
return McpEvent(
kind=kind,
timestamp_epoch_ms=int(time.time() * 1000),
connection_id=connection_id,
capability_id=capability_id,
workflow_name=workflow_name,
payload=payload or {},
)
__all__ = ["McpEvent", "make_event"]
+3
View File
@@ -0,0 +1,3 @@
from .broker.service import WfMcpService
__all__ = ["WfMcpService"]
+3
View File
@@ -0,0 +1,3 @@
from .store import FileStore, Store
__all__ = ["FileStore", "Store"]
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
from pathlib import Path
from ..models import (
AuthRecord,
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
CatalogSnapshot,
dump_catalog_snapshot,
)
class Store:
def save_auth(self, record: AuthRecord) -> None:
raise NotImplementedError
def load_auth(self, connection_id: str) -> AuthRecord | None:
raise NotImplementedError
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
raise NotImplementedError
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
raise NotImplementedError
class FileStore(Store):
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
self.auth_dir.mkdir(parents=True, exist_ok=True)
self.catalog_dir.mkdir(parents=True, exist_ok=True)
@property
def auth_dir(self) -> Path:
return self.root / "auth"
@property
def catalog_dir(self) -> Path:
return self.root / "catalog"
def _auth_path(self, connection_id: str) -> Path:
return self.auth_dir / f"{connection_id}.json"
def _catalog_path(self, connection_id: str) -> Path:
return self.catalog_dir / f"{connection_id}.json"
def save_auth(self, record: AuthRecord) -> None:
self._auth_path(record.connection_id).write_text(
json.dumps(
{
"connection_id": record.connection_id,
"scheme": record.scheme,
"payload": record.payload,
},
indent=2,
),
encoding="utf-8",
)
def load_auth(self, connection_id: str) -> AuthRecord | None:
path = self._auth_path(connection_id)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return AuthRecord(**data)
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
self._catalog_path(snapshot.connection_id).write_text(
json.dumps(dump_catalog_snapshot(snapshot), indent=2),
encoding="utf-8",
)
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
path = self._catalog_path(connection_id)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return CatalogSnapshot(
connection_id=data["connection_id"],
fetched_at_epoch_ms=data["fetched_at_epoch_ms"],
max_age_seconds=data["max_age_seconds"],
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
resources=[
CatalogResourceEntry(**resource)
for resource in data.get("resources", [])
],
prompts=[
CatalogPromptEntry(**prompt) for prompt in data.get("prompts", [])
],
metadata=data.get("metadata", {}),
)
+2 -94
View File
@@ -1,95 +1,3 @@
from __future__ import annotations
from .storage import FileStore, Store
import json
from pathlib import Path
from .models import (
AuthRecord,
CatalogNodeEntry,
CatalogPromptEntry,
CatalogResourceEntry,
CatalogSnapshot,
dump_catalog_snapshot,
)
class Store:
def save_auth(self, record: AuthRecord) -> None:
raise NotImplementedError
def load_auth(self, connection_id: str) -> AuthRecord | None:
raise NotImplementedError
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
raise NotImplementedError
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
raise NotImplementedError
class FileStore(Store):
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
self.auth_dir.mkdir(parents=True, exist_ok=True)
self.catalog_dir.mkdir(parents=True, exist_ok=True)
@property
def auth_dir(self) -> Path:
return self.root / "auth"
@property
def catalog_dir(self) -> Path:
return self.root / "catalog"
def _auth_path(self, connection_id: str) -> Path:
return self.auth_dir / f"{connection_id}.json"
def _catalog_path(self, connection_id: str) -> Path:
return self.catalog_dir / f"{connection_id}.json"
def save_auth(self, record: AuthRecord) -> None:
self._auth_path(record.connection_id).write_text(
json.dumps(
{
"connection_id": record.connection_id,
"scheme": record.scheme,
"payload": record.payload,
},
indent=2,
),
encoding="utf-8",
)
def load_auth(self, connection_id: str) -> AuthRecord | None:
path = self._auth_path(connection_id)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return AuthRecord(**data)
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
self._catalog_path(snapshot.connection_id).write_text(
json.dumps(dump_catalog_snapshot(snapshot), indent=2),
encoding="utf-8",
)
def load_catalog(self, connection_id: str) -> CatalogSnapshot | None:
path = self._catalog_path(connection_id)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return CatalogSnapshot(
connection_id=data["connection_id"],
fetched_at_epoch_ms=data["fetched_at_epoch_ms"],
max_age_seconds=data["max_age_seconds"],
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
resources=[
CatalogResourceEntry(**resource)
for resource in data.get("resources", [])
],
prompts=[
CatalogPromptEntry(**prompt) for prompt in data.get("prompts", [])
],
metadata=data.get("metadata", {}),
)
__all__ = ["FileStore", "Store"]
+3
View File
@@ -0,0 +1,3 @@
from .wrappers import wrap_discovered_tool
__all__ = ["wrap_discovered_tool"]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from pydantic import BaseModel, ConfigDict, Field, create_model
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from ..capabilities import DiscoveredTool
from ..events import McpEvent, make_event
from ..models import AuthRecord, ConnectionConfig
from ..sdk import BackendAdapter
def _model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
properties = cast(dict[str, Any], schema.get("properties", {}))
required = set(cast(list[str], schema.get("required", [])))
field_defs: dict[str, tuple[object, object]] = {}
for field_name in properties:
default = ... if field_name in required else None
field_defs[field_name] = (Any, Field(default=default))
raw_field_defs = cast(dict[str, Any], field_defs)
model = create_model(
name,
__config__=ConfigDict(extra="allow"),
**raw_field_defs,
)
return cast(type[BaseModel], model)
def wrap_discovered_tool(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
tool: DiscoveredTool,
emit_event: Callable[[McpEvent], None] | None = None,
) -> NodeSpec[BaseModel, BaseModel]:
input_model = _model_from_schema(
f"{connection.id}_{tool.name}_Input",
tool.input_schema,
)
output_model = _model_from_schema(
f"{connection.id}_{tool.name}_Output",
tool.output_schema,
)
async def invoke_tool(
payload: BaseModel,
ctx: RuntimeContext,
) -> NodeReturn[BaseModel]:
if emit_event is not None:
emit_event(
make_event(
"tool_call_started",
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
payload={"input": payload.model_dump()},
)
)
result = await adapter.call_tool(
connection=connection,
auth=auth,
tool_name=tool.name,
payload=payload.model_dump(),
)
if emit_event is not None:
emit_event(
make_event(
"tool_call_completed",
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
payload={
"outcome": result.outcome,
"meta": result.meta,
},
)
)
return NodeReturn(
outcome=result.outcome,
output=output_model.model_validate(result.output),
)
return NodeSpec(
name=tool.name,
input_model=input_model,
output_model=output_model,
outcomes=tool.outcomes,
fn=invoke_tool,
description=tool.description,
is_async=True,
)
+2 -95
View File
@@ -1,96 +1,3 @@
from __future__ import annotations
from .workflow import wrap_discovered_tool
from collections.abc import Callable
from typing import Any, cast
from pydantic import BaseModel, ConfigDict, Field, create_model
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from .capabilities import DiscoveredTool
from .sdk import BackendAdapter
from .events import McpEvent, make_event
from .models import AuthRecord, ConnectionConfig
def _model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
properties = cast(dict[str, Any], schema.get("properties", {}))
required = set(cast(list[str], schema.get("required", [])))
field_defs: dict[str, tuple[object, object]] = {}
for field_name in properties:
default = ... if field_name in required else None
field_defs[field_name] = (Any, Field(default=default))
raw_field_defs = cast(dict[str, Any], field_defs)
model = create_model(
name,
__config__=ConfigDict(extra="allow"),
**raw_field_defs,
)
return cast(type[BaseModel], model)
def wrap_discovered_tool(
*,
connection: ConnectionConfig,
auth: AuthRecord | None,
adapter: BackendAdapter,
tool: DiscoveredTool,
emit_event: Callable[[McpEvent], None] | None = None,
) -> NodeSpec[BaseModel, BaseModel]:
input_model = _model_from_schema(
f"{connection.id}_{tool.name}_Input",
tool.input_schema,
)
output_model = _model_from_schema(
f"{connection.id}_{tool.name}_Output",
tool.output_schema,
)
async def invoke_tool(
payload: BaseModel,
ctx: RuntimeContext,
) -> NodeReturn[BaseModel]:
if emit_event is not None:
emit_event(
make_event(
"tool_call_started",
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
payload={"input": payload.model_dump()},
)
)
result = await adapter.call_tool(
connection=connection,
auth=auth,
tool_name=tool.name,
payload=payload.model_dump(),
)
if emit_event is not None:
emit_event(
make_event(
"tool_call_completed",
connection_id=connection.id,
capability_id=f"{connection.id}.{tool.name}",
payload={
"outcome": result.outcome,
"meta": result.meta,
},
)
)
return NodeReturn(
outcome=result.outcome,
output=output_model.model_validate(result.output),
)
return NodeSpec(
name=tool.name,
input_model=input_model,
output_model=output_model,
outcomes=tool.outcomes,
fn=invoke_tool,
description=tool.description,
is_async=True,
)
__all__ = ["wrap_discovered_tool"]