refactor: move mcp source registry to wf_sources_mcp

This commit is contained in:
lda
2026-06-07 00:09:38 +07:00 Verified
parent 13de7d1495
commit 83a3793534
15 changed files with 591 additions and 274 deletions
+10 -6
View File
@@ -273,12 +273,16 @@ implementation state.
as workflow capabilities. The current `wf_mcp` package still contains both as workflow capabilities. The current `wf_mcp` package still contains both
roles plus compatibility entrypoints; new server/transport work should avoid roles plus compatibility entrypoints; new server/transport work should avoid
depending on that combined facade. depending on that combined facade.
First `wf_sources_mcp` slice complete: MCP auth helpers and focused First `wf_sources_mcp` slice complete: MCP auth helpers and focused
auth/catalog stores now live in `wf_sources_mcp`, with `wf_mcp` compatibility auth/catalog stores now live in `wf_sources_mcp`, with `wf_mcp` compatibility
shims preserved. Runtime/session/source-registry moves remain future slices. shims preserved. Runtime/session/source-registry moves remain future slices.
Keep `wf_mcp` re-export shims for compatibility and add import-direction Keep `wf_mcp` re-export shims for compatibility and add import-direction
tests so `wf_sources_mcp` does not depend on workflow/admin surface, tests so `wf_sources_mcp` does not depend on workflow/admin surface,
frontend server, or proxy modules. frontend server, or proxy modules.
Second `wf_sources_mcp` slice complete: MCP desired source registry
models, file store, and conversion helpers now live in
`wf_sources_mcp.source_registry`, with `wf_mcp.source_registry` retained
as a compatibility shim.
The `wf-mcp` script is now a legacy/special-purpose MCP entrypoint, not the The `wf-mcp` script is now a legacy/special-purpose MCP entrypoint, not the
preferred durable workflow server. New product paths should target preferred durable workflow server. New product paths should target
`wf-rpc-server` plus neutral `wf_config`/`wf_server` composition, then keep `wf-rpc-server` plus neutral `wf_config`/`wf_server` composition, then keep
@@ -89,7 +89,7 @@ First slices should move leaf modules only and leave `wf_mcp` re-export shims:
1. Complete: MCP auth helpers and focused auth/catalog stores moved to 1. Complete: MCP auth helpers and focused auth/catalog stores moved to
`wf_sources_mcp`, with `wf_mcp` shims preserved. `wf_sources_mcp`, with `wf_mcp` shims preserved.
2. MCP source registry models/conversion. 2. Complete: MCP source registry models/conversion moved to `wf_sources_mcp.source_registry`, with `wf_mcp.source_registry` retained as a shim.
3. Upstream transport/discovery/session services. 3. Upstream transport/discovery/session services.
Each slice should add import-direction tests so the new source-provider package Each slice should add import-direction tests so the new source-provider package
+4 -4
View File
@@ -6,16 +6,16 @@ from pathlib import Path
from wf_api import file_workflow_stores from wf_api import file_workflow_stores
from wf_config import WorkflowConfigFile from wf_config import WorkflowConfigFile
from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfig from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfig
from wf_sources_mcp.source_registry import (
FileSourceRegistryStore,
workflow_mcp_source_to_connection_config,
)
from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore, FileStore from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore, FileStore
from ..control import BrokerConfigFile, ConnectionConfigFile from ..control import BrokerConfigFile, ConnectionConfigFile
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,
workflow_mcp_source_to_connection_config,
)
from .models import BrokerStoreRoots from .models import BrokerStoreRoots
from .service import WfMcpService from .service import WfMcpService
+1 -1
View File
@@ -12,10 +12,10 @@ from wf_api import (
from wf_api.stores import WorkflowStores from wf_api.stores import WorkflowStores
from wf_config import WorkflowConfigFile from wf_config import WorkflowConfigFile
from wf_server import WorkflowServer, WorkflowServerConfig from wf_server import WorkflowServer, WorkflowServerConfig
from wf_sources_mcp.source_registry import FileSourceRegistryStore, SourceRegistryStore
from ..models import BrokerConfig from ..models import BrokerConfig
from ..sdk.adapter import McpSdkAdapter from ..sdk.adapter import McpSdkAdapter
from ..source_registry import FileSourceRegistryStore, SourceRegistryStore
from .artifact_tools import register_artifact_tools from .artifact_tools import register_artifact_tools
from .config import broker_config_from_workflow_config, 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
@@ -2,15 +2,16 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from ...connections import ConnectionRegistry, parse_connection_id from wf_sources_mcp.source_registry import (
from ...models import BrokerConfig, ConnectionConfig
from ...shared.names import RESERVED_CONNECTION_IDS
from ...source_registry import (
SourceRegistryFile, SourceRegistryFile,
SourceRegistryStore, SourceRegistryStore,
connection_config_to_registry_entry, connection_config_to_registry_entry,
registry_entry_to_connection_config, registry_entry_to_connection_config,
) )
from ...connections import ConnectionRegistry, parse_connection_id
from ...models import BrokerConfig, ConnectionConfig
from ...shared.names import RESERVED_CONNECTION_IDS
from .events import BrokerEventRecorder from .events import BrokerEventRecorder
from .source_catalog import SourceCatalogService from .source_catalog import SourceCatalogService
+1 -1
View File
@@ -26,6 +26,7 @@ from wf_platform import (
CapabilitySource, CapabilitySource,
) )
from wf_sources_mcp.auth import AuthRecord from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.source_registry import SourceRegistryStore
from wf_sources_mcp.storage import AuthStore, CatalogStore, Store from wf_sources_mcp.storage import AuthStore, CatalogStore, Store
from ...connections import ConnectionRegistry from ...connections import ConnectionRegistry
@@ -37,7 +38,6 @@ from ...models import (
) )
from ...runtime import ToolExecutor from ...runtime import ToolExecutor
from ...sdk import BackendAdapter from ...sdk import BackendAdapter
from ...source_registry import SourceRegistryStore
from ..admin_capabilities import admin_source from ..admin_capabilities import admin_source
from ..catalog import CombinedCatalog from ..catalog import CombinedCatalog
from .builtins import builtin_sources from .builtins import builtin_sources
@@ -5,14 +5,14 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from wf_api.source_registry_admin import WorkflowSourceRegistryMutationProvider from wf_api.source_registry_admin import WorkflowSourceRegistryMutationProvider
from wf_sources_mcp.source_registry import (
from ...auth import AuthRecord, connection_auth_diagnostic
from ...models import BrokerConfig, ConnectionConfig
from ...source_registry import (
McpSourceRegistryEntry, McpSourceRegistryEntry,
SourceRegistryFile, SourceRegistryFile,
SourceRegistryStore, SourceRegistryStore,
) )
from ...auth import AuthRecord, connection_auth_diagnostic
from ...models import BrokerConfig, ConnectionConfig
from .connection_service import ConnectionService from .connection_service import ConnectionService
+2 -1
View File
@@ -7,6 +7,8 @@ from fastmcp import FastMCP
from fastmcp.client import Client from fastmcp.client import Client
from fastmcp.client.transports.memory import FastMCPTransport from fastmcp.client.transports.memory import FastMCPTransport
from wf_sources_mcp.source_registry import FileSourceRegistryStore
from ..admin_surface import register_service_admin_tools from ..admin_surface import register_service_admin_tools
from ..broker.config import build_service_from_config from ..broker.config import build_service_from_config
from ..broker.transport import normalize_transport from ..broker.transport import normalize_transport
@@ -14,7 +16,6 @@ from ..documentation import build_local_documentation_source
from ..models import BrokerConfig from ..models import BrokerConfig
from ..proxy.runtime import ProxyRuntime from ..proxy.runtime import ProxyRuntime
from ..sdk import McpSdkAdapter from ..sdk import McpSdkAdapter
from ..source_registry import FileSourceRegistryStore
from ..workflow_surface import register_workflow_tools from ..workflow_surface import register_workflow_tools
from .prompts import register_documentation_prompts from .prompts import register_documentation_prompts
from .resources import register_documentation_resources from .resources import register_documentation_resources
+16 -252
View File
@@ -1,259 +1,23 @@
"""Compatibility shim for MCP source registry models.
Canonical implementation lives in `wf_sources_mcp.source_registry`.
"""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from wf_sources_mcp.source_registry import (
from typing import TYPE_CHECKING, Annotated, Literal, Protocol FileSourceRegistryStore,
HttpSourceTransport,
from pydantic import ( McpSourceRegistryEntry,
AnyHttpUrl, SourceRegistryFile,
Field, SourceRegistryStore,
field_validator, SourceTransport,
model_validator, StdioSourceTransport,
connection_config_to_registry_entry,
registry_entry_to_connection_config,
workflow_mcp_source_to_connection_config,
) )
from wf_api.source_registry import (
AtomicJsonRegistryStore,
SourceRegistryBaseModel,
validate_unique_source_ids,
)
from wf_api.source_registry import (
SourceRegistryStore as GenericSourceRegistryStore,
)
from .connections import parse_connection_id
from .shared.names import RESERVED_CONNECTION_IDS
if TYPE_CHECKING:
from .models import ConnectionConfig
_FLAT_HTTP_TRANSPORTS = {"http", "streamable-http", "streamable_http", "sse"}
_TRANSPORT_METADATA_KEYS = {
"transport",
"command",
"args",
"env",
"cwd",
"url",
"headers",
"profile",
"auth_ref",
"source_registry",
}
class StdioSourceTransport(SourceRegistryBaseModel):
kind: Literal["stdio"] = "stdio"
command: str = Field(min_length=1)
args: tuple[str, ...] = ()
env: dict[str, str] = Field(default_factory=dict)
class HttpSourceTransport(SourceRegistryBaseModel):
kind: Literal["http"] = "http"
url: AnyHttpUrl
headers: dict[str, str] = Field(default_factory=dict)
SourceTransport = Annotated[
StdioSourceTransport | HttpSourceTransport,
Field(discriminator="kind"),
]
class McpSourceRegistryEntry(SourceRegistryBaseModel):
"""Desired MCP source configuration persisted by server-owned mutation."""
id: str
kind: Literal["mcp"] = "mcp"
enabled: bool = True
provider: str = Field(min_length=1)
account: str = Field(min_length=1)
profile: str | None = None
transport: SourceTransport
auth_ref: str | None = None
metadata: dict[str, object] = Field(default_factory=dict)
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
parse_connection_id(value)
if value in RESERVED_CONNECTION_IDS:
raise ValueError(f"source id {value!r} is reserved")
return value
class SourceRegistryFile(SourceRegistryBaseModel):
version: Literal[1] = 1
sources: list[McpSourceRegistryEntry] = Field(default_factory=list)
@model_validator(mode="after")
def validate_unique_source_ids(self) -> SourceRegistryFile:
validate_unique_source_ids(self.sources)
return self
def source_map(self) -> dict[str, McpSourceRegistryEntry]:
return {source.id: source for source in self.sources}
class SourceRegistryStore(GenericSourceRegistryStore[SourceRegistryFile], Protocol):
"""MCP-specialized persistence boundary for desired source configuration."""
class FileSourceRegistryStore:
"""Filesystem implementation for desired source registry state."""
def __init__(self, root: Path) -> None:
self._delegate = AtomicJsonRegistryStore(
root,
filename="source_registry.json",
registry_type=SourceRegistryFile,
empty_factory=SourceRegistryFile,
corrupt_label="source registry file",
)
@property
def path(self) -> Path:
return self._delegate.path
def load_registry(self) -> SourceRegistryFile:
return self._delegate.load_registry()
def save_registry(self, registry: SourceRegistryFile) -> None:
self._delegate.save_registry(registry)
def registry_entry_to_connection_config(
entry: McpSourceRegistryEntry,
) -> ConnectionConfig:
"""Convert a registry entry to a broker connection config."""
from .models import ConnectionConfig
return ConnectionConfig(
id=entry.id,
server=entry.provider,
account=entry.account,
enabled=entry.enabled,
metadata={
**entry.metadata,
"auth_ref": entry.auth_ref,
"profile": entry.profile,
"transport": entry.transport.model_dump(mode="json"),
"source_registry": True,
},
)
def connection_config_to_registry_entry(
connection: ConnectionConfig,
) -> McpSourceRegistryEntry:
"""Materialize a seed config connection into persisted registry state.
Seed config is bootstrap-only. The registry entry must carry enough source
identity to become the future desired-state owner after first startup.
"""
transport = connection.metadata.get("transport")
legacy_transport_value: str | None = None
if isinstance(transport, dict):
pass
elif isinstance(transport, str):
if transport == "stdio":
transport = {
"kind": "stdio",
"command": connection.metadata.get("command", ""),
"args": list(connection.metadata.get("args", [])),
"env": dict(connection.metadata.get("env", {})),
}
elif transport in _FLAT_HTTP_TRANSPORTS:
legacy_transport_value = transport
transport = {
"kind": "http",
"url": connection.metadata.get("url", ""),
"headers": dict(connection.metadata.get("headers", {})),
}
else:
raise ValueError(
f"seed connection {connection.id!r} has unrecognized transport {transport!r}"
)
else:
raise ValueError(
f"seed connection {connection.id!r} requires metadata.transport"
)
profile = connection.metadata.get("profile")
auth_ref = connection.metadata.get("auth_ref")
source_metadata = {
key: value
for key, value in connection.metadata.items()
if key not in _TRANSPORT_METADATA_KEYS
}
if legacy_transport_value is not None:
source_metadata["legacy_transport"] = legacy_transport_value
entry = McpSourceRegistryEntry.model_validate(
{
"id": connection.id,
"enabled": connection.enabled,
"provider": connection.server,
"account": connection.account,
"profile": profile if isinstance(profile, str) else None,
"transport": transport,
"auth_ref": auth_ref if isinstance(auth_ref, str) else None,
"metadata": source_metadata,
}
)
return 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", "transport"):
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", {}))
if transport.kind == "stdio":
metadata.update(
{
"transport": "stdio",
"command": transport.command,
"args": list(transport.args),
"env": dict(transport.env),
"source_registry": False,
}
)
elif transport.kind == "http":
metadata.update(
{
"transport": "streamable_http",
"url": str(transport.url),
"headers": dict(transport.headers),
"source_registry": False,
}
)
else:
raise ValueError(f"unsupported wf_config MCP transport {transport.kind!r}")
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",
+52
View File
@@ -1,5 +1,14 @@
"""MCP upstream-source provider helpers.
Source registry symbols are exported lazily because importing them eagerly pulls
in compatibility `wf_mcp` DTOs, which can re-enter this package through
`wf_mcp.auth` during startup.
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from .auth import ( from .auth import (
AuthRecord, AuthRecord,
auth_missing_diagnostic, auth_missing_diagnostic,
@@ -11,13 +20,56 @@ from .auth import (
neutral_auth_from_mcp, neutral_auth_from_mcp,
) )
if TYPE_CHECKING:
from .source_registry import (
FileSourceRegistryStore,
HttpSourceTransport,
McpSourceRegistryEntry,
SourceRegistryFile,
SourceRegistryStore,
SourceTransport,
StdioSourceTransport,
connection_config_to_registry_entry,
registry_entry_to_connection_config,
workflow_mcp_source_to_connection_config,
)
__all__ = [ __all__ = [
"AuthRecord", "AuthRecord",
"FileSourceRegistryStore",
"HttpSourceTransport",
"McpSourceRegistryEntry",
"SourceRegistryFile",
"SourceRegistryStore",
"SourceTransport",
"StdioSourceTransport",
"auth_missing_diagnostic", "auth_missing_diagnostic",
"auth_ref_for_connection", "auth_ref_for_connection",
"connection_auth_diagnostic", "connection_auth_diagnostic",
"connection_config_to_registry_entry",
"mcp_auth_env", "mcp_auth_env",
"mcp_auth_from_neutral", "mcp_auth_from_neutral",
"mcp_auth_headers", "mcp_auth_headers",
"neutral_auth_from_mcp", "neutral_auth_from_mcp",
"registry_entry_to_connection_config",
"workflow_mcp_source_to_connection_config",
] ]
def __getattr__(name: str) -> object:
if name in {
"FileSourceRegistryStore",
"HttpSourceTransport",
"McpSourceRegistryEntry",
"SourceRegistryFile",
"SourceRegistryStore",
"SourceTransport",
"StdioSourceTransport",
"connection_config_to_registry_entry",
"registry_entry_to_connection_config",
"workflow_mcp_source_to_connection_config",
}:
from . import source_registry
return getattr(source_registry, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+278
View File
@@ -0,0 +1,278 @@
"""MCP upstream-source registry models and conversion helpers.
This module is canonical for MCP-as-source desired registry state. The temporary
runtime dependency on `wf_mcp.models.ConnectionConfig` remains until broker
runtime DTOs move out of the compatibility MCP facade.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Literal, Protocol
from pydantic import (
AnyHttpUrl,
Field,
field_validator,
model_validator,
)
from wf_api.source_registry import (
AtomicJsonRegistryStore,
SourceRegistryBaseModel,
validate_unique_source_ids,
)
from wf_api.source_registry import (
SourceRegistryStore as GenericSourceRegistryStore,
)
# Temporary low-level compatibility imports. `wf_mcp.shared.names` currently
# pulls in FastMCP transitively; keep this visible until reserved-name parsing
# moves to a neutral/source package.
from wf_mcp.connections import parse_connection_id
from wf_mcp.shared.names import RESERVED_CONNECTION_IDS
if TYPE_CHECKING:
from wf_mcp.models import ConnectionConfig
_FLAT_HTTP_TRANSPORTS = {"http", "streamable-http", "streamable_http", "sse"}
_TRANSPORT_METADATA_KEYS = {
"transport",
"command",
"args",
"env",
"cwd",
"url",
"headers",
"profile",
"auth_ref",
"source_registry",
}
class StdioSourceTransport(SourceRegistryBaseModel):
kind: Literal["stdio"] = "stdio"
command: str = Field(min_length=1)
args: tuple[str, ...] = ()
env: dict[str, str] = Field(default_factory=dict)
class HttpSourceTransport(SourceRegistryBaseModel):
kind: Literal["http"] = "http"
url: AnyHttpUrl
headers: dict[str, str] = Field(default_factory=dict)
SourceTransport = Annotated[
StdioSourceTransport | HttpSourceTransport,
Field(discriminator="kind"),
]
class McpSourceRegistryEntry(SourceRegistryBaseModel):
"""Desired MCP source configuration persisted by server-owned mutation."""
id: str
kind: Literal["mcp"] = "mcp"
enabled: bool = True
provider: str = Field(min_length=1)
account: str = Field(min_length=1)
profile: str | None = None
transport: SourceTransport
auth_ref: str | None = None
metadata: dict[str, object] = Field(default_factory=dict)
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
parse_connection_id(value)
if value in RESERVED_CONNECTION_IDS:
raise ValueError(f"source id {value!r} is reserved")
return value
class SourceRegistryFile(SourceRegistryBaseModel):
version: Literal[1] = 1
sources: list[McpSourceRegistryEntry] = Field(default_factory=list)
@model_validator(mode="after")
def validate_unique_source_ids(self) -> SourceRegistryFile:
validate_unique_source_ids(self.sources)
return self
def source_map(self) -> dict[str, McpSourceRegistryEntry]:
return {source.id: source for source in self.sources}
class SourceRegistryStore(GenericSourceRegistryStore[SourceRegistryFile], Protocol):
"""MCP-specialized persistence boundary for desired source configuration."""
class FileSourceRegistryStore:
"""Filesystem implementation for desired source registry state."""
def __init__(self, root: Path) -> None:
self._delegate = AtomicJsonRegistryStore(
root,
filename="source_registry.json",
registry_type=SourceRegistryFile,
empty_factory=SourceRegistryFile,
corrupt_label="source registry file",
)
@property
def path(self) -> Path:
return self._delegate.path
def load_registry(self) -> SourceRegistryFile:
return self._delegate.load_registry()
def save_registry(self, registry: SourceRegistryFile) -> None:
self._delegate.save_registry(registry)
def registry_entry_to_connection_config(
entry: McpSourceRegistryEntry,
) -> ConnectionConfig:
"""Convert a registry entry to a broker connection config."""
from wf_mcp.models import ConnectionConfig
return ConnectionConfig(
id=entry.id,
server=entry.provider,
account=entry.account,
enabled=entry.enabled,
metadata={
**entry.metadata,
"auth_ref": entry.auth_ref,
"profile": entry.profile,
"transport": entry.transport.model_dump(mode="json"),
"source_registry": True,
},
)
def connection_config_to_registry_entry(
connection: ConnectionConfig,
) -> McpSourceRegistryEntry:
"""Materialize a seed config connection into persisted registry state.
Seed config is bootstrap-only. The registry entry must carry enough source
identity to become the future desired-state owner after first startup.
"""
transport = connection.metadata.get("transport")
legacy_transport_value: str | None = None
if isinstance(transport, dict):
pass
elif isinstance(transport, str):
if transport == "stdio":
transport = {
"kind": "stdio",
"command": connection.metadata.get("command", ""),
"args": list(connection.metadata.get("args", [])),
"env": dict(connection.metadata.get("env", {})),
}
elif transport in _FLAT_HTTP_TRANSPORTS:
legacy_transport_value = transport
transport = {
"kind": "http",
"url": connection.metadata.get("url", ""),
"headers": dict(connection.metadata.get("headers", {})),
}
else:
raise ValueError(
f"seed connection {connection.id!r} has unrecognized transport {transport!r}"
)
else:
raise ValueError(
f"seed connection {connection.id!r} requires metadata.transport"
)
profile = connection.metadata.get("profile")
auth_ref = connection.metadata.get("auth_ref")
source_metadata = {
key: value
for key, value in connection.metadata.items()
if key not in _TRANSPORT_METADATA_KEYS
}
if legacy_transport_value is not None:
source_metadata["legacy_transport"] = legacy_transport_value
entry = McpSourceRegistryEntry.model_validate(
{
"id": connection.id,
"enabled": connection.enabled,
"provider": connection.server,
"account": connection.account,
"profile": profile if isinstance(profile, str) else None,
"transport": transport,
"auth_ref": auth_ref if isinstance(auth_ref, str) else None,
"metadata": source_metadata,
}
)
return entry
def workflow_mcp_source_to_connection_config(source: object) -> ConnectionConfig:
"""Convert neutral wf_config MCP source config into a broker connection.
This adapter remains source-provider code even though the output is the
temporary broker runtime DTO. The input is intentionally typed as object to
avoid making `wf_config` part of this package's import graph.
"""
from wf_mcp.models import ConnectionConfig
if getattr(source, "kind", None) != "mcp":
raise ValueError("expected wf_config MCP source")
for field in ("id", "provider", "account", "enabled", "ownership", "transport"):
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", {}))
if transport.kind == "stdio":
metadata.update(
{
"transport": "stdio",
"command": transport.command,
"args": list(transport.args),
"env": dict(transport.env),
"source_registry": False,
}
)
elif transport.kind == "http":
metadata.update(
{
"transport": "streamable_http",
"url": str(transport.url),
"headers": dict(transport.headers),
"source_registry": False,
}
)
else:
raise ValueError(f"unsupported wf_config MCP transport {transport.kind!r}")
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__ = [
"FileSourceRegistryStore",
"HttpSourceTransport",
"McpSourceRegistryEntry",
"SourceRegistryFile",
"SourceRegistryStore",
"SourceTransport",
"StdioSourceTransport",
"connection_config_to_registry_entry",
"registry_entry_to_connection_config",
"workflow_mcp_source_to_connection_config",
]
+15
View File
@@ -69,3 +69,18 @@ def test_wf_mcp_storage_shim_reexports_wf_sources_mcp_storage() -> None:
assert CompatFileAuthStore is FileAuthStore assert CompatFileAuthStore is FileAuthStore
assert CompatFileCatalogStore is FileCatalogStore assert CompatFileCatalogStore is FileCatalogStore
assert CompatFileStore is FileStore assert CompatFileStore is FileStore
def test_wf_mcp_source_registry_shim_reexports_wf_sources_mcp_registry() -> None:
from wf_mcp.source_registry import FileSourceRegistryStore as CompatFileStore
from wf_mcp.source_registry import McpSourceRegistryEntry as CompatEntry
from wf_mcp.source_registry import SourceRegistryFile as CompatFile
from wf_sources_mcp.source_registry import (
FileSourceRegistryStore,
McpSourceRegistryEntry,
SourceRegistryFile,
)
assert CompatFileStore is FileSourceRegistryStore
assert CompatEntry is McpSourceRegistryEntry
assert CompatFile is SourceRegistryFile
@@ -3,6 +3,9 @@ from __future__ import annotations
import ast import ast
from pathlib import Path from pathlib import Path
# Temporary low-level wf_mcp imports are allowed for connection id parsing,
# reserved names, and broker DTO conversion. Frontend/proxy/workflow-surface
# imports are forbidden because wf_sources_mcp is upstream-source code.
FORBIDDEN_WF_MCP_PREFIXES = ( FORBIDDEN_WF_MCP_PREFIXES = (
"wf_mcp.admin_surface", "wf_mcp.admin_surface",
"wf_mcp.workflow_surface", "wf_mcp.workflow_surface",
@@ -0,0 +1,199 @@
from __future__ import annotations
from pathlib import Path
import pytest
from wf_mcp.models import ConnectionConfig
from wf_sources_mcp.source_registry import (
FileSourceRegistryStore,
HttpSourceTransport,
McpSourceRegistryEntry,
SourceRegistryFile,
StdioSourceTransport,
connection_config_to_registry_entry,
registry_entry_to_connection_config,
)
def _entry(source_id: str = "github.work") -> McpSourceRegistryEntry:
return McpSourceRegistryEntry(
id=source_id,
provider="github",
account="work",
transport=StdioSourceTransport(
command="npx",
args=("-y", "@modelcontextprotocol/server-github"),
env={"GITHUB_TOKEN": "${GITHUB_TOKEN}"},
),
auth_ref=source_id,
metadata={"purpose": "tests"},
)
def test_source_registry_entry_keeps_identity_and_transport_structural() -> None:
entry = _entry()
assert entry.id == "github.work"
assert entry.provider == "github"
assert entry.account == "work"
assert entry.profile is None
assert entry.transport.kind == "stdio"
assert entry.transport.command == "npx"
assert entry.auth_ref == "github.work"
def test_source_registry_accepts_http_transport() -> None:
entry = McpSourceRegistryEntry(
id="github.http",
provider="github",
account="work",
transport=HttpSourceTransport(url="https://example.test/mcp"), # type: ignore[arg-type]
)
assert entry.transport.kind == "http"
assert str(entry.transport.url) == "https://example.test/mcp"
def test_source_registry_rejects_reserved_ids() -> None:
with pytest.raises(ValueError, match="reserved"):
_entry("wf.admin")
def test_file_source_registry_store_round_trips_registry(tmp_path: Path) -> None:
store = FileSourceRegistryStore(tmp_path)
registry = SourceRegistryFile(sources=[_entry("github.work")])
store.save_registry(registry)
loaded = store.load_registry()
assert loaded.source_map()["github.work"].provider == "github"
assert loaded.source_map()["github.work"].transport.kind == "stdio"
def test_file_source_registry_store_validates_loaded_registry(tmp_path: Path) -> None:
store = FileSourceRegistryStore(tmp_path)
store.path.write_text(
'{"version": 1, "sources": [{"id": "wf.admin", "provider": "wf", '
'"account": "admin", "transport": {"kind": "stdio", "command": "x"}}]}',
encoding="utf-8",
)
with pytest.raises(ValueError, match="reserved"):
store.load_registry()
def test_registry_entry_to_connection_config_preserves_identity() -> None:
entry = _entry()
config = registry_entry_to_connection_config(entry)
assert config.id == "github.work"
assert config.server == "github"
assert config.account == "work"
assert config.enabled is True
def test_registry_entry_to_connection_config_preserves_transport_metadata() -> None:
entry = _entry()
entry.auth_ref = "github.work.auth"
config = registry_entry_to_connection_config(entry)
assert config.metadata["auth_ref"] == "github.work.auth"
assert config.metadata["profile"] is None
assert config.metadata["transport"]["kind"] == "stdio"
assert config.metadata["transport"]["command"] == "npx"
assert config.metadata["source_registry"] is True
def test_registry_entry_to_connection_config_preserves_user_metadata() -> None:
entry = _entry()
config = registry_entry_to_connection_config(entry)
assert config.metadata["purpose"] == "tests"
def test_registry_entry_to_connection_config_disabled_entry() -> None:
entry = _entry()
entry.enabled = False
config = registry_entry_to_connection_config(entry)
assert config.enabled is False
def test_connection_config_to_registry_entry_preserves_transport_metadata() -> None:
connection = ConnectionConfig(
id="github.work",
server="github",
account="work",
enabled=False,
metadata={
"transport": {"kind": "stdio", "command": "npx", "args": ["server"]},
"profile": "corp",
"auth_ref": "secret://github/work",
"region": "us",
},
)
entry = connection_config_to_registry_entry(connection)
assert entry.id == "github.work"
assert entry.provider == "github"
assert entry.account == "work"
assert entry.enabled is False
assert entry.profile == "corp"
assert entry.auth_ref == "secret://github/work"
assert entry.transport.kind == "stdio"
assert entry.metadata["region"] == "us"
def test_connection_config_to_registry_entry_accepts_flat_stdio_metadata() -> None:
connection = ConnectionConfig(
id="github.work",
server="github",
account="work",
metadata={
"transport": "stdio",
"command": "npx",
"args": ["server"],
"env": {"DEBUG": "1"},
"region": "us",
},
)
entry = connection_config_to_registry_entry(connection)
assert entry.transport.kind == "stdio"
assert isinstance(entry.transport, StdioSourceTransport)
assert entry.transport.command == "npx"
assert entry.transport.args == ("server",)
assert entry.transport.env == {"DEBUG": "1"}
assert entry.metadata == {"region": "us"}
def test_connection_config_to_registry_entry_accepts_flat_http_metadata() -> None:
connection = ConnectionConfig(
id="context7.default",
server="context7",
account="default",
metadata={
"transport": "sse",
"url": "http://127.0.0.1:3000/sse",
"headers": {"X-Test": "yes"},
"purpose": "legacy",
},
)
entry = connection_config_to_registry_entry(connection)
assert entry.transport.kind == "http"
assert isinstance(entry.transport, HttpSourceTransport)
assert str(entry.transport.url) == "http://127.0.0.1:3000/sse"
assert entry.transport.headers == {"X-Test": "yes"}
assert entry.metadata == {"purpose": "legacy", "legacy_transport": "sse"}
def test_connection_config_to_registry_entry_requires_transport_metadata() -> None:
connection = ConnectionConfig(id="github.work", server="github", account="work")
with pytest.raises(ValueError, match="requires metadata.transport"):
connection_config_to_registry_entry(connection)