feat: add mcp source config model

This commit is contained in:
lda
2026-06-05 01:08:59 +07:00 Verified
parent deaaa5c689
commit 0b9ff5fcd1
5 changed files with 219 additions and 4 deletions
+7 -3
View File
@@ -174,9 +174,13 @@ implementation state.
alias.
`McpSourceRegistryEntry` already has most of the target shape; the one
ownership field comes from legacy `ConnectionConfig.source_config_ownership`.
When migrating, carry that policy into the neutral MCP source variant with a
clearer name such as `config_ownership` or `ownership`, rather than leaking
the old connection-centric field name.
When migrating, carry that policy into the neutral MCP source variant with a
clearer name such as `config_ownership` or `ownership`, rather than leaking
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
`wf-rpc-server --mcp-config` hookup proves the product path but makes
`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
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:
- live upstream MCP source management
+10
View File
@@ -4,10 +4,15 @@ from .loader import load_workflow_config
from .models import (
ClientConfig,
FilesystemStoreConfig,
HttpSourceTransportConfig,
LocalTargetConfig,
McpSourceConfig,
RpcHttpTargetConfig,
RpcHttpTransportConfig,
ServerConfig,
SourceConfigOwnership,
SourceTransportConfig,
StdioSourceTransportConfig,
StdlibSourceConfig,
WorkflowConfigFile,
)
@@ -16,10 +21,15 @@ __all__ = [
"load_workflow_config",
"ClientConfig",
"FilesystemStoreConfig",
"HttpSourceTransportConfig",
"LocalTargetConfig",
"McpSourceConfig",
"RpcHttpTargetConfig",
"RpcHttpTransportConfig",
"ServerConfig",
"SourceConfigOwnership",
"SourceTransportConfig",
"StdioSourceTransportConfig",
"StdlibSourceConfig",
"WorkflowConfigFile",
]
+59 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Annotated, Literal
@@ -69,14 +70,71 @@ ServerTransportConfig = Annotated[
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):
kind: Literal["stdlib"]
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[
StdlibSourceConfig,
StdlibSourceConfig | McpSourceConfig,
Field(discriminator="kind"),
]
+139
View File
@@ -8,9 +8,12 @@ from pydantic import ValidationError
from wf_config import (
FilesystemStoreConfig,
HttpSourceTransportConfig,
LocalTargetConfig,
McpSourceConfig,
RpcHttpTargetConfig,
RpcHttpTransportConfig,
StdioSourceTransportConfig,
StdlibSourceConfig,
WorkflowConfigFile,
load_workflow_config,
@@ -154,3 +157,139 @@ def test_load_workflow_config_preserves_absolute_filesystem_store(
config = load_workflow_config(config_path)
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"},
},
]
},
}
)