feat: build mcp server from workflow config
This commit is contained in:
@@ -181,6 +181,10 @@ implementation state.
|
|||||||
`kind: "mcp"` entries with stdio/http transport, auth reference, metadata,
|
`kind: "mcp"` entries with stdio/http transport, auth reference, metadata,
|
||||||
enabled flag, and `locked` / `seed` ownership policy. Runtime composition
|
enabled flag, and `locked` / `seed` ownership policy. Runtime composition
|
||||||
from these entries is the next slice.
|
from these entries is the next slice.
|
||||||
|
Runtime bridge complete: neutral `kind: "mcp"` source entries can now build
|
||||||
|
the MCP-backed `WorkflowServer`. `--mcp-config` remains supported as a
|
||||||
|
legacy compatibility path while new configs should prefer
|
||||||
|
`server.sources[]`.
|
||||||
- Transport package boundary cleanup follows the config migration. The current
|
- Transport package boundary cleanup follows the config migration. The current
|
||||||
`wf-rpc-server --mcp-config` hookup proves the product path but makes
|
`wf-rpc-server --mcp-config` hookup proves the product path but makes
|
||||||
`wf_transport_rpc_http.cli` import `wf_mcp.broker`, tripping the existing
|
`wf_transport_rpc_http.cli` import `wf_mcp.broker`, tripping the existing
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ Model slice complete when `wf_config.server.sources[]` accepts `kind: "mcp"`
|
|||||||
entries. The next slice converts those neutral source entries into MCP
|
entries. The next slice converts those neutral source entries into MCP
|
||||||
broker runtime connections and server composition.
|
broker runtime connections and server composition.
|
||||||
|
|
||||||
|
Runtime bridge complete when `wf-rpc-server --config <path>` can compose an
|
||||||
|
MCP-backed server from neutral `server.sources[]` entries. `--mcp-config`
|
||||||
|
remains a compatibility alias until existing users migrate.
|
||||||
|
|
||||||
First slice should not include:
|
First slice should not include:
|
||||||
|
|
||||||
- live upstream MCP source management
|
- live upstream MCP source management
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ Start a local/static JSON-RPC workflow server:
|
|||||||
wf-rpc-server --store-root .wf_store --host 127.0.0.1 --port 8765
|
wf-rpc-server --store-root .wf_store --host 127.0.0.1 --port 8765
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Prefer neutral workflow config for new MCP-backed servers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".wf_store"},
|
||||||
|
"transports": [{"kind": "rpc_http", "host": "127.0.0.1", "port": 8765}],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "everything.default",
|
||||||
|
"provider": "everything",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {"kind": "stdio", "command": "uvx", "args": ["mcp-server-everything"]}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`--mcp-config` is still accepted for legacy broker config files.
|
||||||
|
|
||||||
Start a JSON-RPC server backed by MCP broker config and MCP-capable sources:
|
Start a JSON-RPC server backed by MCP broker config and MCP-capable sources:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .discovery import (
|
|||||||
from .events import McpEvent, make_event
|
from .events import McpEvent, make_event
|
||||||
from .server import (
|
from .server import (
|
||||||
build_workflow_server_from_config,
|
build_workflow_server_from_config,
|
||||||
|
build_workflow_server_from_workflow_config,
|
||||||
create_broker_server,
|
create_broker_server,
|
||||||
workflow_server_from_service,
|
workflow_server_from_service,
|
||||||
)
|
)
|
||||||
@@ -21,6 +22,7 @@ __all__ = [
|
|||||||
"WfMcpService",
|
"WfMcpService",
|
||||||
"build_service_from_config",
|
"build_service_from_config",
|
||||||
"build_workflow_server_from_config",
|
"build_workflow_server_from_config",
|
||||||
|
"build_workflow_server_from_workflow_config",
|
||||||
"create_broker_server",
|
"create_broker_server",
|
||||||
"discover_connection_capabilities",
|
"discover_connection_capabilities",
|
||||||
"load_broker_config",
|
"load_broker_config",
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from wf_api import file_workflow_stores
|
from wf_api import file_workflow_stores
|
||||||
|
from wf_config import WorkflowConfigFile
|
||||||
|
|
||||||
from ..control import BrokerConfigFile
|
from ..control import BrokerConfigFile
|
||||||
from ..models import BrokerConfig
|
from ..models import BrokerConfig
|
||||||
from ..runtime import McpRuntimePool, PersistentSessionFactory
|
from ..runtime import McpRuntimePool, PersistentSessionFactory
|
||||||
from ..sdk import McpSdkAdapter
|
from ..sdk import McpSdkAdapter
|
||||||
from ..source_registry import FileSourceRegistryStore
|
from ..source_registry import FileSourceRegistryStore, workflow_mcp_source_to_connection_config
|
||||||
from ..storage import FileStore
|
from ..storage import FileStore
|
||||||
from .service import WfMcpService
|
from .service import WfMcpService
|
||||||
|
|
||||||
@@ -21,6 +22,18 @@ def load_broker_config(path: str | Path) -> BrokerConfig:
|
|||||||
return BrokerConfigFile.model_validate(data).to_runtime(config_path=config_path)
|
return BrokerConfigFile.model_validate(data).to_runtime(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def broker_config_from_workflow_config(config: WorkflowConfigFile) -> BrokerConfig:
|
||||||
|
"""Create MCP broker runtime config from neutral workflow server config."""
|
||||||
|
return BrokerConfig(
|
||||||
|
store_root=config.server.store.root,
|
||||||
|
connections=[
|
||||||
|
workflow_mcp_source_to_connection_config(source)
|
||||||
|
for source in config.server.sources
|
||||||
|
if getattr(source, "kind", None) == "mcp"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
||||||
"""Create a broker service with SDK adapters for configured connections."""
|
"""Create a broker service with SDK adapters for configured connections."""
|
||||||
runtime_factory = PersistentSessionFactory()
|
runtime_factory = PersistentSessionFactory()
|
||||||
@@ -44,3 +57,10 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
|||||||
if connection.server not in service.adapters:
|
if connection.server not in service.adapters:
|
||||||
service.register_adapter(connection.server, McpSdkAdapter())
|
service.register_adapter(connection.server, McpSdkAdapter())
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"broker_config_from_workflow_config",
|
||||||
|
"build_service_from_config",
|
||||||
|
"load_broker_config",
|
||||||
|
]
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ from wf_api import (
|
|||||||
from wf_api.stores import WorkflowStores
|
from wf_api.stores import WorkflowStores
|
||||||
from wf_server import WorkflowServer, WorkflowServerConfig
|
from wf_server import WorkflowServer, WorkflowServerConfig
|
||||||
|
|
||||||
|
from wf_config import WorkflowConfigFile
|
||||||
from .artifact_tools import register_artifact_tools
|
from .artifact_tools import register_artifact_tools
|
||||||
from .config import build_service_from_config
|
from .config import broker_config_from_workflow_config, build_service_from_config
|
||||||
from .prompts import register_broker_prompts
|
from .prompts import register_broker_prompts
|
||||||
from .resources import register_broker_resources
|
from .resources import register_broker_resources
|
||||||
from .service import WfMcpService
|
from .service import WfMcpService
|
||||||
@@ -101,8 +102,18 @@ def build_workflow_server_from_config(config: BrokerConfig) -> WorkflowServer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow_server_from_workflow_config(
|
||||||
|
config: WorkflowConfigFile,
|
||||||
|
) -> WorkflowServer:
|
||||||
|
"""Build an MCP-backed WorkflowServer from neutral workflow config sources."""
|
||||||
|
return build_workflow_server_from_config(
|
||||||
|
broker_config_from_workflow_config(config)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"build_workflow_server_from_config",
|
"build_workflow_server_from_config",
|
||||||
|
"build_workflow_server_from_workflow_config",
|
||||||
"create_broker_server",
|
"create_broker_server",
|
||||||
"workflow_server_from_service",
|
"workflow_server_from_service",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -160,6 +160,44 @@ def connection_config_to_registry_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_mcp_source_to_connection_config(source: object) -> ConnectionConfig:
|
||||||
|
"""Convert neutral wf_config MCP source config into a broker connection.
|
||||||
|
|
||||||
|
Keep this adapter in wf_mcp because the output is MCP broker runtime state.
|
||||||
|
The input is intentionally typed as object to avoid making wf_mcp's public
|
||||||
|
registry module part of wf_config's import graph.
|
||||||
|
"""
|
||||||
|
from .models import ConnectionConfig
|
||||||
|
|
||||||
|
if getattr(source, "kind", None) != "mcp":
|
||||||
|
raise ValueError("expected wf_config MCP source")
|
||||||
|
for field in ("id", "provider", "account", "enabled", "ownership"):
|
||||||
|
if getattr(source, field, None) is None:
|
||||||
|
raise ValueError(f"wf_config MCP source missing required field: {field}")
|
||||||
|
transport = getattr(source, "transport")
|
||||||
|
metadata = dict(getattr(source, "metadata", {}))
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"transport": transport.model_dump(mode="json"),
|
||||||
|
"source_registry": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile = getattr(source, "profile", None)
|
||||||
|
if profile is not None:
|
||||||
|
metadata["profile"] = profile
|
||||||
|
auth_ref = getattr(source, "auth_ref", None)
|
||||||
|
if auth_ref is not None:
|
||||||
|
metadata["auth_ref"] = auth_ref
|
||||||
|
return ConnectionConfig(
|
||||||
|
id=getattr(source, "id"),
|
||||||
|
server=getattr(source, "provider"),
|
||||||
|
account=getattr(source, "account"),
|
||||||
|
enabled=getattr(source, "enabled"),
|
||||||
|
metadata=metadata,
|
||||||
|
source_config_ownership=getattr(source, "ownership"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FileSourceRegistryStore",
|
"FileSourceRegistryStore",
|
||||||
"HttpSourceTransport",
|
"HttpSourceTransport",
|
||||||
@@ -170,4 +208,5 @@ __all__ = [
|
|||||||
"StdioSourceTransport",
|
"StdioSourceTransport",
|
||||||
"connection_config_to_registry_entry",
|
"connection_config_to_registry_entry",
|
||||||
"registry_entry_to_connection_config",
|
"registry_entry_to_connection_config",
|
||||||
|
"workflow_mcp_source_to_connection_config",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from wf_config import (
|
|||||||
load_workflow_config,
|
load_workflow_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
from wf_mcp.broker import build_workflow_server_from_config, load_broker_config
|
from wf_mcp.broker import (
|
||||||
|
build_workflow_server_from_config,
|
||||||
|
build_workflow_server_from_workflow_config,
|
||||||
|
load_broker_config,
|
||||||
|
)
|
||||||
|
|
||||||
from wf_server import build_local_static_workflow_server
|
from wf_server import build_local_static_workflow_server
|
||||||
|
|
||||||
@@ -66,6 +70,12 @@ def serve(
|
|||||||
|
|
||||||
if config is not None:
|
if config is not None:
|
||||||
workflow_config = load_workflow_config(config)
|
workflow_config = load_workflow_config(config)
|
||||||
|
has_mcp_sources = any(
|
||||||
|
getattr(source, "kind", None) == "mcp"
|
||||||
|
for source in workflow_config.server.sources
|
||||||
|
)
|
||||||
|
if server is None and has_mcp_sources:
|
||||||
|
server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
store = workflow_config.server.store
|
store = workflow_config.server.store
|
||||||
if server is None and not isinstance(store, FilesystemStoreConfig):
|
if server is None and not isinstance(store, FilesystemStoreConfig):
|
||||||
raise typer.BadParameter(
|
raise typer.BadParameter(
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_config import WorkflowConfigFile
|
||||||
|
from wf_mcp.broker.config import broker_config_from_workflow_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_config_from_workflow_config_converts_mcp_sources(tmp_path) -> None:
|
||||||
|
workflow_config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": str(tmp_path / "store")},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "everything.default",
|
||||||
|
"enabled": True,
|
||||||
|
"provider": "everything",
|
||||||
|
"account": "default",
|
||||||
|
"profile": "dev",
|
||||||
|
"ownership": "seed",
|
||||||
|
"transport": {
|
||||||
|
"kind": "stdio",
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-everything"],
|
||||||
|
"env": {"DEBUG": "1"},
|
||||||
|
},
|
||||||
|
"auth_ref": "auth.everything.default",
|
||||||
|
"metadata": {"description": "Everything test server"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
broker_config = broker_config_from_workflow_config(workflow_config)
|
||||||
|
|
||||||
|
assert broker_config.store_root == tmp_path / "store"
|
||||||
|
assert len(broker_config.connections) == 1
|
||||||
|
connection = broker_config.connections[0]
|
||||||
|
assert connection.id == "everything.default"
|
||||||
|
assert connection.server == "everything"
|
||||||
|
assert connection.account == "default"
|
||||||
|
assert connection.enabled is True
|
||||||
|
assert connection.source_config_ownership == "seed"
|
||||||
|
assert connection.metadata["profile"] == "dev"
|
||||||
|
assert connection.metadata["auth_ref"] == "auth.everything.default"
|
||||||
|
assert connection.metadata["transport"] == {
|
||||||
|
"kind": "stdio",
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-everything"],
|
||||||
|
"env": {"DEBUG": "1"},
|
||||||
|
}
|
||||||
|
assert connection.metadata["description"] == "Everything test server"
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_config_from_workflow_config_ignores_non_mcp_sources(tmp_path) -> None:
|
||||||
|
workflow_config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": str(tmp_path / "store")},
|
||||||
|
"sources": [{"kind": "stdlib", "id": "wf.std"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
broker_config = broker_config_from_workflow_config(workflow_config)
|
||||||
|
|
||||||
|
assert broker_config.store_root == tmp_path / "store"
|
||||||
|
assert broker_config.connections == []
|
||||||
@@ -281,3 +281,65 @@ def test_rpc_server_cli_mcp_config_with_config_uses_transport_settings(monkeypat
|
|||||||
assert captured["host"] == "127.0.0.3"
|
assert captured["host"] == "127.0.0.3"
|
||||||
assert captured["port"] == 7777
|
assert captured["port"] == 7777
|
||||||
assert captured["access_log"] is False
|
assert captured["access_log"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_server_cli_config_with_mcp_source_uses_mcp_builder(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_build_from_workflow_config(config):
|
||||||
|
captured["source_kinds"] = [source.kind for source in config.server.sources]
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def fake_create_rpc_app(server, *, rpc_path="/rpc"):
|
||||||
|
captured["server"] = server
|
||||||
|
captured["rpc_path"] = rpc_path
|
||||||
|
return "app"
|
||||||
|
|
||||||
|
def fake_run(app, *, host, port, access_log):
|
||||||
|
captured["run"] = {
|
||||||
|
"app": app,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"access_log": access_log,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_transport_rpc_http.cli.build_workflow_server_from_workflow_config",
|
||||||
|
fake_build_from_workflow_config,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("wf_transport_rpc_http.cli.create_rpc_app", fake_create_rpc_app)
|
||||||
|
monkeypatch.setattr("wf_transport_rpc_http.cli.uvicorn.run", fake_run)
|
||||||
|
|
||||||
|
config_path = tmp_path / "wf.json"
|
||||||
|
config_path.write_text(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": ".wf_store"},
|
||||||
|
"transports": [{"kind": "rpc_http", "host": "127.0.0.1", "port": 8765}],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "everything.default",
|
||||||
|
"provider": "everything",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {"kind": "stdio", "command": "uvx"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
from wf_transport_rpc_http.cli import app
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
result = CliRunner().invoke(app, ["--config", str(config_path)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert captured["source_kinds"] == ["mcp"]
|
||||||
|
assert captured["run"]["app"] == "app"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wf_mcp.broker.server import build_workflow_server_from_config
|
from wf_config import WorkflowConfigFile
|
||||||
|
from wf_mcp.broker.server import build_workflow_server_from_config, build_workflow_server_from_workflow_config
|
||||||
from wf_mcp.models import BrokerConfig, ConnectionConfig
|
from wf_mcp.models import BrokerConfig, ConnectionConfig
|
||||||
from wf_mcp.source_registry import (
|
from wf_mcp.source_registry import (
|
||||||
FileSourceRegistryStore,
|
FileSourceRegistryStore,
|
||||||
@@ -109,3 +110,37 @@ async def test_mcp_backed_rpc_reports_connections_and_events(tmp_path) -> None:
|
|||||||
event["kind"] == "connection_registered"
|
event["kind"] == "connection_registered"
|
||||||
for event in events["result"]["events"]
|
for event in events["result"]["events"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_backed_rpc_can_be_built_from_neutral_workflow_config(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
workflow_config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"store": {"kind": "filesystem", "root": str(tmp_path / "store")},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "demo.default",
|
||||||
|
"provider": "demo",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {"kind": "stdio", "command": "demo-server"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
server = build_workflow_server_from_workflow_config(workflow_config)
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as http_client:
|
||||||
|
connections = await _rpc(
|
||||||
|
http_client, "workflow.admin.connections.list", {}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert connections["result"]["connections"][0]["id"] == "demo.default"
|
||||||
|
|||||||
Reference in New Issue
Block a user