feat: add mcp source config model
This commit is contained in:
@@ -177,6 +177,10 @@ implementation state.
|
|||||||
When migrating, carry that policy into the neutral MCP source variant with a
|
When migrating, carry that policy into the neutral MCP source variant with a
|
||||||
clearer name such as `config_ownership` or `ownership`, rather than leaking
|
clearer name such as `config_ownership` or `ownership`, rather than leaking
|
||||||
the old connection-centric field name.
|
the old connection-centric field name.
|
||||||
|
First slice complete: `wf_config.server.sources[]` now accepts
|
||||||
|
`kind: "mcp"` entries with stdio/http transport, auth reference, metadata,
|
||||||
|
enabled flag, and `locked` / `seed` ownership policy. Runtime composition
|
||||||
|
from these entries is the next slice.
|
||||||
- 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
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ Implementation status:
|
|||||||
- Slice 5 complete: `wf-rpc-server --mcp-config <path>` starts JSON-RPC over an
|
- Slice 5 complete: `wf-rpc-server --mcp-config <path>` starts JSON-RPC over an
|
||||||
MCP-backed `WorkflowServer`; `--store-root` remains local/static-only.
|
MCP-backed `WorkflowServer`; `--store-root` remains local/static-only.
|
||||||
|
|
||||||
|
Model slice complete when `wf_config.server.sources[]` accepts `kind: "mcp"`
|
||||||
|
entries. The next slice converts those neutral source entries into MCP
|
||||||
|
broker runtime connections and server composition.
|
||||||
|
|
||||||
First slice should not include:
|
First slice should not include:
|
||||||
|
|
||||||
- live upstream MCP source management
|
- live upstream MCP source management
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ from .loader import load_workflow_config
|
|||||||
from .models import (
|
from .models import (
|
||||||
ClientConfig,
|
ClientConfig,
|
||||||
FilesystemStoreConfig,
|
FilesystemStoreConfig,
|
||||||
|
HttpSourceTransportConfig,
|
||||||
LocalTargetConfig,
|
LocalTargetConfig,
|
||||||
|
McpSourceConfig,
|
||||||
RpcHttpTargetConfig,
|
RpcHttpTargetConfig,
|
||||||
RpcHttpTransportConfig,
|
RpcHttpTransportConfig,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
|
SourceConfigOwnership,
|
||||||
|
SourceTransportConfig,
|
||||||
|
StdioSourceTransportConfig,
|
||||||
StdlibSourceConfig,
|
StdlibSourceConfig,
|
||||||
WorkflowConfigFile,
|
WorkflowConfigFile,
|
||||||
)
|
)
|
||||||
@@ -16,10 +21,15 @@ __all__ = [
|
|||||||
"load_workflow_config",
|
"load_workflow_config",
|
||||||
"ClientConfig",
|
"ClientConfig",
|
||||||
"FilesystemStoreConfig",
|
"FilesystemStoreConfig",
|
||||||
|
"HttpSourceTransportConfig",
|
||||||
"LocalTargetConfig",
|
"LocalTargetConfig",
|
||||||
|
"McpSourceConfig",
|
||||||
"RpcHttpTargetConfig",
|
"RpcHttpTargetConfig",
|
||||||
"RpcHttpTransportConfig",
|
"RpcHttpTransportConfig",
|
||||||
"ServerConfig",
|
"ServerConfig",
|
||||||
|
"SourceConfigOwnership",
|
||||||
|
"SourceTransportConfig",
|
||||||
|
"StdioSourceTransportConfig",
|
||||||
"StdlibSourceConfig",
|
"StdlibSourceConfig",
|
||||||
"WorkflowConfigFile",
|
"WorkflowConfigFile",
|
||||||
]
|
]
|
||||||
|
|||||||
+59
-1
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
@@ -69,14 +70,71 @@ ServerTransportConfig = Annotated[
|
|||||||
Field(discriminator="kind"),
|
Field(discriminator="kind"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SourceConfigOwnership = Literal["locked", "seed"]
|
||||||
|
SOURCE_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
|
||||||
|
|
||||||
|
|
||||||
|
class StdioSourceTransportConfig(WorkflowConfigModel):
|
||||||
|
kind: Literal["stdio"] = "stdio"
|
||||||
|
command: str = Field(min_length=1)
|
||||||
|
args: tuple[str, ...] = ()
|
||||||
|
env: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpSourceTransportConfig(WorkflowConfigModel):
|
||||||
|
kind: Literal["http"] = "http"
|
||||||
|
url: AnyHttpUrl
|
||||||
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
SourceTransportConfig = Annotated[
|
||||||
|
StdioSourceTransportConfig | HttpSourceTransportConfig,
|
||||||
|
Field(discriminator="kind"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class StdlibSourceConfig(WorkflowConfigModel):
|
class StdlibSourceConfig(WorkflowConfigModel):
|
||||||
kind: Literal["stdlib"]
|
kind: Literal["stdlib"]
|
||||||
id: Literal["wf.std", "wf.recipes"]
|
id: Literal["wf.std", "wf.recipes"]
|
||||||
|
|
||||||
|
|
||||||
|
class McpSourceConfig(WorkflowConfigModel):
|
||||||
|
"""Neutral config shape for MCP-backed workflow capability sources.
|
||||||
|
|
||||||
|
This intentionally mirrors `wf_mcp.source_registry.McpSourceRegistryEntry`
|
||||||
|
without importing MCP modules. `ownership` carries the old
|
||||||
|
`ConnectionConfig.source_config_ownership` policy with neutral terminology.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: Literal["mcp"] = "mcp"
|
||||||
|
id: str
|
||||||
|
enabled: bool = True
|
||||||
|
provider: str = Field(min_length=1)
|
||||||
|
account: str = Field(min_length=1)
|
||||||
|
profile: str | None = None
|
||||||
|
ownership: SourceConfigOwnership = "locked"
|
||||||
|
transport: SourceTransportConfig
|
||||||
|
auth_ref: str | None = None
|
||||||
|
metadata: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("id")
|
||||||
|
@classmethod
|
||||||
|
def validate_source_id(cls, value: str) -> str:
|
||||||
|
if not re.fullmatch(SOURCE_ID_PATTERN, value):
|
||||||
|
raise ValueError(
|
||||||
|
"source id must start with alphanumeric or underscore and contain "
|
||||||
|
"only [A-Za-z0-9_.-]"
|
||||||
|
)
|
||||||
|
if "." not in value:
|
||||||
|
raise ValueError("source id must look like '<provider>.<account>'")
|
||||||
|
provider, account = value.split(".", 1)
|
||||||
|
if not provider or not account:
|
||||||
|
raise ValueError("source id must look like '<provider>.<account>'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
SourceConfig = Annotated[
|
SourceConfig = Annotated[
|
||||||
StdlibSourceConfig,
|
StdlibSourceConfig | McpSourceConfig,
|
||||||
Field(discriminator="kind"),
|
Field(discriminator="kind"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from wf_config import (
|
from wf_config import (
|
||||||
FilesystemStoreConfig,
|
FilesystemStoreConfig,
|
||||||
|
HttpSourceTransportConfig,
|
||||||
LocalTargetConfig,
|
LocalTargetConfig,
|
||||||
|
McpSourceConfig,
|
||||||
RpcHttpTargetConfig,
|
RpcHttpTargetConfig,
|
||||||
RpcHttpTransportConfig,
|
RpcHttpTransportConfig,
|
||||||
|
StdioSourceTransportConfig,
|
||||||
StdlibSourceConfig,
|
StdlibSourceConfig,
|
||||||
WorkflowConfigFile,
|
WorkflowConfigFile,
|
||||||
load_workflow_config,
|
load_workflow_config,
|
||||||
@@ -154,3 +157,139 @@ def test_load_workflow_config_preserves_absolute_filesystem_store(
|
|||||||
config = load_workflow_config(config_path)
|
config = load_workflow_config(config_path)
|
||||||
|
|
||||||
assert config.server.store.root == absolute_root
|
assert config.server.store.root == absolute_root
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_config_parses_mcp_stdio_source() -> None:
|
||||||
|
config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"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"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
source = config.server.sources[0]
|
||||||
|
assert isinstance(source, McpSourceConfig)
|
||||||
|
assert source.id == "everything.default"
|
||||||
|
assert source.enabled is True
|
||||||
|
assert source.provider == "everything"
|
||||||
|
assert source.account == "default"
|
||||||
|
assert source.profile == "dev"
|
||||||
|
assert source.ownership == "seed"
|
||||||
|
assert isinstance(source.transport, StdioSourceTransportConfig)
|
||||||
|
assert source.transport.command == "uvx"
|
||||||
|
assert source.transport.args == ("mcp-server-everything",)
|
||||||
|
assert source.transport.env == {"DEBUG": "1"}
|
||||||
|
assert source.auth_ref == "auth.everything.default"
|
||||||
|
assert source.metadata["description"] == "Everything test server"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_config_parses_mcp_http_source() -> None:
|
||||||
|
config = WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "context7.default",
|
||||||
|
"provider": "context7",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {
|
||||||
|
"kind": "http",
|
||||||
|
"url": "http://127.0.0.1:3000/mcp",
|
||||||
|
"headers": {"X-Test": "yes"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
source = config.server.sources[0]
|
||||||
|
assert isinstance(source, McpSourceConfig)
|
||||||
|
assert source.enabled is True
|
||||||
|
assert source.ownership == "locked"
|
||||||
|
assert isinstance(source.transport, HttpSourceTransportConfig)
|
||||||
|
assert str(source.transport.url) == "http://127.0.0.1:3000/mcp"
|
||||||
|
assert source.transport.headers == {"X-Test": "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_config_rejects_mcp_source_without_provider_account_shape() -> None:
|
||||||
|
with pytest.raises(ValidationError, match="source id must look like"):
|
||||||
|
WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "everything",
|
||||||
|
"provider": "everything",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {"kind": "stdio", "command": "uvx"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_config_rejects_unsafe_mcp_source_id() -> None:
|
||||||
|
with pytest.raises(ValidationError, match="source id must start"):
|
||||||
|
WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": ".hidden.default",
|
||||||
|
"provider": "hidden",
|
||||||
|
"account": "default",
|
||||||
|
"transport": {"kind": "stdio", "command": "uvx"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_config_rejects_duplicate_source_ids_across_kinds() -> None:
|
||||||
|
with pytest.raises(ValidationError, match="duplicate source id"):
|
||||||
|
WorkflowConfigFile.model_validate(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"server": {
|
||||||
|
"sources": [
|
||||||
|
{"kind": "stdlib", "id": "wf.std"},
|
||||||
|
{
|
||||||
|
"kind": "mcp",
|
||||||
|
"id": "wf.std",
|
||||||
|
"provider": "wf",
|
||||||
|
"account": "std",
|
||||||
|
"transport": {"kind": "stdio", "command": "uvx"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user