refactor: add typed mcp source connection seam
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"},
|
||||||
|
|||||||
Reference in New Issue
Block a user