wf-mcp reorg big 1 the Folders have appeared

This commit is contained in:
lda
2026-05-07 15:53:37 +07:00 Verified
parent 7b3ee7c50f
commit 1f79c449cb
26 changed files with 743 additions and 660 deletions
+6 -5
View File
@@ -1,5 +1,6 @@
from .adapters import (
from .sdk import (
BackendAdapter,
McpSdkAdapter,
ToolCallResult,
)
from .broker_server import (
@@ -19,8 +20,9 @@ from .capabilities import (
)
from .catalog import CombinedCatalog
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from .config_manager import BrokerConfigManager, ConfigMutationError
from .config_models import (
from .control import (
BrokerConfigManager,
ConfigMutationError,
BrokerConfigFile,
ConnectionConfigFile,
HttpConnectionMetadata,
@@ -39,8 +41,7 @@ from .models import (
ConnectionConfig,
RawWorkflowPlan,
)
from .mcp_sdk_adapter import McpSdkAdapter
from .names import (
from .shared.names import (
ADMIN_NAMESPACE,
ProxyToolName,
is_admin_tool_name,
+2 -78
View File
@@ -1,79 +1,3 @@
from __future__ import annotations
from .sdk.base import BackendAdapter, ToolCallResult
from typing import Any, Protocol
from .capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from .models import AuthRecord, ConnectionConfig
from dataclasses import dataclass, field
@dataclass(slots=True)
class ToolCallResult:
outcome: str
output: dict[str, Any] = field(default_factory=dict)
meta: dict[str, Any] = field(default_factory=dict)
class BackendAdapter(Protocol):
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]: ...
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredResource]: ...
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ...
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]: ...
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]: ...
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ...
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]: ...
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None: ...
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult: ...
__all__ = ["BackendAdapter", "ToolCallResult"]
+3 -3
View File
@@ -8,9 +8,9 @@ from typing import Any, Literal
from mcp.server.fastmcp import FastMCP
from .config_models import BrokerConfigFile
from .error_info import error_payload
from .mcp_sdk_adapter import McpSdkAdapter
from .control import BrokerConfigFile
from .shared.errors import error_payload
from .sdk import McpSdkAdapter
from .models import BrokerConfig
from .service import WfMcpService
from .store import FileStore
+2 -124
View File
@@ -1,125 +1,3 @@
from __future__ import annotations
from .control.manager import BrokerConfigManager, ConfigMutationError
import json
from pathlib import Path
from typing import Any
from .config_models import BrokerConfigFile, ConnectionConfigFile
from .models import BrokerConfig
class ConfigMutationError(ValueError):
"""Raised when a requested config mutation cannot be applied."""
class BrokerConfigManager:
def __init__(self, config_path: str | Path) -> None:
self.config_path = Path(config_path)
def load_file(self) -> BrokerConfigFile:
data = json.loads(self.config_path.read_text(encoding="utf-8"))
return BrokerConfigFile.model_validate(data)
def load_runtime(self) -> BrokerConfig:
return self.load_file().to_runtime(config_path=self.config_path)
def write_file(self, config: BrokerConfigFile) -> None:
payload = config.model_dump(mode="json", exclude_none=True)
text = json.dumps(payload, indent=2) + "\n"
self.config_path.write_text(text, encoding="utf-8")
def get_payload(self) -> dict[str, Any]:
return self.load_file().model_dump(mode="json", exclude_none=True)
def add_connection(
self,
*,
connection_id: str,
server: str,
account: str,
metadata: dict[str, Any] | None = None,
enabled: bool = True,
) -> dict[str, Any]:
config = self.load_file()
if _find_connection(config, connection_id) is not None:
raise ConfigMutationError(f"connection {connection_id!r} already exists")
connection = ConnectionConfigFile(
id=connection_id,
server=server,
account=account,
enabled=enabled,
metadata={} if metadata is None else metadata,
)
config.connections.append(connection)
self.write_file(config)
return _mutation_payload("add_connection", connection_id)
def update_connection(
self,
*,
connection_id: str,
server: str | None = None,
account: str | None = None,
metadata: dict[str, Any] | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
config = self.load_file()
index = _find_connection_index(config, connection_id)
if index is None:
raise ConfigMutationError(f"connection {connection_id!r} does not exist")
existing = config.connections[index]
config.connections[index] = ConnectionConfigFile(
id=existing.id,
server=existing.server if server is None else server,
account=existing.account if account is None else account,
enabled=existing.enabled if enabled is None else enabled,
metadata=existing.metadata if metadata is None else metadata,
)
self.write_file(config)
return _mutation_payload("update_connection", connection_id)
def set_connection_enabled(
self,
connection_id: str,
*,
enabled: bool,
) -> dict[str, Any]:
return self.update_connection(connection_id=connection_id, enabled=enabled)
def remove_connection(self, connection_id: str) -> dict[str, Any]:
config = self.load_file()
index = _find_connection_index(config, connection_id)
if index is None:
raise ConfigMutationError(f"connection {connection_id!r} does not exist")
del config.connections[index]
self.write_file(config)
return _mutation_payload("remove_connection", connection_id)
def _find_connection(
config: BrokerConfigFile,
connection_id: str,
) -> ConnectionConfigFile | None:
index = _find_connection_index(config, connection_id)
if index is None:
return None
return config.connections[index]
def _find_connection_index(
config: BrokerConfigFile,
connection_id: str,
) -> int | None:
for index, connection in enumerate(config.connections):
if connection.id == connection_id:
return index
return None
def _mutation_payload(action: str, connection_id: str) -> dict[str, Any]:
return {
"action": action,
"connection_id": connection_id,
"ok": True,
"requires_reload": True,
}
__all__ = ["BrokerConfigManager", "ConfigMutationError"]
+11 -82
View File
@@ -1,84 +1,13 @@
from __future__ import annotations
from .control.models import (
BrokerConfigFile,
ConnectionConfigFile,
HttpConnectionMetadata,
StdioConnectionMetadata,
)
from pathlib import Path
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
from .models import BrokerConfig, ConnectionConfig
class StdioConnectionMetadata(BaseModel):
model_config = ConfigDict(extra="allow")
transport: Literal["stdio"] = "stdio"
command: str | None = None
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
cwd: str | None = None
description: str | None = None
class HttpConnectionMetadata(BaseModel):
model_config = ConfigDict(extra="allow")
transport: Literal["http", "streamable-http", "streamable_http", "sse"]
url: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
description: str | None = None
TypedConnectionMetadata = Annotated[
StdioConnectionMetadata | HttpConnectionMetadata,
Field(discriminator="transport"),
__all__ = [
"BrokerConfigFile",
"ConnectionConfigFile",
"HttpConnectionMetadata",
"StdioConnectionMetadata",
]
_METADATA_ADAPTER = TypeAdapter(TypedConnectionMetadata)
class ConnectionConfigFile(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
server: str
account: str
enabled: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("metadata", mode="before")
@classmethod
def validate_metadata(cls, value: object) -> dict[str, Any]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError("metadata must be an object")
if not value:
return {}
if "transport" not in value:
value = {**value, "transport": "stdio"}
metadata = _METADATA_ADAPTER.validate_python(value)
return metadata.model_dump(exclude_none=True)
def to_runtime(self) -> ConnectionConfig:
return ConnectionConfig(
id=self.id,
server=self.server,
account=self.account,
enabled=self.enabled,
metadata=self.metadata,
)
class BrokerConfigFile(BaseModel):
model_config = ConfigDict(extra="forbid")
store_root: Path = Path(".wf_mcp_store")
connections: list[ConnectionConfigFile] = Field(default_factory=list)
def to_runtime(self, *, config_path: Path) -> BrokerConfig:
store_root = self.store_root
if not store_root.is_absolute():
store_root = (config_path.parent / store_root).resolve()
return BrokerConfig(
store_root=store_root,
connections=[connection.to_runtime() for connection in self.connections],
)
+16
View File
@@ -0,0 +1,16 @@
from .manager import BrokerConfigManager, ConfigMutationError
from .models import (
BrokerConfigFile,
ConnectionConfigFile,
HttpConnectionMetadata,
StdioConnectionMetadata,
)
__all__ = [
"BrokerConfigFile",
"BrokerConfigManager",
"ConfigMutationError",
"ConnectionConfigFile",
"HttpConnectionMetadata",
"StdioConnectionMetadata",
]
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from ..models import BrokerConfig
from .models import BrokerConfigFile, ConnectionConfigFile
class ConfigMutationError(ValueError):
"""Raised when a requested config mutation cannot be applied."""
class BrokerConfigManager:
def __init__(self, config_path: str | Path) -> None:
self.config_path = Path(config_path)
def load_file(self) -> BrokerConfigFile:
data = json.loads(self.config_path.read_text(encoding="utf-8"))
return BrokerConfigFile.model_validate(data)
def load_runtime(self) -> BrokerConfig:
return self.load_file().to_runtime(config_path=self.config_path)
def write_file(self, config: BrokerConfigFile) -> None:
payload = config.model_dump(mode="json", exclude_none=True)
text = json.dumps(payload, indent=2) + "\n"
self.config_path.write_text(text, encoding="utf-8")
def get_payload(self) -> dict[str, Any]:
return self.load_file().model_dump(mode="json", exclude_none=True)
def add_connection(
self,
*,
connection_id: str,
server: str,
account: str,
metadata: dict[str, Any] | None = None,
enabled: bool = True,
) -> dict[str, Any]:
config = self.load_file()
if _find_connection(config, connection_id) is not None:
raise ConfigMutationError(f"connection {connection_id!r} already exists")
connection = ConnectionConfigFile(
id=connection_id,
server=server,
account=account,
enabled=enabled,
metadata={} if metadata is None else metadata,
)
config.connections.append(connection)
self.write_file(config)
return _mutation_payload("add_connection", connection_id)
def update_connection(
self,
*,
connection_id: str,
server: str | None = None,
account: str | None = None,
metadata: dict[str, Any] | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
config = self.load_file()
index = _find_connection_index(config, connection_id)
if index is None:
raise ConfigMutationError(f"connection {connection_id!r} does not exist")
existing = config.connections[index]
config.connections[index] = ConnectionConfigFile(
id=existing.id,
server=existing.server if server is None else server,
account=existing.account if account is None else account,
enabled=existing.enabled if enabled is None else enabled,
metadata=existing.metadata if metadata is None else metadata,
)
self.write_file(config)
return _mutation_payload("update_connection", connection_id)
def set_connection_enabled(
self,
connection_id: str,
*,
enabled: bool,
) -> dict[str, Any]:
return self.update_connection(connection_id=connection_id, enabled=enabled)
def remove_connection(self, connection_id: str) -> dict[str, Any]:
config = self.load_file()
index = _find_connection_index(config, connection_id)
if index is None:
raise ConfigMutationError(f"connection {connection_id!r} does not exist")
del config.connections[index]
self.write_file(config)
return _mutation_payload("remove_connection", connection_id)
def _find_connection(
config: BrokerConfigFile,
connection_id: str,
) -> ConnectionConfigFile | None:
index = _find_connection_index(config, connection_id)
if index is None:
return None
return config.connections[index]
def _find_connection_index(
config: BrokerConfigFile,
connection_id: str,
) -> int | None:
for index, connection in enumerate(config.connections):
if connection.id == connection_id:
return index
return None
def _mutation_payload(action: str, connection_id: str) -> dict[str, Any]:
return {
"action": action,
"connection_id": connection_id,
"ok": True,
"requires_reload": True,
}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
from ..models import BrokerConfig, ConnectionConfig
class StdioConnectionMetadata(BaseModel):
model_config = ConfigDict(extra="allow")
transport: Literal["stdio"] = "stdio"
command: str | None = None
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
cwd: str | None = None
description: str | None = None
class HttpConnectionMetadata(BaseModel):
model_config = ConfigDict(extra="allow")
transport: Literal["http", "streamable-http", "streamable_http", "sse"]
url: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
description: str | None = None
TypedConnectionMetadata = Annotated[
StdioConnectionMetadata | HttpConnectionMetadata,
Field(discriminator="transport"),
]
_METADATA_ADAPTER = TypeAdapter(TypedConnectionMetadata)
class ConnectionConfigFile(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
server: str
account: str
enabled: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("metadata", mode="before")
@classmethod
def validate_metadata(cls, value: object) -> dict[str, Any]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError("metadata must be an object")
if not value:
return {}
if "transport" not in value:
value = {**value, "transport": "stdio"}
metadata = _METADATA_ADAPTER.validate_python(value)
return metadata.model_dump(exclude_none=True)
def to_runtime(self) -> ConnectionConfig:
return ConnectionConfig(
id=self.id,
server=self.server,
account=self.account,
enabled=self.enabled,
metadata=self.metadata,
)
class BrokerConfigFile(BaseModel):
model_config = ConfigDict(extra="forbid")
store_root: Path = Path(".wf_mcp_store")
connections: list[ConnectionConfigFile] = Field(default_factory=list)
def to_runtime(self, *, config_path: Path) -> BrokerConfig:
store_root = self.store_root
if not store_root.is_absolute():
store_root = (config_path.parent / store_root).resolve()
return BrokerConfig(
store_root=store_root,
connections=[connection.to_runtime() for connection in self.connections],
)
+2 -6
View File
@@ -6,12 +6,8 @@ from typing import Any
from wf_authoring import NodeSpec
from .adapters import (
BackendAdapter,
DiscoveredPrompt,
DiscoveredResource,
DiscoveredTool,
)
from .capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from .sdk import BackendAdapter
from .events import McpEvent
from .models import AuthRecord, ConnectionConfig
from .wrappers import wrap_discovered_tool
+2 -19
View File
@@ -1,20 +1,3 @@
from __future__ import annotations
from .shared.errors import error_payload, root_exception
def root_exception(exc: BaseException) -> BaseException:
current: BaseException = exc
while isinstance(current, ExceptionGroup) and current.exceptions:
nested = current.exceptions[0]
if isinstance(nested, BaseException):
current = nested
continue
break
return current
def error_payload(exc: BaseException) -> dict[str, str]:
root = root_exception(exc)
return {
"error_type": type(root).__name__,
"error": str(root),
}
__all__ = ["error_payload", "root_exception"]
+2 -242
View File
@@ -1,243 +1,3 @@
from __future__ import annotations
from .sdk.adapter import McpSdkAdapter
from contextlib import asynccontextmanager
from typing import Any
import httpx
from mcp import ClientResult
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult as McpCallToolResult
from mcp.types import (
ClientNotification,
ClientRequest,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
)
from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource
from mcp.types import Tool as McpTool
from pydantic import AnyUrl
from .adapters import (
BackendAdapter,
DiscoveredPrompt,
DiscoveredResource,
DiscoveredTool,
ToolCallResult,
)
from .models import AuthRecord, ConnectionConfig
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
output_schema = tool.outputSchema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
display_name = (
tool.annotations.title
if tool.annotations is not None and tool.annotations.title
else tool.title
)
return DiscoveredTool(
name=tool.name,
title=display_name,
description=tool.description,
input_schema=tool.inputSchema,
output_schema=output_schema,
outcomes=("ok", "error"),
metadata=tool.model_dump(by_alias=True, mode="json"),
)
def _resource_to_discovered(resource: McpResource) -> DiscoveredResource:
local_name = resource.name or str(resource.uri)
return DiscoveredResource(
uri=str(resource.uri),
name=local_name,
title=resource.title,
description=resource.description,
mime_type=resource.mimeType,
metadata=resource.model_dump(by_alias=True, mode="json"),
)
def _prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
arguments = [
argument.model_dump(by_alias=True, mode="json")
for argument in prompt.arguments or []
]
return DiscoveredPrompt(
name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=arguments,
metadata=prompt.model_dump(by_alias=True, mode="json"),
)
def _tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
if result.structuredContent is not None:
output = result.structuredContent
else:
output = {
"content": [item.model_dump(by_alias=True) for item in result.content]
}
return ToolCallResult(
outcome="error" if result.isError else "ok",
output=output,
meta=result.meta or {},
)
class McpSdkAdapter(BackendAdapter):
@asynccontextmanager
async def _session(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
):
transport = connection.metadata.get("transport", "stdio")
if transport == "stdio":
command = connection.metadata["command"]
args = list(connection.metadata.get("args", []))
env = connection.metadata.get("env")
cwd = connection.metadata.get("cwd")
if auth is not None:
auth_env = auth.payload.get("env")
if isinstance(auth_env, dict):
env = {**(env or {}), **auth_env}
params = StdioServerParameters(
command=command,
args=args,
env=env,
cwd=cwd,
)
async with stdio_client(params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
return
if transport == "streamable_http":
url = connection.metadata["url"]
headers = _auth_headers(auth)
http_client = httpx.AsyncClient(headers=headers or None)
async with http_client:
async with streamable_http_client(
url,
http_client=http_client,
) as (read_stream, write_stream, _get_session_id):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
return
raise ValueError(f"unsupported MCP transport {transport!r}")
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]:
async with self._session(connection, auth) as session:
result: ListToolsResult = await session.list_tools()
return [_tool_to_discovered(tool) for tool in result.tools]
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
async with self._session(connection, auth) as session:
result: ListResourcesResult = await session.list_resources()
return [_resource_to_discovered(resource) for resource in result.resources]
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]:
async with self._session(connection, auth) as session:
result: ListPromptsResult = await session.list_prompts()
return [_prompt_to_discovered(prompt) for prompt in result.prompts]
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]:
return {
"server": connection.server,
"transport": connection.metadata.get("transport", "stdio"),
}
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.read_resource(AnyUrl(uri))
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.get_prompt(prompt_name, arguments)
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.send_request(
ClientRequest.model_validate({"method": method, "params": params}),
ClientResult,
)
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None:
async with self._session(connection, auth) as session:
await session.send_notification(
ClientNotification.model_validate({"method": method, "params": params})
)
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
async with self._session(connection, auth) as session:
result = await session.call_tool(tool_name, payload)
return _tool_result_to_call_result(result)
__all__ = ["McpSdkAdapter"]
+16 -49
View File
@@ -1,50 +1,17 @@
from __future__ import annotations
from .shared.names import (
ADMIN_NAMESPACE,
LdaNamespace,
ProxyToolName,
is_admin_tool_name,
namespaced_tool_name,
parse_namespaced_tool_name,
)
from dataclasses import dataclass
from fastmcp.server.transforms import Namespace
ADMIN_NAMESPACE = "wf.mcp"
@dataclass(frozen=True, slots=True)
class ProxyToolName:
proxy_name: str
connection_id: str
local_name: str
def namespaced_tool_name(connection_id: str, local_name: str) -> str:
return f"{connection_id}_{local_name}"
def parse_namespaced_tool_name(
proxy_name: str,
connection_ids: set[str],
) -> ProxyToolName | None:
matches = [
connection_id
for connection_id in connection_ids
if proxy_name.startswith(f"{connection_id}_")
]
if not matches:
return None
connection_id = max(matches, key=len)
local_name = proxy_name[len(connection_id) + 1 :]
if not local_name:
return None
return ProxyToolName(
proxy_name=proxy_name,
connection_id=connection_id,
local_name=local_name,
)
def is_admin_tool_name(proxy_name: str) -> bool:
return proxy_name.startswith(f"{ADMIN_NAMESPACE}_")
class LdaNamespace(Namespace):
def __init__(self, prefix: str) -> None:
super().__init__(prefix)
self._name_prefix = f"{prefix}." # some good stuff
__all__ = [
"ADMIN_NAMESPACE",
"LdaNamespace",
"ProxyToolName",
"is_admin_tool_name",
"namespaced_tool_name",
"parse_namespaced_tool_name",
]
+2 -43
View File
@@ -1,44 +1,3 @@
from __future__ import annotations
from .shared.pagination import clamp_limit, make_cursor, paginate_items, parse_cursor
import base64
import json
from typing import TypeVar
T = TypeVar("T")
def parse_cursor(cursor: str | None) -> int:
if cursor is None:
return 0
try:
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())
except Exception as exc:
raise ValueError("invalid cursor") from exc
start = payload.get("start")
if not isinstance(start, int) or start < 0:
raise ValueError("invalid cursor")
return start
def make_cursor(start: int) -> str:
payload = json.dumps({"start": start}, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(payload).decode()
def clamp_limit(limit: int, *, default: int = 50, maximum: int = 200) -> int:
if limit <= 0:
return default
return min(limit, maximum)
def paginate_items(
items: list[T],
*,
cursor: str | None,
limit: int,
) -> tuple[list[T], str | None]:
page_limit = clamp_limit(limit)
start = parse_cursor(cursor)
end = start + page_limit
next_cursor = make_cursor(end) if end < len(items) else None
return items[start:end], next_cursor
__all__ = ["clamp_limit", "make_cursor", "paginate_items", "parse_cursor"]
+4
View File
@@ -0,0 +1,4 @@
from .adapter import McpSdkAdapter
from .base import BackendAdapter, ToolCallResult
__all__ = ["BackendAdapter", "McpSdkAdapter", "ToolCallResult"]
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
import httpx
from mcp import ClientResult
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult as McpCallToolResult
from mcp.types import (
ClientNotification,
ClientRequest,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
)
from mcp.types import Prompt as McpPrompt
from mcp.types import Resource as McpResource
from mcp.types import Tool as McpTool
from pydantic import AnyUrl
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from ..models import AuthRecord, ConnectionConfig
from .base import BackendAdapter, ToolCallResult
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
output_schema = tool.outputSchema or {
"type": "object",
"properties": {"content": {"type": "array"}},
}
display_name = (
tool.annotations.title
if tool.annotations is not None and tool.annotations.title
else tool.title
)
return DiscoveredTool(
name=tool.name,
title=display_name,
description=tool.description,
input_schema=tool.inputSchema,
output_schema=output_schema,
outcomes=("ok", "error"),
metadata=tool.model_dump(by_alias=True, mode="json"),
)
def _resource_to_discovered(resource: McpResource) -> DiscoveredResource:
local_name = resource.name or str(resource.uri)
return DiscoveredResource(
uri=str(resource.uri),
name=local_name,
title=resource.title,
description=resource.description,
mime_type=resource.mimeType,
metadata=resource.model_dump(by_alias=True, mode="json"),
)
def _prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
arguments = [
argument.model_dump(by_alias=True, mode="json")
for argument in prompt.arguments or []
]
return DiscoveredPrompt(
name=prompt.name,
title=prompt.title,
description=prompt.description,
arguments=arguments,
metadata=prompt.model_dump(by_alias=True, mode="json"),
)
def _tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
if result.structuredContent is not None:
output = result.structuredContent
else:
output = {
"content": [item.model_dump(by_alias=True) for item in result.content]
}
return ToolCallResult(
outcome="error" if result.isError else "ok",
output=output,
meta=result.meta or {},
)
class McpSdkAdapter(BackendAdapter):
@asynccontextmanager
async def _session(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
):
transport = connection.metadata.get("transport", "stdio")
if transport == "stdio":
command = connection.metadata["command"]
args = list(connection.metadata.get("args", []))
env = connection.metadata.get("env")
cwd = connection.metadata.get("cwd")
if auth is not None:
auth_env = auth.payload.get("env")
if isinstance(auth_env, dict):
env = {**(env or {}), **auth_env}
params = StdioServerParameters(
command=command,
args=args,
env=env,
cwd=cwd,
)
async with stdio_client(params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
return
if transport == "streamable_http":
url = connection.metadata["url"]
headers = _auth_headers(auth)
http_client = httpx.AsyncClient(headers=headers or None)
async with http_client:
async with streamable_http_client(
url,
http_client=http_client,
) as (read_stream, write_stream, _get_session_id):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
return
raise ValueError(f"unsupported MCP transport {transport!r}")
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]:
async with self._session(connection, auth) as session:
result: ListToolsResult = await session.list_tools()
return [_tool_to_discovered(tool) for tool in result.tools]
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
async with self._session(connection, auth) as session:
result: ListResourcesResult = await session.list_resources()
return [_resource_to_discovered(resource) for resource in result.resources]
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]:
async with self._session(connection, auth) as session:
result: ListPromptsResult = await session.list_prompts()
return [_prompt_to_discovered(prompt) for prompt in result.prompts]
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]:
return {
"server": connection.server,
"transport": connection.metadata.get("transport", "stdio"),
}
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.read_resource(AnyUrl(uri))
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.get_prompt(prompt_name, arguments)
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
async with self._session(connection, auth) as session:
result = await session.send_request(
ClientRequest.model_validate({"method": method, "params": params}),
ClientResult,
)
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None:
async with self._session(connection, auth) as session:
await session.send_notification(
ClientNotification.model_validate({"method": method, "params": params})
)
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
async with self._session(connection, auth) as session:
result = await session.call_tool(tool_name, payload)
return _tool_result_to_call_result(result)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from ..models import AuthRecord, ConnectionConfig
@dataclass(slots=True)
class ToolCallResult:
outcome: str
output: dict[str, Any] = field(default_factory=dict)
meta: dict[str, Any] = field(default_factory=dict)
class BackendAdapter(Protocol):
async def list_tools(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredTool]: ...
async def list_resources(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredResource]: ...
async def list_prompts(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ...
async def get_connection_metadata(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> dict[str, Any]: ...
async def read_resource(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]: ...
async def get_prompt(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ...
async def invoke_method(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]: ...
async def send_notification(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
method: str,
params: dict[str, Any] | None = None,
) -> None: ...
async def call_tool(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult: ...
+1 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping
from ..adapters import BackendAdapter
from ..sdk import BackendAdapter
from ..models import ConnectionConfig
+2 -2
View File
@@ -7,11 +7,11 @@ from typing import Any
from wf_authoring import NodeSpec, build_async_registry
from wf_core import NodeUse, Workflow, execute_workflow_async
from ..adapters import BackendAdapter
from ..sdk import BackendAdapter
from ..catalog import CombinedCatalog, snapshot_from_specs
from ..connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
from ..error_info import error_payload
from ..shared.errors import error_payload
from ..events import McpEvent, make_event
from ..models import (
AuthRecord,
+25
View File
@@ -0,0 +1,25 @@
from .errors import error_payload, root_exception
from .names import (
ADMIN_NAMESPACE,
LdaNamespace,
ProxyToolName,
is_admin_tool_name,
namespaced_tool_name,
parse_namespaced_tool_name,
)
from .pagination import clamp_limit, make_cursor, paginate_items, parse_cursor
__all__ = [
"ADMIN_NAMESPACE",
"LdaNamespace",
"ProxyToolName",
"clamp_limit",
"error_payload",
"is_admin_tool_name",
"make_cursor",
"namespaced_tool_name",
"paginate_items",
"parse_cursor",
"parse_namespaced_tool_name",
"root_exception",
]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
def root_exception(exc: BaseException) -> BaseException:
current: BaseException = exc
while isinstance(current, ExceptionGroup) and current.exceptions:
nested = current.exceptions[0]
if isinstance(nested, BaseException):
current = nested
continue
break
return current
def error_payload(exc: BaseException) -> dict[str, str]:
root = root_exception(exc)
return {
"error_type": type(root).__name__,
"error": str(root),
}
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
from fastmcp.server.transforms import Namespace
ADMIN_NAMESPACE = "wf.mcp"
@dataclass(frozen=True, slots=True)
class ProxyToolName:
proxy_name: str
connection_id: str
local_name: str
def namespaced_tool_name(connection_id: str, local_name: str) -> str:
return f"{connection_id}_{local_name}"
def parse_namespaced_tool_name(
proxy_name: str,
connection_ids: set[str],
) -> ProxyToolName | None:
matches = [
connection_id
for connection_id in connection_ids
if proxy_name.startswith(f"{connection_id}_")
]
if not matches:
return None
connection_id = max(matches, key=len)
local_name = proxy_name[len(connection_id) + 1 :]
if not local_name:
return None
return ProxyToolName(
proxy_name=proxy_name,
connection_id=connection_id,
local_name=local_name,
)
def is_admin_tool_name(proxy_name: str) -> bool:
return proxy_name.startswith(f"{ADMIN_NAMESPACE}_")
class LdaNamespace(Namespace):
def __init__(self, prefix: str) -> None:
super().__init__(prefix)
self._name_prefix = f"{prefix}." # some good stuff
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import base64
import json
from typing import TypeVar
T = TypeVar("T")
def parse_cursor(cursor: str | None) -> int:
if cursor is None:
return 0
try:
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())
except Exception as exc:
raise ValueError("invalid cursor") from exc
start = payload.get("start")
if not isinstance(start, int) or start < 0:
raise ValueError("invalid cursor")
return start
def make_cursor(start: int) -> str:
payload = json.dumps({"start": start}, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(payload).decode()
def clamp_limit(limit: int, *, default: int = 50, maximum: int = 200) -> int:
if limit <= 0:
return default
return min(limit, maximum)
def paginate_items(
items: list[T],
*,
cursor: str | None,
limit: int,
) -> tuple[list[T], str | None]:
page_limit = clamp_limit(limit)
start = parse_cursor(cursor)
end = start + page_limit
next_cursor = make_cursor(end) if end < len(items) else None
return items[start:end], next_cursor
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, Protocol
from fastmcp import FastMCP
from ..config_manager import BrokerConfigManager
from ..control import BrokerConfigManager
from ..models import BrokerConfig
+2 -2
View File
@@ -11,9 +11,9 @@ from fastmcp.server import create_proxy
from fastmcp.server.transforms import Namespace, PromptsAsTools, ResourcesAsTools
from fastmcp.server.transforms.search import BM25SearchTransform
from ..config_manager import BrokerConfigManager, ConfigMutationError
from ..control import BrokerConfigManager, ConfigMutationError
from ..models import BrokerConfig
from ..names import ADMIN_NAMESPACE
from ..shared.names import ADMIN_NAMESPACE
from ..proxy_config import broker_config_to_fastmcp_config
from ..proxy_validation import validate_transparent_proxy_config
from .admin import create_proxy_admin_server
+2 -2
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from ..names import is_admin_tool_name, parse_namespaced_tool_name
from ..pagination import paginate_items
from ..shared.names import is_admin_tool_name, parse_namespaced_tool_name
from ..shared.pagination import paginate_items
def proxy_tool_payload(
+2 -1
View File
@@ -8,7 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field, create_model
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from .adapters import BackendAdapter, DiscoveredTool
from .capabilities import DiscoveredTool
from .sdk import BackendAdapter
from .events import McpEvent, make_event
from .models import AuthRecord, ConnectionConfig