wf-mcp reorg big 1 the Folders have appeared
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from .adapters import (
|
from .sdk import (
|
||||||
BackendAdapter,
|
BackendAdapter,
|
||||||
|
McpSdkAdapter,
|
||||||
ToolCallResult,
|
ToolCallResult,
|
||||||
)
|
)
|
||||||
from .broker_server import (
|
from .broker_server import (
|
||||||
@@ -19,8 +20,9 @@ from .capabilities import (
|
|||||||
)
|
)
|
||||||
from .catalog import CombinedCatalog
|
from .catalog import CombinedCatalog
|
||||||
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
||||||
from .config_manager import BrokerConfigManager, ConfigMutationError
|
from .control import (
|
||||||
from .config_models import (
|
BrokerConfigManager,
|
||||||
|
ConfigMutationError,
|
||||||
BrokerConfigFile,
|
BrokerConfigFile,
|
||||||
ConnectionConfigFile,
|
ConnectionConfigFile,
|
||||||
HttpConnectionMetadata,
|
HttpConnectionMetadata,
|
||||||
@@ -39,8 +41,7 @@ from .models import (
|
|||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
RawWorkflowPlan,
|
RawWorkflowPlan,
|
||||||
)
|
)
|
||||||
from .mcp_sdk_adapter import McpSdkAdapter
|
from .shared.names import (
|
||||||
from .names import (
|
|
||||||
ADMIN_NAMESPACE,
|
ADMIN_NAMESPACE,
|
||||||
ProxyToolName,
|
ProxyToolName,
|
||||||
is_admin_tool_name,
|
is_admin_tool_name,
|
||||||
|
|||||||
+2
-78
@@ -1,79 +1,3 @@
|
|||||||
from __future__ import annotations
|
from .sdk.base import BackendAdapter, ToolCallResult
|
||||||
|
|
||||||
from typing import Any, Protocol
|
__all__ = ["BackendAdapter", "ToolCallResult"]
|
||||||
|
|
||||||
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: ...
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .config_models import BrokerConfigFile
|
from .control import BrokerConfigFile
|
||||||
from .error_info import error_payload
|
from .shared.errors import error_payload
|
||||||
from .mcp_sdk_adapter import McpSdkAdapter
|
from .sdk import McpSdkAdapter
|
||||||
from .models import BrokerConfig
|
from .models import BrokerConfig
|
||||||
from .service import WfMcpService
|
from .service import WfMcpService
|
||||||
from .store import FileStore
|
from .store import FileStore
|
||||||
|
|||||||
@@ -1,125 +1,3 @@
|
|||||||
from __future__ import annotations
|
from .control.manager import BrokerConfigManager, ConfigMutationError
|
||||||
|
|
||||||
import json
|
__all__ = ["BrokerConfigManager", "ConfigMutationError"]
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-82
@@ -1,84 +1,13 @@
|
|||||||
from __future__ import annotations
|
from .control.models import (
|
||||||
|
BrokerConfigFile,
|
||||||
|
ConnectionConfigFile,
|
||||||
|
HttpConnectionMetadata,
|
||||||
|
StdioConnectionMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
from pathlib import Path
|
__all__ = [
|
||||||
from typing import Annotated, Any, Literal
|
"BrokerConfigFile",
|
||||||
|
"ConnectionConfigFile",
|
||||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
|
"HttpConnectionMetadata",
|
||||||
|
"StdioConnectionMetadata",
|
||||||
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],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from .manager import BrokerConfigManager, ConfigMutationError
|
||||||
|
from .models import (
|
||||||
|
BrokerConfigFile,
|
||||||
|
ConnectionConfigFile,
|
||||||
|
HttpConnectionMetadata,
|
||||||
|
StdioConnectionMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BrokerConfigFile",
|
||||||
|
"BrokerConfigManager",
|
||||||
|
"ConfigMutationError",
|
||||||
|
"ConnectionConfigFile",
|
||||||
|
"HttpConnectionMetadata",
|
||||||
|
"StdioConnectionMetadata",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -6,12 +6,8 @@ from typing import Any
|
|||||||
|
|
||||||
from wf_authoring import NodeSpec
|
from wf_authoring import NodeSpec
|
||||||
|
|
||||||
from .adapters import (
|
from .capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||||
BackendAdapter,
|
from .sdk import BackendAdapter
|
||||||
DiscoveredPrompt,
|
|
||||||
DiscoveredResource,
|
|
||||||
DiscoveredTool,
|
|
||||||
)
|
|
||||||
from .events import McpEvent
|
from .events import McpEvent
|
||||||
from .models import AuthRecord, ConnectionConfig
|
from .models import AuthRecord, ConnectionConfig
|
||||||
from .wrappers import wrap_discovered_tool
|
from .wrappers import wrap_discovered_tool
|
||||||
|
|||||||
@@ -1,20 +1,3 @@
|
|||||||
from __future__ import annotations
|
from .shared.errors import error_payload, root_exception
|
||||||
|
|
||||||
|
__all__ = ["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),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,243 +1,3 @@
|
|||||||
from __future__ import annotations
|
from .sdk.adapter import McpSdkAdapter
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
__all__ = ["McpSdkAdapter"]
|
||||||
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)
|
|
||||||
|
|||||||
+16
-49
@@ -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
|
__all__ = [
|
||||||
|
"ADMIN_NAMESPACE",
|
||||||
from fastmcp.server.transforms import Namespace
|
"LdaNamespace",
|
||||||
|
"ProxyToolName",
|
||||||
ADMIN_NAMESPACE = "wf.mcp"
|
"is_admin_tool_name",
|
||||||
|
"namespaced_tool_name",
|
||||||
|
"parse_namespaced_tool_name",
|
||||||
@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
|
|
||||||
|
|||||||
@@ -1,44 +1,3 @@
|
|||||||
from __future__ import annotations
|
from .shared.pagination import clamp_limit, make_cursor, paginate_items, parse_cursor
|
||||||
|
|
||||||
import base64
|
__all__ = ["clamp_limit", "make_cursor", "paginate_items", "parse_cursor"]
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .adapter import McpSdkAdapter
|
||||||
|
from .base import BackendAdapter, ToolCallResult
|
||||||
|
|
||||||
|
__all__ = ["BackendAdapter", "McpSdkAdapter", "ToolCallResult"]
|
||||||
@@ -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)
|
||||||
@@ -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: ...
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from ..adapters import BackendAdapter
|
from ..sdk import BackendAdapter
|
||||||
from ..models import ConnectionConfig
|
from ..models import ConnectionConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ from typing import Any
|
|||||||
from wf_authoring import NodeSpec, build_async_registry
|
from wf_authoring import NodeSpec, build_async_registry
|
||||||
from wf_core import NodeUse, Workflow, execute_workflow_async
|
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 ..catalog import CombinedCatalog, snapshot_from_specs
|
||||||
from ..connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
from ..connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
||||||
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
|
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 ..events import McpEvent, make_event
|
||||||
from ..models import (
|
from ..models import (
|
||||||
AuthRecord,
|
AuthRecord,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ from typing import Any, Protocol
|
|||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from ..config_manager import BrokerConfigManager
|
from ..control import BrokerConfigManager
|
||||||
from ..models import BrokerConfig
|
from ..models import BrokerConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ from fastmcp.server import create_proxy
|
|||||||
from fastmcp.server.transforms import Namespace, PromptsAsTools, ResourcesAsTools
|
from fastmcp.server.transforms import Namespace, PromptsAsTools, ResourcesAsTools
|
||||||
from fastmcp.server.transforms.search import BM25SearchTransform
|
from fastmcp.server.transforms.search import BM25SearchTransform
|
||||||
|
|
||||||
from ..config_manager import BrokerConfigManager, ConfigMutationError
|
from ..control import BrokerConfigManager, ConfigMutationError
|
||||||
from ..models import BrokerConfig
|
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_config import broker_config_to_fastmcp_config
|
||||||
from ..proxy_validation import validate_transparent_proxy_config
|
from ..proxy_validation import validate_transparent_proxy_config
|
||||||
from .admin import create_proxy_admin_server
|
from .admin import create_proxy_admin_server
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..names import is_admin_tool_name, parse_namespaced_tool_name
|
from ..shared.names import is_admin_tool_name, parse_namespaced_tool_name
|
||||||
from ..pagination import paginate_items
|
from ..shared.pagination import paginate_items
|
||||||
|
|
||||||
|
|
||||||
def proxy_tool_payload(
|
def proxy_tool_payload(
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ 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 .adapters import BackendAdapter, DiscoveredTool
|
from .capabilities import DiscoveredTool
|
||||||
|
from .sdk import BackendAdapter
|
||||||
from .events import McpEvent, make_event
|
from .events import McpEvent, make_event
|
||||||
from .models import AuthRecord, ConnectionConfig
|
from .models import AuthRecord, ConnectionConfig
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user