feat: add mcp source config model
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user