and thats a server we can use

This commit is contained in:
lda
2026-04-30 00:02:23 +07:00 Verified
parent 6cfdb5dcd5
commit 67a3e6a64c
50 changed files with 492 additions and 10 deletions
+7
View File
@@ -3,12 +3,16 @@ name = "lda-wf"
version = "0.0.1" version = "0.0.1"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
authors = [{ name = "lda", email = "[email protected]" }]
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"mcp[cli,rich]>=1", "mcp[cli,rich]>=1",
"pydantic>=2", "pydantic>=2",
] ]
[project.scripts]
wf-mcp = "wf_mcp.cli:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8", "pytest>=8",
@@ -16,3 +20,6 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-p no:cacheprovider" addopts = "-p no:cacheprovider"
[tool.uv]
package = true
+2
View File
@@ -0,0 +1,2 @@
def main() -> None:
print("Hello from lda-workflow-as-struct!")
@@ -2,6 +2,11 @@ from .adapters import (
BackendAdapter, BackendAdapter,
ToolCallResult, ToolCallResult,
) )
from .broker_server import (
build_service_from_config,
create_broker_server,
load_broker_config,
)
from .capabilities import ( from .capabilities import (
CatalogNodeEntry, CatalogNodeEntry,
CatalogPromptEntry, CatalogPromptEntry,
@@ -20,6 +25,7 @@ from .discovery import (
from .events import McpEvent, make_event from .events import McpEvent, make_event
from .models import ( from .models import (
AuthRecord, AuthRecord,
BrokerConfig,
CatalogSnapshot, CatalogSnapshot,
ConnectionConfig, ConnectionConfig,
RawWorkflowPlan, RawWorkflowPlan,
@@ -32,6 +38,7 @@ from .wrappers import wrap_discovered_tool
__all__ = [ __all__ = [
"AuthRecord", "AuthRecord",
"BackendAdapter", "BackendAdapter",
"BrokerConfig",
"CatalogNodeEntry", "CatalogNodeEntry",
"CatalogPromptEntry", "CatalogPromptEntry",
"CatalogResourceEntry", "CatalogResourceEntry",
@@ -50,7 +57,10 @@ __all__ = [
"Store", "Store",
"ToolCallResult", "ToolCallResult",
"WfMcpService", "WfMcpService",
"build_service_from_config",
"create_broker_server",
"discover_connection_capabilities", "discover_connection_capabilities",
"load_broker_config",
"make_event", "make_event",
"parse_connection_id", "parse_connection_id",
"qualify_node_name", "qualify_node_name",
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import json
import os
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP
from .mcp_sdk_adapter import McpSdkAdapter
from .models import BrokerConfig, ConnectionConfig
from .service import WfMcpService
from .store import FileStore
def load_broker_config(path: str | Path) -> BrokerConfig:
config_path = Path(path)
data = json.loads(config_path.read_text(encoding="utf-8"))
store_root_raw = data.get("store_root", ".wf_mcp_store")
store_root = Path(store_root_raw)
if not store_root.is_absolute():
store_root = (config_path.parent / store_root).resolve()
connections = [ConnectionConfig(**item) for item in data.get("connections", [])]
return BrokerConfig(store_root=store_root, connections=connections)
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 refresh_connection_catalog(connection_id: str) -> dict[str, Any]:
await service.refresh_connection_catalog(connection_id)
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]:
return await service.invoke_method(connection_id, method, params=params)
@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.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))
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
from typing import Any
from .broker_server import (
build_service_from_config,
load_broker_config,
run_broker_server,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="wf-mcp")
parser.add_argument(
"--config",
default="wf_mcp.config.json",
help="Path to broker config JSON.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
serve = subparsers.add_parser("serve", help="Run the broker MCP server.")
serve.add_argument(
"--transport",
default="stdio",
choices=["stdio", "sse", "streamable-http", "streamable_http"],
help="Transport to run the broker server with.",
)
subparsers.add_parser("connections", help="List configured connections.")
subparsers.add_parser("catalog", help="Print the broker catalog as JSON.")
refresh = subparsers.add_parser(
"refresh",
help="Refresh one connection catalog or all configured connections.",
)
refresh.add_argument("connection_id", nargs="?", help="Connection id to refresh.")
return parser
def _service_from_config(config_path: str | Path):
config = load_broker_config(config_path)
return build_service_from_config(config)
def _json_dump(data: Any) -> None:
print(json.dumps(data, indent=2))
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "serve":
run_broker_server(args.config, args.transport)
return 0
service = _service_from_config(args.config)
if args.command == "connections":
_json_dump(
[
{
"id": connection.id,
"server": connection.server,
"account": connection.account,
"enabled": connection.enabled,
"metadata": connection.metadata,
}
for connection in service.connections.list_all()
]
)
return 0
if args.command == "catalog":
_json_dump(service.get_catalog().as_payload())
return 0
if args.command == "refresh":
if args.connection_id:
asyncio.run(service.refresh_connection_catalog(args.connection_id))
else:
for connection in service.connections.list_enabled():
asyncio.run(service.refresh_connection_catalog(connection.id))
_json_dump(service.get_catalog().as_payload())
return 0
parser.error(f"unknown command {args.command!r}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -32,6 +32,9 @@ class ConnectionRegistry:
def get(self, connection_id: str) -> ConnectionConfig: def get(self, connection_id: str) -> ConnectionConfig:
return self.connections[connection_id] return self.connections[connection_id]
def list_all(self) -> list[ConnectionConfig]:
return list(self.connections.values())
def list_enabled(self) -> list[ConnectionConfig]: def list_enabled(self) -> list[ConnectionConfig]:
return [ return [
connection for connection in self.connections.values() if connection.enabled connection for connection in self.connections.values() if connection.enabled
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any from typing import Any
from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry
@@ -48,6 +49,12 @@ class RawWorkflowPlan:
edges: list[dict[str, Any]] edges: list[dict[str, Any]]
@dataclass(slots=True)
class BrokerConfig:
store_root: Path
connections: list[ConnectionConfig] = field(default_factory=list)
def dump_catalog_snapshot(snapshot: CatalogSnapshot) -> dict[str, Any]: def dump_catalog_snapshot(snapshot: CatalogSnapshot) -> dict[str, Any]:
return { return {
"connection_id": snapshot.connection_id, "connection_id": snapshot.connection_id,
-9
View File
@@ -1,9 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import asyncio
import json
from wf_mcp import (
BrokerConfig,
ConnectionConfig,
FileStore,
WfMcpService,
build_service_from_config,
create_broker_server,
load_broker_config,
)
from test_wf_mcp_support import FakeAdapter, local_temp_root
def test_load_broker_config_resolves_relative_store_root() -> None:
tmp_path = local_temp_root() / "broker_config_test"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps(
{
"store_root": ".broker-store",
"connections": [
{
"id": "demo.personal",
"server": "demo",
"account": "personal",
}
],
}
),
encoding="utf-8",
)
config = load_broker_config(config_path)
assert config.store_root == (tmp_path / ".broker-store").resolve()
assert [connection.id for connection in config.connections] == ["demo.personal"]
def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
service = WfMcpService(store=FileStore(local_temp_root() / "broker_server_store"))
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_adapter("demo", FakeAdapter())
asyncio.run(service.refresh_connection_catalog("demo.personal"))
server = create_broker_server(service)
tools = asyncio.run(server.list_tools())
resources = asyncio.run(server.list_resources())
prompts = asyncio.run(server.list_prompts())
tool_names = {tool.name for tool in tools}
resource_names = {resource.name for resource in resources}
prompt_names = {prompt.name for prompt in prompts}
assert "refresh_connection_catalog" in tool_names
assert "invoke_broker_method" in tool_names
assert "catalog.all" in resource_names
assert "events.all" in resource_names
assert "plan_with_catalog" in prompt_names
def test_build_service_from_config_registers_connections() -> None:
config = BrokerConfig(
store_root=local_temp_root() / "broker_config_store",
connections=[
ConnectionConfig(id="demo.personal", server="demo", account="personal"),
ConnectionConfig(id="demo.work", server="demo", account="work"),
],
)
service = build_service_from_config(config)
ids = [connection.id for connection in service.connections.list_all()]
assert ids == ["demo.personal", "demo.work"]
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import json
from pathlib import Path
from wf_mcp.cli import build_parser, main
from test_wf_mcp_support import local_temp_root
def _write_config(path: Path) -> None:
path.write_text(
json.dumps(
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "demo.personal",
"server": "demo",
"account": "personal",
}
],
}
),
encoding="utf-8",
)
def test_build_parser_accepts_serve_transport() -> None:
parser = build_parser()
args = parser.parse_args(
["--config", "wf_mcp.config.json", "serve", "--transport", "streamable_http"]
)
assert args.command == "serve"
assert args.transport == "streamable_http"
def test_cli_connections_prints_configured_connections(capsys) -> None:
tmp_path = local_temp_root() / "cli_connections_test"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
_write_config(config_path)
exit_code = main(["--config", str(config_path), "connections"])
captured = capsys.readouterr()
assert exit_code == 0
payload = json.loads(captured.out)
assert payload[0]["id"] == "demo.personal"
def test_cli_catalog_prints_empty_catalog_when_not_refreshed(
capsys,
) -> None:
tmp_path = local_temp_root() / "cli_catalog_test"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
_write_config(config_path)
exit_code = main(["--config", str(config_path), "catalog"])
captured = capsys.readouterr()
assert exit_code == 0
payload = json.loads(captured.out)
assert payload["nodes"] == []
assert payload["resources"] == []
assert payload["prompts"] == []
Generated
+1 -1
View File
@@ -251,7 +251,7 @@ wheels = [
[[package]] [[package]]
name = "lda-wf" name = "lda-wf"
version = "0.0.1" version = "0.0.1"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp", extra = ["cli", "rich"] }, { name = "mcp", extra = ["cli", "rich"] },
{ name = "pydantic" }, { name = "pydantic" },
+19
View File
@@ -0,0 +1,19 @@
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "everything.default",
"server": "everything",
"account": "default",
"enabled": true,
"metadata": {
"transport": "stdio",
"command": "pnpx",
"args": [
"-y",
"@modelcontextprotocol/server-everything"
]
}
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "everything.default",
"server": "everything",
"account": "default",
"enabled": true,
"metadata": {
"transport": "stdio",
"command": "pnpx",
"args": [
"-y",
"@modelcontextprotocol/server-everything"
]
}
}
]
}