refactor: add typed mcp source connection seam

This commit is contained in:
lda
2026-06-07 13:50:57 +07:00 Verified
parent f79741eb2a
commit 195a967527
27 changed files with 690 additions and 217 deletions
+4 -4
View File
@@ -221,10 +221,10 @@ implementation state.
MCP-backed JSON-RPC path. A neutral-config `WorkflowServer` can start an MCP-backed JSON-RPC path. A neutral-config `WorkflowServer` can start an
interrupting run, be rebuilt from the same filesystem stores, inspect the interrupting run, be rebuilt from the same filesystem stores, inspect the
interrupted run, and resume it to completion through `RpcWorkflowApiClient`. interrupted run, and resume it to completion through `RpcWorkflowApiClient`.
- Planned: MCP upstream source runtime cleanup now starts with a typed - Completed: MCP upstream source runtime cleanup now starts with a typed
`McpSourceConnection` seam in `wf_sources_mcp`, not by moving `McpSourceConnection` seam in `wf_sources_mcp`, not by moving
`runtime/factory.py` as-is. The active plan is `runtime/factory.py` as-is. The active plan was
[2026-06-07 MCP source connection seam](./superpowers/plans/2026-06-07-mcp-source-connection-seam.md). [2026-06-07 MCP source connection seam](./historical/superpowers/plans/2026-06-07-mcp-source-connection-seam.md).
- Auth/source secrets boundary: keep registry desired state separate from - Auth/source secrets boundary: keep registry desired state separate from
upstream credentials, and surface missing auth as validation diagnostics. upstream credentials, and surface missing auth as validation diagnostics.
The contract is now specified in The contract is now specified in
+17 -5
View File
@@ -9,6 +9,10 @@ from mcp.types import METHOD_NOT_FOUND
from wf_authoring import NodeSpec from wf_authoring import NodeSpec
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from wf_sources_mcp.connections import (
McpSourceConnection,
mcp_source_connection_from_connection_config,
)
from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor
from ..auth import AuthRecord from ..auth import AuthRecord
@@ -34,14 +38,18 @@ async def discover_connection_capabilities(
auth: AuthRecord | None, auth: AuthRecord | None,
adapter: BackendAdapter, adapter: BackendAdapter,
) -> DiscoveredConnectionCapabilities: ) -> DiscoveredConnectionCapabilities:
tools = await adapter.list_tools(connection, auth) # Compatibility boundary: broker callers still pass ConnectionConfig. Runtime
# internals use McpSourceConnection so the session code can move to
# wf_sources_mcp in a later slice.
source_connection = mcp_source_connection_from_connection_config(connection)
tools = await adapter.list_tools(source_connection, auth)
resources = await _list_optional_capabilities( resources = await _list_optional_capabilities(
lambda: adapter.list_resources(connection, auth) lambda: adapter.list_resources(source_connection, auth)
) )
prompts = await _list_optional_capabilities( prompts = await _list_optional_capabilities(
lambda: adapter.list_prompts(connection, auth) lambda: adapter.list_prompts(source_connection, auth)
) )
metadata = await adapter.get_connection_metadata(connection, auth) metadata = await adapter.get_connection_metadata(source_connection, auth)
return DiscoveredConnectionCapabilities( return DiscoveredConnectionCapabilities(
tools=tools, tools=tools,
resources=resources, resources=resources,
@@ -77,9 +85,13 @@ def specs_from_discovered_tools(
tools: list[DiscoveredTool], tools: list[DiscoveredTool],
emit_event: Callable[[McpEvent], None] | None = None, emit_event: Callable[[McpEvent], None] | None = None,
) -> list[NodeSpec[Any, Any]]: ) -> list[NodeSpec[Any, Any]]:
# Compatibility boundary: broker callers still pass ConnectionConfig. Runtime
# internals use McpSourceConnection so the session code can move to
# wf_sources_mcp in a later slice.
source_connection = mcp_source_connection_from_connection_config(connection)
return [ return [
wrap_discovered_tool( wrap_discovered_tool(
connection=connection, connection=source_connection,
auth=auth, auth=auth,
executor=executor, executor=executor,
tool=tool, tool=tool,
+4 -1
View File
@@ -23,6 +23,7 @@ from wf_sources_mcp.catalog import (
CatalogPromptEntry, CatalogPromptEntry,
CatalogResourceEntry, CatalogResourceEntry,
) )
from wf_sources_mcp.connections import mcp_source_connection_from_connection_config
from wf_sources_mcp.sdk import ToolExecutor from wf_sources_mcp.sdk import ToolExecutor
from wf_sources_mcp.storage import CatalogStore from wf_sources_mcp.storage import CatalogStore
@@ -273,8 +274,10 @@ class SourceCatalogService:
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]: async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
connection = self.connection_lookup(entry.connection_id) connection = self.connection_lookup(entry.connection_id)
auth = self.load_auth(connection) auth = self.load_auth(connection)
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(connection)
result = await self.tool_executor_for(connection).call_tool( result = await self.tool_executor_for(connection).call_tool(
connection, source_connection,
auth, auth,
entry.local_name, entry.local_name,
payload.model_dump(exclude_unset=True), payload.model_dump(exclude_unset=True),
@@ -5,6 +5,7 @@ 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.connections import mcp_source_connection_from_connection_config
from wf_sources_mcp.source_registry import ( from wf_sources_mcp.source_registry import (
McpSourceRegistryEntry, McpSourceRegistryEntry,
SourceRegistryFile, SourceRegistryFile,
@@ -162,8 +163,12 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
auth_diagnostics = [] auth_diagnostics = []
if self.load_auth is not None: if self.load_auth is not None:
for source_id in sorted(after): for source_id in sorted(after):
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(
after[source_id]
)
diagnostic = connection_auth_diagnostic( diagnostic = connection_auth_diagnostic(
after[source_id], source_connection,
load_auth_ref=self.load_auth, load_auth_ref=self.load_auth,
) )
if diagnostic is not None: if diagnostic is not None:
@@ -27,6 +27,10 @@ from wf_mcp.models import ConnectionConfig
from wf_mcp.shared.errors import error_payload from wf_mcp.shared.errors import error_payload
from wf_sources_mcp.auth import AuthRecord, connection_auth_diagnostic from wf_sources_mcp.auth import AuthRecord, connection_auth_diagnostic
from wf_sources_mcp.catalog.models import CatalogSnapshot from wf_sources_mcp.catalog.models import CatalogSnapshot
from wf_sources_mcp.connections import (
McpSourceConnection,
mcp_source_connection_from_connection_config,
)
from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor
from wf_sources_mcp.storage import AuthStore, CatalogStore from wf_sources_mcp.storage import AuthStore, CatalogStore
@@ -74,6 +78,9 @@ class UpstreamTransportService:
old compatibility surface has no callers. old compatibility surface has no callers.
""" """
# Compatibility boundary: broker callers still pass ConnectionConfig.
# Check legacy metadata for auth_ref first to avoid requiring transport
# metadata just for auth resolution.
auth_ref = connection.metadata.get("auth_ref") auth_ref = connection.metadata.get("auth_ref")
if isinstance(auth_ref, str): if isinstance(auth_ref, str):
return self.load_auth(auth_ref) return self.load_auth(auth_ref)
@@ -98,6 +105,8 @@ class UpstreamTransportService:
) -> dict[str, Any]: ) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_connection_auth(connection) auth = self.load_connection_auth(connection)
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"resource_read_started", "resource_read_started",
@@ -106,7 +115,7 @@ class UpstreamTransportService:
payload={"uri": uri}, payload={"uri": uri},
) )
) )
result = await adapter.read_resource(connection, auth, uri) result = await adapter.read_resource(source_connection, auth, uri)
self.event_sink( self.event_sink(
make_event( make_event(
"resource_read_completed", "resource_read_completed",
@@ -126,6 +135,8 @@ class UpstreamTransportService:
) -> dict[str, Any]: ) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_connection_auth(connection) auth = self.load_connection_auth(connection)
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"prompt_get_started", "prompt_get_started",
@@ -134,7 +145,7 @@ class UpstreamTransportService:
payload={"argument_keys": sorted((arguments or {}).keys())}, payload={"argument_keys": sorted((arguments or {}).keys())},
) )
) )
result = await adapter.get_prompt(connection, auth, local_name, arguments) result = await adapter.get_prompt(source_connection, auth, local_name, arguments)
self.event_sink( self.event_sink(
make_event( make_event(
"prompt_get_completed", "prompt_get_completed",
@@ -154,6 +165,8 @@ class UpstreamTransportService:
) -> dict[str, Any]: ) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_connection_auth(connection) auth = self.load_connection_auth(connection)
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"raw_method_started", "raw_method_started",
@@ -162,7 +175,7 @@ class UpstreamTransportService:
payload={"params": params or {}}, payload={"params": params or {}},
) )
) )
result = await adapter.invoke_method(connection, auth, method, params) result = await adapter.invoke_method(source_connection, auth, method, params)
self.event_sink( self.event_sink(
make_event( make_event(
"raw_method_completed", "raw_method_completed",
@@ -182,6 +195,8 @@ class UpstreamTransportService:
) -> None: ) -> None:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_connection_auth(connection) auth = self.load_connection_auth(connection)
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"raw_notification_started", "raw_notification_started",
@@ -190,7 +205,7 @@ class UpstreamTransportService:
payload={"params": params or {}}, payload={"params": params or {}},
) )
) )
await adapter.send_notification(connection, auth, method, params) await adapter.send_notification(source_connection, auth, method, params)
self.event_sink( self.event_sink(
make_event( make_event(
"raw_notification_completed", "raw_notification_completed",
@@ -309,8 +324,10 @@ class UpstreamTransportService:
) )
) )
continue continue
# Compatibility boundary: broker callers still pass ConnectionConfig.
source_connection = mcp_source_connection_from_connection_config(connection)
auth_diagnostic = connection_auth_diagnostic( auth_diagnostic = connection_auth_diagnostic(
connection, source_connection,
# The diagnostic helper passes the explicit auth_ref to this # The diagnostic helper passes the explicit auth_ref to this
# loader, matching load_connection_auth's auth_ref-first path. # loader, matching load_connection_auth's auth_ref-first path.
load_auth_ref=self.load_auth, load_auth_ref=self.load_auth,
@@ -323,7 +340,7 @@ class UpstreamTransportService:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_connection_auth(connection) auth = self.load_connection_auth(connection)
await asyncio.wait_for( await asyncio.wait_for(
adapter.list_tools(connection, auth), adapter.list_tools(source_connection, auth),
timeout=LIVE_SOURCE_CHECK_TIMEOUT_SECONDS, timeout=LIVE_SOURCE_CHECK_TIMEOUT_SECONDS,
) )
except _LIVE_SOURCE_CHECK_FAILURES as exc: except _LIVE_SOURCE_CHECK_FAILURES as exc:
+2 -20
View File
@@ -1,29 +1,11 @@
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from wf_sources_mcp.ids import CONNECTION_ID_PATTERN, parse_connection_id
from .models import ConnectionConfig from .models import ConnectionConfig
CONNECTION_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
def parse_connection_id(connection_id: str) -> tuple[str, str]:
# Connection ids are logical source ids, but they also key persisted auth and
# catalog files. Keep this parser conservative so unsafe ids are rejected
# before they reach either registry or store boundaries.
if not re.fullmatch(CONNECTION_ID_PATTERN, connection_id):
raise ValueError(
"connection id must start with alphanumeric or underscore and contain "
"only [A-Za-z0-9_.-]"
)
if "." not in connection_id:
raise ValueError("connection id must look like '<server>.<account>'")
server, account = connection_id.split(".", 1)
if not server or not account:
raise ValueError("connection id must look like '<server>.<account>'")
return server, account
def qualify_node_name(connection_id: str, local_name: str) -> str: def qualify_node_name(connection_id: str, local_name: str) -> str:
parse_connection_id(connection_id) parse_connection_id(connection_id)
+2 -2
View File
@@ -75,8 +75,8 @@ class McpRuntimePool:
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
) -> ToolCallResult: ) -> ToolCallResult:
+23 -28
View File
@@ -19,6 +19,7 @@ from pydantic import AnyUrl
from wf_sources_mcp.auth import AuthRecord, mcp_auth_env, mcp_auth_headers from wf_sources_mcp.auth import AuthRecord, mcp_auth_env, mcp_auth_headers
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult
from wf_sources_mcp.sdk.converters import ( from wf_sources_mcp.sdk.converters import (
prompt_to_discovered, prompt_to_discovered,
@@ -26,31 +27,26 @@ from wf_sources_mcp.sdk.converters import (
tool_result_to_call_result, tool_result_to_call_result,
tool_to_discovered, tool_to_discovered,
) )
from wf_sources_mcp.transports import HttpSourceTransport, StdioSourceTransport
from ..models import ConnectionConfig
class McpSdkAdapter(BackendAdapter): class McpSdkAdapter(BackendAdapter):
@asynccontextmanager @asynccontextmanager
async def _session( async def _session(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
): ):
transport = connection.metadata.get("transport", "stdio") transport = connection.transport
if transport == "stdio": if isinstance(transport, StdioSourceTransport):
command = connection.metadata["command"]
args = list(connection.metadata.get("args", []))
env = connection.metadata.get("env")
cwd = connection.metadata.get("cwd")
auth_env = mcp_auth_env(auth) auth_env = mcp_auth_env(auth)
env = dict(transport.env)
if auth_env: if auth_env:
env = {**(env or {}), **auth_env} env = {**env, **auth_env}
params = StdioServerParameters( params = StdioServerParameters(
command=command, command=transport.command,
args=args, args=list(transport.args),
env=env, env=env,
cwd=cwd,
) )
async with stdio_client(params) as (read_stream, write_stream): async with stdio_client(params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
@@ -58,13 +54,12 @@ class McpSdkAdapter(BackendAdapter):
yield session yield session
return return
if transport == "streamable_http": if isinstance(transport, HttpSourceTransport):
url = connection.metadata["url"]
headers = mcp_auth_headers(auth) headers = mcp_auth_headers(auth)
http_client = httpx.AsyncClient(headers=headers or None) http_client = httpx.AsyncClient(headers=headers or None)
async with http_client: async with http_client:
async with streamable_http_client( async with streamable_http_client(
url, str(transport.url),
http_client=http_client, http_client=http_client,
) as (read_stream, write_stream, _get_session_id): ) as (read_stream, write_stream, _get_session_id):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
@@ -72,11 +67,11 @@ class McpSdkAdapter(BackendAdapter):
yield session yield session
return return
raise ValueError(f"unsupported MCP transport {transport!r}") raise ValueError(f"unsupported MCP transport {transport.kind!r}")
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
@@ -85,7 +80,7 @@ class McpSdkAdapter(BackendAdapter):
async def list_resources( async def list_resources(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredResource]: ) -> list[DiscoveredResource]:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
@@ -94,7 +89,7 @@ class McpSdkAdapter(BackendAdapter):
async def list_prompts( async def list_prompts(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ) -> list[DiscoveredPrompt]:
async with self._session(connection, auth) as session: async with self._session(connection, auth) as session:
@@ -103,17 +98,17 @@ class McpSdkAdapter(BackendAdapter):
async def get_connection_metadata( async def get_connection_metadata(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"server": connection.server, "server": connection.provider,
"transport": connection.metadata.get("transport", "stdio"), "transport": connection.transport.kind,
} }
async def read_resource( async def read_resource(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
uri: str, uri: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -123,7 +118,7 @@ class McpSdkAdapter(BackendAdapter):
async def get_prompt( async def get_prompt(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
@@ -134,7 +129,7 @@ class McpSdkAdapter(BackendAdapter):
async def invoke_method( async def invoke_method(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -148,7 +143,7 @@ class McpSdkAdapter(BackendAdapter):
async def send_notification( async def send_notification(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -160,7 +155,7 @@ class McpSdkAdapter(BackendAdapter):
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
+2 -2
View File
@@ -21,9 +21,9 @@ if TYPE_CHECKING:
from fastmcp.resources.template import ResourceTemplate from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.base import Tool from fastmcp.tools.base import Tool
from wf_sources_mcp.ids import RESERVED_CONNECTION_IDS
ADMIN_NAMESPACE = "wf.admin" ADMIN_NAMESPACE = "wf.admin"
RESERVED_CONNECTION_IDS = frozenset({ADMIN_NAMESPACE, "wf.mcp"})
"""Source ids reserved by wf-mcp system capabilities."""
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
+3 -4
View File
@@ -9,12 +9,11 @@ from pydantic import BaseModel, ConfigDict, Field, create_model
from wf_authoring import NodeReturn, NodeSpec from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext from wf_core import RuntimeContext
from wf_mcp.broker.events import McpEvent, make_event from wf_mcp.broker.events import McpEvent, make_event
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredTool from wf_sources_mcp.catalog import DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import ToolExecutor from wf_sources_mcp.sdk import ToolExecutor
from ..auth import AuthRecord
from ..models import ConnectionConfig
_JSON_TYPE_MAP: dict[str, object] = { _JSON_TYPE_MAP: dict[str, object] = {
"string": str, "string": str,
"integer": int, "integer": int,
@@ -118,7 +117,7 @@ def _model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
def wrap_discovered_tool( def wrap_discovered_tool(
*, *,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
executor: ToolExecutor, executor: ToolExecutor,
tool: DiscoveredTool, tool: DiscoveredTool,
+29 -6
View File
@@ -21,23 +21,31 @@ from .auth import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from .connections import (
McpSourceConnection,
mcp_source_connection_from_connection_config,
mcp_source_connection_from_registry_entry,
)
from .source_registry import ( from .source_registry import (
FileSourceRegistryStore, FileSourceRegistryStore,
HttpSourceTransport,
McpSourceRegistryEntry, McpSourceRegistryEntry,
SourceRegistryFile, SourceRegistryFile,
SourceRegistryStore, SourceRegistryStore,
SourceTransport,
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, workflow_mcp_source_to_connection_config,
) )
from .transports import (
HttpSourceTransport,
SourceTransport,
StdioSourceTransport,
)
__all__ = [ __all__ = [
"AuthRecord", "AuthRecord",
"FileSourceRegistryStore", "FileSourceRegistryStore",
"HttpSourceTransport", "HttpSourceTransport",
"McpSourceConnection",
"McpSourceRegistryEntry", "McpSourceRegistryEntry",
"SourceRegistryFile", "SourceRegistryFile",
"SourceRegistryStore", "SourceRegistryStore",
@@ -50,6 +58,8 @@ __all__ = [
"mcp_auth_env", "mcp_auth_env",
"mcp_auth_from_neutral", "mcp_auth_from_neutral",
"mcp_auth_headers", "mcp_auth_headers",
"mcp_source_connection_from_connection_config",
"mcp_source_connection_from_registry_entry",
"neutral_auth_from_mcp", "neutral_auth_from_mcp",
"registry_entry_to_connection_config", "registry_entry_to_connection_config",
"workflow_mcp_source_to_connection_config", "workflow_mcp_source_to_connection_config",
@@ -57,14 +67,19 @@ __all__ = [
def __getattr__(name: str) -> object: def __getattr__(name: str) -> object:
if name in {
"McpSourceConnection",
"mcp_source_connection_from_connection_config",
"mcp_source_connection_from_registry_entry",
}:
from . import connections
return getattr(connections, name)
if name in { if name in {
"FileSourceRegistryStore", "FileSourceRegistryStore",
"HttpSourceTransport",
"McpSourceRegistryEntry", "McpSourceRegistryEntry",
"SourceRegistryFile", "SourceRegistryFile",
"SourceRegistryStore", "SourceRegistryStore",
"SourceTransport",
"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", "workflow_mcp_source_to_connection_config",
@@ -72,4 +87,12 @@ def __getattr__(name: str) -> object:
from . import source_registry from . import source_registry
return getattr(source_registry, name) return getattr(source_registry, name)
if name in {
"HttpSourceTransport",
"SourceTransport",
"StdioSourceTransport",
}:
from . import transports
return getattr(transports, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+14 -11
View File
@@ -1,22 +1,18 @@
"""MCP upstream-source auth helpers. """MCP upstream-source auth helpers.
This module is canonical for MCP-as-source auth interpretation. The temporary This module is canonical for MCP-as-source auth interpretation. Runtime-facing
TYPE_CHECKING dependency on `wf_mcp.broker.models.ConnectionConfig` exists until helpers consume source-connection-like objects instead of broker config DTOs.
connection runtime DTOs move out of the compatibility MCP facade.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import Any, Protocol
from wf_api.auth import AuthRecord as NeutralAuthRecord from wf_api.auth import AuthRecord as NeutralAuthRecord
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity
if TYPE_CHECKING:
from wf_mcp.broker.models import ConnectionConfig
@dataclass(slots=True) @dataclass(slots=True)
class AuthRecord: class AuthRecord:
@@ -85,11 +81,18 @@ def mcp_auth_env(auth: AuthRecord | None) -> dict[str, str]:
} }
def auth_ref_for_connection(connection: ConnectionConfig) -> str | None: class SourceConnectionLike(Protocol):
@property
def id(self) -> str: ...
@property
def auth_ref(self) -> str | None: ...
def auth_ref_for_connection(connection: SourceConnectionLike) -> str | None:
"""Return the explicit auth ref for one source connection, if present.""" """Return the explicit auth ref for one source connection, if present."""
auth_ref = connection.metadata.get("auth_ref") return connection.auth_ref
return auth_ref if isinstance(auth_ref, str) else None
def auth_missing_diagnostic( def auth_missing_diagnostic(
@@ -117,7 +120,7 @@ def auth_missing_diagnostic(
def connection_auth_diagnostic( def connection_auth_diagnostic(
connection: ConnectionConfig, connection: SourceConnectionLike,
*, *,
load_auth_ref: Callable[[str], AuthRecord | None], load_auth_ref: Callable[[str], AuthRecord | None],
logical_ref: str | None = None, logical_ref: str | None = None,
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from wf_sources_mcp.ids import parse_connection_id
from wf_sources_mcp.source_registry import McpSourceRegistryEntry
from wf_sources_mcp.transports import (
HttpSourceTransport,
SourceTransport,
StdioSourceTransport,
)
if TYPE_CHECKING:
from wf_mcp.broker.models import ConnectionConfig
_FLAT_HTTP_TRANSPORTS = {"http", "streamable-http", "streamable_http", "sse"}
_CONNECTION_METADATA_KEYS = {
"transport",
"command",
"args",
"env",
"cwd",
"url",
"headers",
"profile",
"auth_ref",
}
@dataclass(frozen=True, slots=True)
class McpSourceConnection:
"""Typed runtime-facing MCP source connection.
This is the object runtime/session code should consume. Legacy broker
`ConnectionConfig.metadata` remains at the compatibility edge only.
"""
id: str
provider: str
account: str
transport: SourceTransport
enabled: bool = True
profile: str | None = None
auth_ref: str | None = None
metadata: dict[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
provider, account = parse_connection_id(self.id)
if not self.provider:
raise ValueError("provider must not be empty")
if not self.account:
raise ValueError("account must not be empty")
if provider != self.provider or account != self.account:
raise ValueError(
"MCP source connection id must match provider/account fields"
)
def mcp_source_connection_from_registry_entry(
entry: McpSourceRegistryEntry,
) -> McpSourceConnection:
"""Adapt persisted desired-source registry state to runtime source shape."""
return McpSourceConnection(
id=entry.id,
provider=entry.provider,
account=entry.account,
enabled=entry.enabled,
profile=entry.profile,
transport=entry.transport,
auth_ref=entry.auth_ref,
metadata=dict(entry.metadata),
)
def mcp_source_connection_from_connection_config(
connection: ConnectionConfig,
) -> McpSourceConnection:
"""Adapt legacy broker connection config into typed source shape.
Keep all metadata-bag reads in this compatibility converter. Runtime/session
code should use `McpSourceConnection.transport` directly.
"""
transport = _transport_from_connection_metadata(connection)
profile = connection.metadata.get("profile")
auth_ref = connection.metadata.get("auth_ref")
metadata = {
str(key): value
for key, value in connection.metadata.items()
if key not in _CONNECTION_METADATA_KEYS
}
return McpSourceConnection(
id=connection.id,
provider=connection.server,
account=connection.account,
enabled=connection.enabled,
profile=profile if isinstance(profile, str) else None,
transport=transport,
auth_ref=auth_ref if isinstance(auth_ref, str) else None,
metadata=metadata,
)
def _transport_from_connection_metadata(connection: ConnectionConfig) -> SourceTransport:
transport = connection.metadata.get("transport")
if isinstance(transport, dict):
kind = transport.get("kind")
if kind == "stdio":
return StdioSourceTransport.model_validate(transport)
if kind == "http":
return HttpSourceTransport.model_validate(transport)
raise ValueError(
f"connection {connection.id!r} has unsupported metadata.transport.kind {kind!r}"
)
if isinstance(transport, str):
if transport == "stdio":
return StdioSourceTransport(
command=str(connection.metadata.get("command", "")),
args=tuple(str(arg) for arg in connection.metadata.get("args", ())),
env={
str(key): str(value)
for key, value in dict(connection.metadata.get("env", {})).items()
},
)
if transport in _FLAT_HTTP_TRANSPORTS:
url = connection.metadata.get("url", "")
return HttpSourceTransport(
url=url if isinstance(url, str) else str(url), # type: ignore[arg-type]
headers={
str(key): str(value)
for key, value in dict(
connection.metadata.get("headers", {})
).items()
},
)
raise ValueError(
f"connection {connection.id!r} has unrecognized metadata.transport {transport!r}"
)
raise ValueError(f"connection {connection.id!r} requires metadata.transport")
__all__ = [
"McpSourceConnection",
"mcp_source_connection_from_connection_config",
"mcp_source_connection_from_registry_entry",
]
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import re
CONNECTION_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
RESERVED_CONNECTION_IDS = frozenset({"wf.admin", "wf.mcp"})
"""Source ids reserved by built-in workflow/MCP control surfaces."""
def parse_connection_id(connection_id: str) -> tuple[str, str]:
"""Validate and split one MCP source id into provider/account parts.
Source ids also key persisted auth, registry, and catalog files. Keep this
conservative so unsafe ids are rejected before reaching store boundaries.
"""
if not re.fullmatch(CONNECTION_ID_PATTERN, connection_id):
raise ValueError(
"connection id must start with alphanumeric or underscore and contain "
"only [A-Za-z0-9_.-]"
)
if "." not in connection_id:
raise ValueError("connection id must look like '<server>.<account>'")
server, account = connection_id.split(".", 1)
if not server or not account:
raise ValueError("connection id must look like '<server>.<account>'")
return server, account
__all__ = [
"CONNECTION_ID_PATTERN",
"RESERVED_CONNECTION_IDS",
"parse_connection_id",
]
+13 -19
View File
@@ -1,19 +1,13 @@
"""Protocol/result contracts for MCP upstream source providers. """Protocol/result contracts for MCP upstream source providers."""
The temporary `wf_mcp.broker.models.ConnectionConfig` dependency remains until
broker runtime connection DTOs move to a neutral/source-provider package.
"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol from typing import Any, Protocol
from wf_sources_mcp.auth import AuthRecord from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
if TYPE_CHECKING:
from wf_mcp.broker.models import ConnectionConfig
@dataclass(slots=True) @dataclass(slots=True)
@@ -26,38 +20,38 @@ class ToolCallResult:
class BackendAdapter(Protocol): class BackendAdapter(Protocol):
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredTool]: ... ) -> list[DiscoveredTool]: ...
async def list_resources( async def list_resources(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredResource]: ... ) -> list[DiscoveredResource]: ...
async def list_prompts( async def list_prompts(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ... ) -> list[DiscoveredPrompt]: ...
async def get_connection_metadata( async def get_connection_metadata(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def read_resource( async def read_resource(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
uri: str, uri: str,
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
async def get_prompt( async def get_prompt(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
@@ -65,7 +59,7 @@ class BackendAdapter(Protocol):
async def invoke_method( async def invoke_method(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -73,7 +67,7 @@ class BackendAdapter(Protocol):
async def send_notification( async def send_notification(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -81,7 +75,7 @@ class BackendAdapter(Protocol):
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
@@ -98,7 +92,7 @@ class ToolExecutor(Protocol):
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
+8 -32
View File
@@ -8,14 +8,9 @@ runtime DTOs move out of the compatibility MCP facade.
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Literal, Protocol from typing import TYPE_CHECKING, Literal, Protocol
from pydantic import ( from pydantic import Field, field_validator, model_validator
AnyHttpUrl,
Field,
field_validator,
model_validator,
)
from wf_api.source_registry import ( from wf_api.source_registry import (
AtomicJsonRegistryStore, AtomicJsonRegistryStore,
@@ -25,12 +20,12 @@ from wf_api.source_registry import (
from wf_api.source_registry import ( from wf_api.source_registry import (
SourceRegistryStore as GenericSourceRegistryStore, SourceRegistryStore as GenericSourceRegistryStore,
) )
from wf_sources_mcp.ids import RESERVED_CONNECTION_IDS, parse_connection_id
# Temporary low-level compatibility imports. `wf_mcp.shared.names` currently from wf_sources_mcp.transports import (
# pulls in FastMCP transitively; keep this visible until reserved-name parsing HttpSourceTransport,
# moves to a neutral/source package. SourceTransport,
from wf_mcp.connections import parse_connection_id StdioSourceTransport,
from wf_mcp.shared.names import RESERVED_CONNECTION_IDS )
if TYPE_CHECKING: if TYPE_CHECKING:
from wf_mcp.models import ConnectionConfig from wf_mcp.models import ConnectionConfig
@@ -50,25 +45,6 @@ _TRANSPORT_METADATA_KEYS = {
} }
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): class McpSourceRegistryEntry(SourceRegistryBaseModel):
"""Desired MCP source configuration persisted by server-owned mutation.""" """Desired MCP source configuration persisted by server-owned mutation."""
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import AnyHttpUrl, Field
from wf_api.source_registry import SourceRegistryBaseModel
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"),
]
__all__ = [
"HttpSourceTransport",
"SourceTransport",
"StdioSourceTransport",
]
+4 -4
View File
@@ -28,8 +28,8 @@ class ContentOnlyOutputAdapter(FakeAdapter):
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
return [ return [
DiscoveredTool( DiscoveredTool(
@@ -51,8 +51,8 @@ class ContentOnlyOutputAdapter(FakeAdapter):
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
) -> ToolCallResult: ) -> ToolCallResult:
@@ -16,6 +16,10 @@ from ..test_support import FakeAdapter, local_temp_root
from ..workflow_surface.conftest import echo_artifact from ..workflow_surface.conftest import echo_artifact
def _fake_transport_metadata() -> dict[str, object]:
return {"transport": "stdio", "command": "fake-mcp-server"}
def _transport(root: Path) -> UpstreamTransportService: def _transport(root: Path) -> UpstreamTransportService:
events: list[McpEvent] = [] events: list[McpEvent] = []
return UpstreamTransportService( return UpstreamTransportService(
@@ -74,7 +78,12 @@ async def test_upstream_transport_invokes_raw_method_and_records_events() -> Non
events: list[McpEvent] = [] events: list[McpEvent] = []
connections = ConnectionRegistry() connections = ConnectionRegistry()
connections.register( connections.register(
ConnectionConfig(id="demo.personal", server="demo", account="personal") ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
metadata=_fake_transport_metadata(),
)
) )
transport = UpstreamTransportService( transport = UpstreamTransportService(
auth_store=FileStore(local_temp_root() / "upstream_raw_method"), auth_store=FileStore(local_temp_root() / "upstream_raw_method"),
@@ -100,7 +109,12 @@ async def test_upstream_transport_refreshes_catalog_directly() -> None:
events: list[McpEvent] = [] events: list[McpEvent] = []
store = FileStore(local_temp_root() / "upstream_refresh") store = FileStore(local_temp_root() / "upstream_refresh")
connections = ConnectionRegistry() connections = ConnectionRegistry()
connection = ConnectionConfig(id="demo.personal", server="demo", account="personal") connection = ConnectionConfig(
id="demo.personal",
server="demo",
account="personal",
metadata=_fake_transport_metadata(),
)
connections.register(connection) connections.register(connection)
transport = UpstreamTransportService( transport = UpstreamTransportService(
auth_store=store, auth_store=store,
@@ -197,7 +211,7 @@ def test_upstream_load_connection_auth_prefers_auth_ref(tmp_path: Path) -> None:
id="github.work", id="github.work",
server="github", server="github",
account="work", account="work",
metadata={"auth_ref": "github.creds"}, metadata={**_fake_transport_metadata(), "auth_ref": "github.creds"},
) )
assert service.load_connection_auth(connection) == AuthRecord( assert service.load_connection_auth(connection) == AuthRecord(
@@ -264,9 +278,9 @@ async def test_upstream_transport_live_diagnostics_report_missing_auth_ref(
connections = ConnectionRegistry() connections = ConnectionRegistry()
connection = ConnectionConfig( connection = ConnectionConfig(
id="github.work", id="github.work",
server="demo", server="github",
account="work", account="work",
metadata={"auth_ref": "github.creds"}, metadata={**_fake_transport_metadata(), "auth_ref": "github.creds"},
) )
connections.register(connection) connections.register(connection)
transport = UpstreamTransportService( transport = UpstreamTransportService(
+23 -13
View File
@@ -15,6 +15,8 @@ from wf_mcp.auth import (
from wf_mcp.models import AuthRecord as McpAuthRecord from wf_mcp.models import AuthRecord as McpAuthRecord
from wf_mcp.models import ConnectionConfig from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.transports import StdioSourceTransport
def test_mcp_auth_from_neutral_preserves_scheme_and_payload() -> None: def test_mcp_auth_from_neutral_preserves_scheme_and_payload() -> None:
@@ -125,22 +127,23 @@ def test_file_store_legacy_auth_methods_still_work(tmp_path: Path) -> None:
def test_auth_ref_for_connection_returns_string_only() -> None: def test_auth_ref_for_connection_returns_string_only() -> None:
assert ( assert (
auth_ref_for_connection( auth_ref_for_connection(
ConnectionConfig( McpSourceConnection(
id="github.work", id="github.work",
server="github", provider="github",
account="work", account="work",
metadata={"auth_ref": "github.creds"}, transport=StdioSourceTransport(command="placeholder"),
auth_ref="github.creds",
) )
) )
== "github.creds" == "github.creds"
) )
assert ( assert (
auth_ref_for_connection( auth_ref_for_connection(
ConnectionConfig( McpSourceConnection(
id="github.work", id="github.work",
server="github", provider="github",
account="work", account="work",
metadata={"auth_ref": 123}, transport=StdioSourceTransport(command="placeholder"),
) )
) )
is None is None
@@ -148,11 +151,12 @@ def test_auth_ref_for_connection_returns_string_only() -> None:
def test_connection_auth_diagnostic_reports_missing_auth_ref() -> None: def test_connection_auth_diagnostic_reports_missing_auth_ref() -> None:
connection = ConnectionConfig( connection = McpSourceConnection(
id="github.work", id="github.work",
server="github", provider="github",
account="work", account="work",
metadata={"auth_ref": "github.creds"}, transport=StdioSourceTransport(command="placeholder"),
auth_ref="github.creds",
) )
diagnostic = connection_auth_diagnostic( diagnostic = connection_auth_diagnostic(
@@ -172,12 +176,18 @@ def test_connection_auth_diagnostic_reports_missing_auth_ref() -> None:
def test_connection_auth_diagnostic_ignores_absent_or_present_auth_ref() -> None: def test_connection_auth_diagnostic_ignores_absent_or_present_auth_ref() -> None:
no_ref = ConnectionConfig(id="github.work", server="github", account="work") no_ref = McpSourceConnection(
with_ref = ConnectionConfig(
id="github.work", id="github.work",
server="github", provider="github",
account="work", account="work",
metadata={"auth_ref": "github.creds"}, transport=StdioSourceTransport(command="placeholder"),
)
with_ref = McpSourceConnection(
id="github.work",
provider="github",
account="work",
transport=StdioSourceTransport(command="placeholder"),
auth_ref="github.creds",
) )
auth = McpAuthRecord( auth = McpAuthRecord(
connection_id="github.creds", connection_id="github.creds",
+8 -2
View File
@@ -11,6 +11,7 @@ from wf_mcp.capabilities import DiscoveredTool
from wf_mcp.models import ConnectionConfig from wf_mcp.models import ConnectionConfig
from wf_mcp.sdk import McpSdkAdapter from wf_mcp.sdk import McpSdkAdapter
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_sources_mcp.connections import mcp_source_connection_from_connection_config
from .test_support import ( from .test_support import (
everything_server_connection, everything_server_connection,
@@ -39,7 +40,7 @@ class _ToolsOnlyAdapter:
raise McpError(ErrorData(code=-32601, message="Method not found")) raise McpError(ErrorData(code=-32601, message="Method not found"))
async def get_connection_metadata(self, connection, auth): async def get_connection_metadata(self, connection, auth):
return {"server": connection.server} return {"server": getattr(connection, "provider", getattr(connection, "server", None))}
async def read_resource(self, connection, auth, uri): async def read_resource(self, connection, auth, uri):
raise NotImplementedError raise NotImplementedError
@@ -133,9 +134,12 @@ def test_mcp_sdk_adapter_lists_and_calls_stdio_tool() -> None:
adapter = McpSdkAdapter() adapter = McpSdkAdapter()
try: try:
source_connection = mcp_source_connection_from_connection_config(
service.connections.get("fixture.personal")
)
result = asyncio.run( result = asyncio.run(
adapter.call_tool( adapter.call_tool(
connection=service.connections.get("fixture.personal"), connection=source_connection,
auth=None, auth=None,
tool_name="echo_tool", tool_name="echo_tool",
payload={"text": "hello"}, payload={"text": "hello"},
@@ -184,6 +188,7 @@ def test_refresh_catalog_keeps_tools_when_optional_lists_are_unsupported() -> No
id="tools_only.personal", id="tools_only.personal",
server="tools_only", server="tools_only",
account="personal", account="personal",
metadata={"transport": "stdio", "command": "fake-tools-only"},
) )
) )
service.register_adapter("tools_only", _ToolsOnlyAdapter()) service.register_adapter("tools_only", _ToolsOnlyAdapter())
@@ -205,6 +210,7 @@ def test_refresh_catalog_unwraps_taskgroup_method_not_found() -> None:
id="wrapped_tools_only.personal", id="wrapped_tools_only.personal",
server="wrapped_tools_only", server="wrapped_tools_only",
account="personal", account="personal",
metadata={"transport": "stdio", "command": "fake-tools-only"},
) )
) )
service.register_adapter("wrapped_tools_only", _WrappedToolsOnlyAdapter()) service.register_adapter("wrapped_tools_only", _WrappedToolsOnlyAdapter())
+6 -3
View File
@@ -16,6 +16,8 @@ from wf_mcp.runtime import McpRuntimePool, PersistentMcpSession
from wf_mcp.runtime.factory import PersistentSessionFactory from wf_mcp.runtime.factory import PersistentSessionFactory
from wf_mcp.sdk import ToolCallResult from wf_mcp.sdk import ToolCallResult
from wf_mcp.workflow import wrap_discovered_tool from wf_mcp.workflow import wrap_discovered_tool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.transports import StdioSourceTransport
@dataclass(slots=True) @dataclass(slots=True)
@@ -27,7 +29,7 @@ class FakeStatefulExecutor:
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth: AuthRecord | None,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
@@ -120,10 +122,11 @@ def _tool(name: str) -> DiscoveredTool:
def test_generated_workflow_specs_share_injected_tool_executor() -> None: def test_generated_workflow_specs_share_injected_tool_executor() -> None:
"""Generated NodeSpecs use the injected executor, not a baked-in adapter.""" """Generated NodeSpecs use the injected executor, not a baked-in adapter."""
connection = ConnectionConfig( connection = McpSourceConnection(
id="playwright.default", id="playwright.default",
server="playwright", provider="playwright",
account="default", account="default",
transport=StdioSourceTransport(command="placeholder"),
) )
executor = FakeStatefulExecutor() executor = FakeStatefulExecutor()
navigate = wrap_discovered_tool( navigate = wrap_discovered_tool(
+21 -21
View File
@@ -112,8 +112,8 @@ def everything_server_connection() -> ConnectionConfig | None:
class FakeAdapter: class FakeAdapter:
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
return [ return [
DiscoveredTool( DiscoveredTool(
@@ -145,8 +145,8 @@ class FakeAdapter:
async def list_resources( async def list_resources(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[DiscoveredResource]: ) -> list[DiscoveredResource]:
return [ return [
DiscoveredResource( DiscoveredResource(
@@ -161,8 +161,8 @@ class FakeAdapter:
async def list_prompts( async def list_prompts(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[DiscoveredPrompt]: ) -> list[DiscoveredPrompt]:
return [ return [
DiscoveredPrompt( DiscoveredPrompt(
@@ -182,19 +182,19 @@ class FakeAdapter:
async def get_connection_metadata( async def get_connection_metadata(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"server": connection.server, "server": getattr(connection, "provider", getattr(connection, "server", None)),
"account": connection.account, "account": connection.account,
"auth_scheme": auth.scheme if auth is not None else None, "auth_scheme": auth.scheme if auth is not None else None,
} }
async def read_resource( async def read_resource(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
uri: str, uri: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
if uri != "demo://docs/welcome": if uri != "demo://docs/welcome":
@@ -211,8 +211,8 @@ class FakeAdapter:
async def get_prompt( async def get_prompt(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -234,8 +234,8 @@ class FakeAdapter:
async def invoke_method( async def invoke_method(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -247,8 +247,8 @@ class FakeAdapter:
async def send_notification( async def send_notification(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> None: ) -> None:
@@ -256,8 +256,8 @@ class FakeAdapter:
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
) -> ToolCallResult: ) -> ToolCallResult:
@@ -272,8 +272,8 @@ class FakeAdapter:
class FailingDiscoveryAdapter(FakeAdapter): class FailingDiscoveryAdapter(FakeAdapter):
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
raise PermissionError("Access is denied") raise PermissionError("Access is denied")
+8 -4
View File
@@ -10,6 +10,8 @@ from wf_mcp.models import AuthRecord, ConnectionConfig
from wf_mcp.runtime import ToolExecutor from wf_mcp.runtime import ToolExecutor
from wf_mcp.sdk import ToolCallResult from wf_mcp.sdk import ToolCallResult
from wf_mcp.workflow import wrap_discovered_tool from wf_mcp.workflow import wrap_discovered_tool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.transports import StdioSourceTransport
class RecordingAdapter: class RecordingAdapter:
@@ -54,10 +56,11 @@ class TextContentAdapter:
def test_discovered_tool_wrapper_omits_unset_optional_arguments() -> None: def test_discovered_tool_wrapper_omits_unset_optional_arguments() -> None:
adapter = RecordingAdapter() adapter = RecordingAdapter()
spec = wrap_discovered_tool( spec = wrap_discovered_tool(
connection=ConnectionConfig( connection=McpSourceConnection(
id="playwright.default", id="playwright.default",
server="playwright", provider="playwright",
account="default", account="default",
transport=StdioSourceTransport(command="placeholder"),
), ),
auth=None, auth=None,
executor=cast(ToolExecutor, adapter), executor=cast(ToolExecutor, adapter),
@@ -92,10 +95,11 @@ def test_discovered_tool_wrapper_omits_unset_optional_arguments() -> None:
def test_discovered_tool_wrapper_preserves_raw_mcp_content_output() -> None: def test_discovered_tool_wrapper_preserves_raw_mcp_content_output() -> None:
spec = wrap_discovered_tool( spec = wrap_discovered_tool(
connection=ConnectionConfig( connection=McpSourceConnection(
id="everything.default", id="everything.default",
server="everything", provider="everything",
account="default", account="default",
transport=StdioSourceTransport(command="placeholder"),
), ),
auth=None, auth=None,
executor=cast(ToolExecutor, TextContentAdapter()), executor=cast(ToolExecutor, TextContentAdapter()),
+19 -19
View File
@@ -64,8 +64,8 @@ class ContentOnlyOutputAdapter:
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
return [ return [
DiscoveredTool( DiscoveredTool(
@@ -87,29 +87,29 @@ class ContentOnlyOutputAdapter:
async def list_resources( async def list_resources(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[Any]: ) -> list[Any]:
return [] return []
async def list_prompts( async def list_prompts(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> list[Any]: ) -> list[Any]:
return [] return []
async def get_connection_metadata( async def get_connection_metadata(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
) -> dict[str, Any]: ) -> dict[str, Any]:
return {"server": connection.server} return {"server": getattr(connection, "provider", getattr(connection, "server", None))}
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
) -> ToolCallResult: ) -> ToolCallResult:
@@ -121,16 +121,16 @@ class ContentOnlyOutputAdapter:
async def read_resource( async def read_resource(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
uri: str, uri: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
raise KeyError(uri) raise KeyError(uri)
async def get_prompt( async def get_prompt(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -138,8 +138,8 @@ class ContentOnlyOutputAdapter:
async def invoke_method( async def invoke_method(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -147,8 +147,8 @@ class ContentOnlyOutputAdapter:
async def send_notification( async def send_notification(
self, self,
connection: ConnectionConfig, connection,
auth: AuthRecord | None, auth,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> None: ) -> None:
+200
View File
@@ -0,0 +1,200 @@
from typing import Protocol
import pytest
from pydantic import TypeAdapter
from wf_sources_mcp.auth import auth_ref_for_connection
from wf_sources_mcp.connections import (
McpSourceConnection,
mcp_source_connection_from_connection_config,
mcp_source_connection_from_registry_entry,
)
from wf_sources_mcp.ids import (
CONNECTION_ID_PATTERN,
RESERVED_CONNECTION_IDS,
parse_connection_id,
)
from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor
from wf_sources_mcp.source_registry import McpSourceRegistryEntry
from wf_sources_mcp.transports import (
HttpSourceTransport,
SourceTransport,
StdioSourceTransport,
)
def test_stdio_source_transport_is_typed() -> None:
transport = StdioSourceTransport(
command="uvx",
args=("mcp-server",),
env={"TOKEN": "x"},
)
assert transport.kind == "stdio"
assert transport.command == "uvx"
assert transport.args == ("mcp-server",)
assert transport.env == {"TOKEN": "x"}
def test_http_source_transport_is_typed() -> None:
transport = HttpSourceTransport(url="http://127.0.0.1:8000/mcp")
assert transport.kind == "http"
assert str(transport.url) == "http://127.0.0.1:8000/mcp"
def test_source_transport_discriminated_union_parses() -> None:
adapter = TypeAdapter(SourceTransport)
transport = adapter.validate_python(
{"kind": "stdio", "command": "pnpx", "args": ["-y", "server"]}
)
assert isinstance(transport, StdioSourceTransport)
assert transport.args == ("-y", "server")
def test_parse_connection_id_splits_provider_and_account() -> None:
assert parse_connection_id("github.work") == ("github", "work")
@pytest.mark.parametrize(
"source_id",
["github", ".github.work", "github.", "github/work", "github work"],
)
def test_parse_connection_id_rejects_unsafe_or_unqualified_ids(source_id: str) -> None:
with pytest.raises(ValueError):
parse_connection_id(source_id)
def test_reserved_connection_ids_are_source_provider_constants() -> None:
assert "wf.admin" in RESERVED_CONNECTION_IDS
assert "wf.mcp" in RESERVED_CONNECTION_IDS
assert CONNECTION_ID_PATTERN.startswith("^")
def test_mcp_source_connection_from_registry_entry() -> None:
entry = McpSourceRegistryEntry.model_validate(
{
"id": "github.work",
"provider": "github",
"account": "work",
"profile": "engineering",
"transport": {
"kind": "stdio",
"command": "uvx",
"args": ["github-mcp"],
"env": {"A": "B"},
},
"auth_ref": "github.token",
"metadata": {"team": "platform"},
}
)
connection = mcp_source_connection_from_registry_entry(entry)
assert connection == McpSourceConnection(
id="github.work",
provider="github",
account="work",
enabled=True,
profile="engineering",
transport=StdioSourceTransport(
command="uvx",
args=("github-mcp",),
env={"A": "B"},
),
auth_ref="github.token",
metadata={"team": "platform"},
)
def test_mcp_source_connection_from_legacy_connection_config_stdio() -> None:
from wf_mcp.broker.models import ConnectionConfig
legacy = ConnectionConfig(
id="github.work",
server="github",
account="work",
enabled=False,
metadata={
"transport": "stdio",
"command": "uvx",
"args": ["github-mcp"],
"env": {"A": "B"},
"auth_ref": "github.token",
"profile": "engineering",
"source_registry": True,
"team": "platform",
},
)
connection = mcp_source_connection_from_connection_config(legacy)
assert connection.id == "github.work"
assert connection.provider == "github"
assert connection.account == "work"
assert connection.enabled is False
assert connection.profile == "engineering"
assert connection.auth_ref == "github.token"
assert connection.metadata == {"source_registry": True, "team": "platform"}
assert isinstance(connection.transport, StdioSourceTransport)
assert connection.transport.command == "uvx"
assert connection.transport.args == ("github-mcp",)
def test_mcp_source_connection_from_legacy_connection_config_http() -> None:
from wf_mcp.broker.models import ConnectionConfig
legacy = ConnectionConfig(
id="github.work",
server="github",
account="work",
metadata={
"transport": "streamable_http",
"url": "http://127.0.0.1:8000/mcp",
"headers": {"X-Test": "yes"},
},
)
connection = mcp_source_connection_from_connection_config(legacy)
assert isinstance(connection.transport, HttpSourceTransport)
assert str(connection.transport.url) == "http://127.0.0.1:8000/mcp"
assert connection.transport.headers == {"X-Test": "yes"}
def test_mcp_source_connection_rejects_missing_legacy_transport() -> None:
from wf_mcp.broker.models import ConnectionConfig
legacy = ConnectionConfig(
id="github.work",
server="github",
account="work",
metadata={},
)
with pytest.raises(ValueError, match="requires metadata.transport"):
mcp_source_connection_from_connection_config(legacy)
class _ConnectionLike(Protocol):
id: str
auth_ref: str | None
def test_auth_ref_for_typed_mcp_source_connection() -> None:
connection = McpSourceConnection(
id="github.work",
provider="github",
account="work",
transport=StdioSourceTransport(command="uvx"),
auth_ref="github.token",
)
assert auth_ref_for_connection(connection) == "github.token"
def test_sdk_protocols_are_importable_without_broker_connection_config() -> None:
assert BackendAdapter is not None
assert ToolExecutor is not None
+16 -5
View File
@@ -3,16 +3,17 @@ from __future__ import annotations
from dataclasses import is_dataclass from dataclasses import is_dataclass
from typing import cast from typing import cast
from wf_mcp.broker.models import ConnectionConfig
from wf_sources_mcp.auth import AuthRecord from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredTool from wf_sources_mcp.catalog import DiscoveredTool
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult, ToolExecutor from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult, ToolExecutor
from wf_sources_mcp.transports import StdioSourceTransport
class EchoAdapter: class EchoAdapter:
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
return [ return [
@@ -27,7 +28,7 @@ class EchoAdapter:
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
tool_name: str, tool_name: str,
payload: dict[str, object], payload: dict[str, object],
@@ -46,7 +47,12 @@ def test_tool_call_result_is_slots_dataclass_with_empty_defaults() -> None:
async def test_backend_adapter_protocol_can_describe_tool_listing() -> None: async def test_backend_adapter_protocol_can_describe_tool_listing() -> None:
adapter = cast(BackendAdapter, EchoAdapter()) adapter = cast(BackendAdapter, EchoAdapter())
tools = await adapter.list_tools( tools = await adapter.list_tools(
ConnectionConfig(id="demo.default", server="demo", account="default"), McpSourceConnection(
id="demo.default",
provider="demo",
account="default",
transport=StdioSourceTransport(command="echo"),
),
None, None,
) )
@@ -56,7 +62,12 @@ async def test_backend_adapter_protocol_can_describe_tool_listing() -> None:
async def test_tool_executor_protocol_can_describe_tool_calls() -> None: async def test_tool_executor_protocol_can_describe_tool_calls() -> None:
executor = cast(ToolExecutor, EchoAdapter()) executor = cast(ToolExecutor, EchoAdapter())
result = await executor.call_tool( result = await executor.call_tool(
ConnectionConfig(id="demo.default", server="demo", account="default"), McpSourceConnection(
id="demo.default",
provider="demo",
account="default",
transport=StdioSourceTransport(command="echo"),
),
None, None,
"echo", "echo",
{"message": "hello"}, {"message": "hello"},