smartify-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartify/__init__.py +3 -0
- smartify/agents/__init__.py +0 -0
- smartify/agents/adapters/__init__.py +13 -0
- smartify/agents/adapters/anthropic.py +253 -0
- smartify/agents/adapters/openai.py +289 -0
- smartify/api/__init__.py +26 -0
- smartify/api/auth.py +352 -0
- smartify/api/errors.py +380 -0
- smartify/api/events.py +345 -0
- smartify/api/server.py +992 -0
- smartify/cli/__init__.py +1 -0
- smartify/cli/main.py +430 -0
- smartify/engine/__init__.py +64 -0
- smartify/engine/approval.py +479 -0
- smartify/engine/orchestrator.py +1365 -0
- smartify/engine/scheduler.py +380 -0
- smartify/engine/spark.py +294 -0
- smartify/guardrails/__init__.py +22 -0
- smartify/guardrails/breakers.py +409 -0
- smartify/models/__init__.py +61 -0
- smartify/models/grid.py +625 -0
- smartify/notifications/__init__.py +22 -0
- smartify/notifications/webhook.py +556 -0
- smartify/state/__init__.py +46 -0
- smartify/state/checkpoint.py +558 -0
- smartify/state/resume.py +301 -0
- smartify/state/store.py +370 -0
- smartify/tools/__init__.py +17 -0
- smartify/tools/base.py +196 -0
- smartify/tools/builtin/__init__.py +79 -0
- smartify/tools/builtin/file.py +464 -0
- smartify/tools/builtin/http.py +195 -0
- smartify/tools/builtin/shell.py +137 -0
- smartify/tools/mcp/__init__.py +33 -0
- smartify/tools/mcp/adapter.py +157 -0
- smartify/tools/mcp/client.py +334 -0
- smartify/tools/mcp/registry.py +130 -0
- smartify/validator/__init__.py +0 -0
- smartify/validator/validate.py +271 -0
- smartify/workspace/__init__.py +5 -0
- smartify/workspace/manager.py +248 -0
- smartify_ai-0.1.0.dist-info/METADATA +201 -0
- smartify_ai-0.1.0.dist-info/RECORD +46 -0
- smartify_ai-0.1.0.dist-info/WHEEL +4 -0
- smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
- smartify_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Shell command execution tool."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import shlex
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from smartify.tools.base import Tool, ToolResult
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShellTool(Tool):
|
|
14
|
+
"""Execute shell commands.
|
|
15
|
+
|
|
16
|
+
Runs commands in a subprocess with configurable timeout and working directory.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = "shell"
|
|
20
|
+
description = "Execute a shell command and return its output. Use for running CLI tools, scripts, or system commands."
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
default_timeout: float = 60.0,
|
|
25
|
+
allowed_commands: Optional[list[str]] = None,
|
|
26
|
+
blocked_commands: Optional[list[str]] = None,
|
|
27
|
+
working_dir: Optional[str] = None,
|
|
28
|
+
):
|
|
29
|
+
"""Initialize shell tool.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
default_timeout: Default command timeout in seconds
|
|
33
|
+
allowed_commands: If set, only these command prefixes are allowed
|
|
34
|
+
blocked_commands: Commands to block (e.g., ["rm -rf", "sudo"])
|
|
35
|
+
working_dir: Default working directory
|
|
36
|
+
"""
|
|
37
|
+
self.default_timeout = default_timeout
|
|
38
|
+
self.allowed_commands = allowed_commands
|
|
39
|
+
self.blocked_commands = blocked_commands or ["rm -rf /", "sudo rm", ":(){ :|:& };:"]
|
|
40
|
+
self.working_dir = working_dir
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def parameters(self) -> Dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"command": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "The shell command to execute"
|
|
50
|
+
},
|
|
51
|
+
"timeout": {
|
|
52
|
+
"type": "number",
|
|
53
|
+
"description": f"Timeout in seconds (default: {self.default_timeout})"
|
|
54
|
+
},
|
|
55
|
+
"working_dir": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Working directory for the command"
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
"required": ["command"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async def execute(
|
|
64
|
+
self,
|
|
65
|
+
command: str,
|
|
66
|
+
timeout: Optional[float] = None,
|
|
67
|
+
working_dir: Optional[str] = None,
|
|
68
|
+
**kwargs
|
|
69
|
+
) -> ToolResult:
|
|
70
|
+
"""Execute a shell command.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
command: Shell command to run
|
|
74
|
+
timeout: Command timeout in seconds
|
|
75
|
+
working_dir: Working directory
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
ToolResult with stdout/stderr
|
|
79
|
+
"""
|
|
80
|
+
# Security checks
|
|
81
|
+
for blocked in self.blocked_commands:
|
|
82
|
+
if blocked in command:
|
|
83
|
+
return ToolResult(
|
|
84
|
+
success=False,
|
|
85
|
+
error=f"Blocked command pattern: {blocked}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if self.allowed_commands:
|
|
89
|
+
cmd_prefix = command.split()[0] if command.split() else ""
|
|
90
|
+
if cmd_prefix not in self.allowed_commands:
|
|
91
|
+
return ToolResult(
|
|
92
|
+
success=False,
|
|
93
|
+
error=f"Command not in allowlist: {cmd_prefix}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
timeout = timeout or self.default_timeout
|
|
97
|
+
cwd = working_dir or self.working_dir
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
process = await asyncio.create_subprocess_shell(
|
|
101
|
+
command,
|
|
102
|
+
stdout=asyncio.subprocess.PIPE,
|
|
103
|
+
stderr=asyncio.subprocess.PIPE,
|
|
104
|
+
cwd=cwd,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
stdout, stderr = await asyncio.wait_for(
|
|
108
|
+
process.communicate(),
|
|
109
|
+
timeout=timeout
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
stdout_text = stdout.decode("utf-8", errors="replace")
|
|
113
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
return ToolResult(
|
|
116
|
+
success=process.returncode == 0,
|
|
117
|
+
output={
|
|
118
|
+
"stdout": stdout_text,
|
|
119
|
+
"stderr": stderr_text,
|
|
120
|
+
"exit_code": process.returncode,
|
|
121
|
+
},
|
|
122
|
+
error=stderr_text if process.returncode != 0 else None,
|
|
123
|
+
metadata={"command": command, "cwd": cwd}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
except asyncio.TimeoutError:
|
|
127
|
+
return ToolResult(
|
|
128
|
+
success=False,
|
|
129
|
+
error=f"Command timed out after {timeout}s",
|
|
130
|
+
metadata={"command": command}
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return ToolResult(
|
|
134
|
+
success=False,
|
|
135
|
+
error=f"Command execution failed: {str(e)}",
|
|
136
|
+
metadata={"command": command}
|
|
137
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) integration for Smartify.
|
|
2
|
+
|
|
3
|
+
This module provides plug-and-play integration with external MCP servers,
|
|
4
|
+
allowing grids to use tools from any MCP-compatible server.
|
|
5
|
+
|
|
6
|
+
Install the optional dependency:
|
|
7
|
+
pip install smartify[mcp]
|
|
8
|
+
|
|
9
|
+
Example usage in a grid:
|
|
10
|
+
tools:
|
|
11
|
+
mcpServers:
|
|
12
|
+
- id: filesystem
|
|
13
|
+
transport: stdio
|
|
14
|
+
command: npx
|
|
15
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
16
|
+
prefix: fs
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from smartify.tools.mcp.client import (
|
|
20
|
+
McpClient,
|
|
21
|
+
McpServerConfig,
|
|
22
|
+
McpTransport,
|
|
23
|
+
)
|
|
24
|
+
from smartify.tools.mcp.adapter import McpToolWrapper
|
|
25
|
+
from smartify.tools.mcp.registry import register_mcp_server
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"McpClient",
|
|
29
|
+
"McpServerConfig",
|
|
30
|
+
"McpTransport",
|
|
31
|
+
"McpToolWrapper",
|
|
32
|
+
"register_mcp_server",
|
|
33
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""MCP tool adapter for Smartify.
|
|
2
|
+
|
|
3
|
+
Wraps MCP tools to implement the Smartify Tool interface, allowing
|
|
4
|
+
MCP server tools to be used seamlessly in grid execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from smartify.tools.base import Tool, ToolResult
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from smartify.tools.mcp.client import McpClient, McpToolDef
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class McpToolWrapper(Tool):
|
|
21
|
+
"""Wraps an MCP tool to implement the Smartify Tool interface.
|
|
22
|
+
|
|
23
|
+
This adapter allows MCP server tools to be registered in the Smartify
|
|
24
|
+
ToolRegistry and used by grid nodes just like builtin tools.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
client = McpClient(config)
|
|
28
|
+
await client.connect()
|
|
29
|
+
|
|
30
|
+
tools = await client.list_tools()
|
|
31
|
+
for tool_def in tools:
|
|
32
|
+
wrapper = McpToolWrapper(client, tool_def, prefix="mcp")
|
|
33
|
+
registry.register(wrapper)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
client: "McpClient",
|
|
39
|
+
tool_def: "McpToolDef",
|
|
40
|
+
prefix: Optional[str] = None,
|
|
41
|
+
):
|
|
42
|
+
"""Initialize the MCP tool wrapper.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
client: Connected MCP client instance
|
|
46
|
+
tool_def: Tool definition from MCP server
|
|
47
|
+
prefix: Optional prefix for the tool name (e.g. "mcp" -> "mcp_read_file")
|
|
48
|
+
"""
|
|
49
|
+
self._client = client
|
|
50
|
+
self._tool_def = tool_def
|
|
51
|
+
self._prefix = prefix
|
|
52
|
+
|
|
53
|
+
# Build the Smartify tool name
|
|
54
|
+
if prefix:
|
|
55
|
+
self._name = f"{prefix}_{tool_def.name}"
|
|
56
|
+
else:
|
|
57
|
+
self._name = tool_def.name
|
|
58
|
+
|
|
59
|
+
# Store the original MCP tool name for calling
|
|
60
|
+
self._mcp_tool_name = tool_def.name
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def name(self) -> str:
|
|
64
|
+
"""Tool name (possibly prefixed)."""
|
|
65
|
+
return self._name
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def description(self) -> str:
|
|
69
|
+
"""Tool description from MCP server."""
|
|
70
|
+
return self._tool_def.description
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def parameters(self) -> Dict[str, Any]:
|
|
74
|
+
"""JSON Schema for tool parameters from MCP server."""
|
|
75
|
+
return self._tool_def.parameters
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def mcp_server_id(self) -> str:
|
|
79
|
+
"""ID of the MCP server this tool belongs to."""
|
|
80
|
+
return self._client.config.id
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def mcp_tool_name(self) -> str:
|
|
84
|
+
"""Original tool name on the MCP server."""
|
|
85
|
+
return self._mcp_tool_name
|
|
86
|
+
|
|
87
|
+
async def execute(self, **kwargs) -> ToolResult:
|
|
88
|
+
"""Execute the MCP tool.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
**kwargs: Tool arguments matching the parameter schema
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
ToolResult with success status and output/error
|
|
95
|
+
"""
|
|
96
|
+
if not self._client.is_connected:
|
|
97
|
+
return ToolResult(
|
|
98
|
+
success=False,
|
|
99
|
+
error=f"MCP server '{self._client.config.id}' is not connected",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
logger.debug(
|
|
103
|
+
f"Executing MCP tool '{self._mcp_tool_name}' on server "
|
|
104
|
+
f"'{self._client.config.id}' with args: {kwargs}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
result = await self._client.call_tool(self._mcp_tool_name, kwargs)
|
|
109
|
+
|
|
110
|
+
return ToolResult(
|
|
111
|
+
success=result["success"],
|
|
112
|
+
output=result["output"],
|
|
113
|
+
error=result.get("error"),
|
|
114
|
+
metadata={
|
|
115
|
+
"mcp_server": self._client.config.id,
|
|
116
|
+
"mcp_tool": self._mcp_tool_name,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(
|
|
122
|
+
f"MCP tool execution failed: {self._mcp_tool_name} - {e}"
|
|
123
|
+
)
|
|
124
|
+
return ToolResult(
|
|
125
|
+
success=False,
|
|
126
|
+
error=f"MCP tool execution failed: {e}",
|
|
127
|
+
metadata={
|
|
128
|
+
"mcp_server": self._client.config.id,
|
|
129
|
+
"mcp_tool": self._mcp_tool_name,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def to_openai_format(self) -> Dict[str, Any]:
|
|
134
|
+
"""Convert to OpenAI function calling format."""
|
|
135
|
+
return {
|
|
136
|
+
"type": "function",
|
|
137
|
+
"function": {
|
|
138
|
+
"name": self.name,
|
|
139
|
+
"description": self.description,
|
|
140
|
+
"parameters": self.parameters,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def to_anthropic_format(self) -> Dict[str, Any]:
|
|
145
|
+
"""Convert to Anthropic tool format."""
|
|
146
|
+
return {
|
|
147
|
+
"name": self.name,
|
|
148
|
+
"description": self.description,
|
|
149
|
+
"input_schema": self.parameters,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return (
|
|
154
|
+
f"McpToolWrapper(name={self.name!r}, "
|
|
155
|
+
f"mcp_server={self._client.config.id!r}, "
|
|
156
|
+
f"mcp_tool={self._mcp_tool_name!r})"
|
|
157
|
+
)
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""MCP client wrapper for Smartify.
|
|
2
|
+
|
|
3
|
+
Provides a thin wrapper around the MCP Python SDK for connecting to
|
|
4
|
+
MCP servers and calling their tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from contextlib import AsyncExitStack
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Check for MCP SDK availability
|
|
18
|
+
try:
|
|
19
|
+
from mcp import ClientSession, StdioServerParameters
|
|
20
|
+
from mcp.client.stdio import stdio_client
|
|
21
|
+
from mcp.client.sse import sse_client
|
|
22
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
23
|
+
import mcp.types as mcp_types
|
|
24
|
+
|
|
25
|
+
MCP_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
MCP_AVAILABLE = False
|
|
28
|
+
ClientSession = None
|
|
29
|
+
StdioServerParameters = None
|
|
30
|
+
stdio_client = None
|
|
31
|
+
sse_client = None
|
|
32
|
+
streamable_http_client = None
|
|
33
|
+
mcp_types = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class McpTransport(str, Enum):
|
|
37
|
+
"""Transport type for connecting to MCP servers."""
|
|
38
|
+
|
|
39
|
+
STDIO = "stdio"
|
|
40
|
+
SSE = "sse"
|
|
41
|
+
STREAMABLE_HTTP = "streamable_http"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class McpToolDef:
|
|
46
|
+
"""Definition of a tool from an MCP server."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
description: str
|
|
50
|
+
parameters: Dict[str, Any] # JSON Schema
|
|
51
|
+
output_schema: Optional[Dict[str, Any]] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class McpServerConfig:
|
|
56
|
+
"""Configuration for connecting to an MCP server."""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
transport: McpTransport = McpTransport.STDIO
|
|
60
|
+
|
|
61
|
+
# For stdio transport
|
|
62
|
+
command: Optional[str] = None
|
|
63
|
+
args: List[str] = field(default_factory=list)
|
|
64
|
+
env: Optional[Dict[str, str]] = None
|
|
65
|
+
cwd: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
# For SSE/HTTP transport
|
|
68
|
+
url: Optional[str] = None
|
|
69
|
+
headers: Optional[Dict[str, str]] = None
|
|
70
|
+
|
|
71
|
+
# Tool naming
|
|
72
|
+
prefix: Optional[str] = None # Prefix for tool names (e.g. "mcp_" -> "mcp_read_file")
|
|
73
|
+
|
|
74
|
+
# Optional: only expose specific tools
|
|
75
|
+
tools: Optional[List[str]] = None # If set, only these tools are registered
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_mcp_available() -> None:
|
|
79
|
+
"""Raise ImportError if MCP SDK is not installed."""
|
|
80
|
+
if not MCP_AVAILABLE:
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"MCP SDK not installed. Install with: pip install smartify[mcp]"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class McpClient:
|
|
87
|
+
"""Client for connecting to and interacting with an MCP server.
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
config = McpServerConfig(
|
|
91
|
+
id="filesystem",
|
|
92
|
+
transport=McpTransport.STDIO,
|
|
93
|
+
command="npx",
|
|
94
|
+
args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async with McpClient(config) as client:
|
|
98
|
+
tools = await client.list_tools()
|
|
99
|
+
result = await client.call_tool("read_file", {"path": "/tmp/test.txt"})
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, config: McpServerConfig):
|
|
103
|
+
_check_mcp_available()
|
|
104
|
+
self.config = config
|
|
105
|
+
self._exit_stack: Optional[AsyncExitStack] = None
|
|
106
|
+
self._session: Optional[ClientSession] = None
|
|
107
|
+
self._connected = False
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> "McpClient":
|
|
110
|
+
await self.connect()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
114
|
+
await self.disconnect()
|
|
115
|
+
|
|
116
|
+
async def connect(self) -> None:
|
|
117
|
+
"""Connect to the MCP server."""
|
|
118
|
+
if self._connected:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
_check_mcp_available()
|
|
122
|
+
|
|
123
|
+
self._exit_stack = AsyncExitStack()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if self.config.transport == McpTransport.STDIO:
|
|
127
|
+
await self._connect_stdio()
|
|
128
|
+
elif self.config.transport == McpTransport.SSE:
|
|
129
|
+
await self._connect_sse()
|
|
130
|
+
elif self.config.transport == McpTransport.STREAMABLE_HTTP:
|
|
131
|
+
await self._connect_streamable_http()
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unknown transport: {self.config.transport}")
|
|
134
|
+
|
|
135
|
+
# Initialize the session
|
|
136
|
+
await self._session.initialize()
|
|
137
|
+
self._connected = True
|
|
138
|
+
|
|
139
|
+
logger.info(
|
|
140
|
+
f"Connected to MCP server '{self.config.id}' via {self.config.transport.value}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
# Clean up on connection failure
|
|
145
|
+
if self._exit_stack:
|
|
146
|
+
await self._exit_stack.aclose()
|
|
147
|
+
self._exit_stack = None
|
|
148
|
+
raise ConnectionError(
|
|
149
|
+
f"Failed to connect to MCP server '{self.config.id}': {e}"
|
|
150
|
+
) from e
|
|
151
|
+
|
|
152
|
+
async def _connect_stdio(self) -> None:
|
|
153
|
+
"""Connect via stdio transport."""
|
|
154
|
+
if not self.config.command:
|
|
155
|
+
raise ValueError("stdio transport requires 'command' in config")
|
|
156
|
+
|
|
157
|
+
server_params = StdioServerParameters(
|
|
158
|
+
command=self.config.command,
|
|
159
|
+
args=self.config.args,
|
|
160
|
+
env=self.config.env,
|
|
161
|
+
cwd=self.config.cwd,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
transport = await self._exit_stack.enter_async_context(
|
|
165
|
+
stdio_client(server_params)
|
|
166
|
+
)
|
|
167
|
+
read_stream, write_stream = transport
|
|
168
|
+
|
|
169
|
+
self._session = await self._exit_stack.enter_async_context(
|
|
170
|
+
ClientSession(read_stream, write_stream)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def _connect_sse(self) -> None:
|
|
174
|
+
"""Connect via SSE transport."""
|
|
175
|
+
if not self.config.url:
|
|
176
|
+
raise ValueError("SSE transport requires 'url' in config")
|
|
177
|
+
|
|
178
|
+
transport = await self._exit_stack.enter_async_context(
|
|
179
|
+
sse_client(url=self.config.url, headers=self.config.headers)
|
|
180
|
+
)
|
|
181
|
+
read_stream, write_stream = transport
|
|
182
|
+
|
|
183
|
+
self._session = await self._exit_stack.enter_async_context(
|
|
184
|
+
ClientSession(read_stream, write_stream)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def _connect_streamable_http(self) -> None:
|
|
188
|
+
"""Connect via streamable HTTP transport."""
|
|
189
|
+
if not self.config.url:
|
|
190
|
+
raise ValueError("streamable_http transport requires 'url' in config")
|
|
191
|
+
|
|
192
|
+
transport = await self._exit_stack.enter_async_context(
|
|
193
|
+
streamable_http_client(url=self.config.url)
|
|
194
|
+
)
|
|
195
|
+
read_stream, write_stream, _ = transport
|
|
196
|
+
|
|
197
|
+
self._session = await self._exit_stack.enter_async_context(
|
|
198
|
+
ClientSession(read_stream, write_stream)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def disconnect(self) -> None:
|
|
202
|
+
"""Disconnect from the MCP server."""
|
|
203
|
+
if self._exit_stack:
|
|
204
|
+
await self._exit_stack.aclose()
|
|
205
|
+
self._exit_stack = None
|
|
206
|
+
self._session = None
|
|
207
|
+
self._connected = False
|
|
208
|
+
logger.debug(f"Disconnected from MCP server '{self.config.id}'")
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def is_connected(self) -> bool:
|
|
212
|
+
"""Check if connected to the server."""
|
|
213
|
+
return self._connected and self._session is not None
|
|
214
|
+
|
|
215
|
+
async def list_tools(self) -> List[McpToolDef]:
|
|
216
|
+
"""List available tools from the MCP server.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of tool definitions with name, description, and parameter schema.
|
|
220
|
+
"""
|
|
221
|
+
if not self.is_connected:
|
|
222
|
+
raise RuntimeError("Not connected to MCP server")
|
|
223
|
+
|
|
224
|
+
result = await self._session.list_tools()
|
|
225
|
+
|
|
226
|
+
tools = []
|
|
227
|
+
for tool in result.tools:
|
|
228
|
+
# Filter if specific tools are requested
|
|
229
|
+
if self.config.tools and tool.name not in self.config.tools:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
tools.append(
|
|
233
|
+
McpToolDef(
|
|
234
|
+
name=tool.name,
|
|
235
|
+
description=tool.description or "",
|
|
236
|
+
parameters=tool.inputSchema or {"type": "object", "properties": {}},
|
|
237
|
+
output_schema=tool.outputSchema,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
logger.debug(
|
|
242
|
+
f"Listed {len(tools)} tools from MCP server '{self.config.id}': "
|
|
243
|
+
f"{[t.name for t in tools]}"
|
|
244
|
+
)
|
|
245
|
+
return tools
|
|
246
|
+
|
|
247
|
+
async def call_tool(
|
|
248
|
+
self, name: str, arguments: Optional[Dict[str, Any]] = None
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Call a tool on the MCP server.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
name: Tool name (without prefix)
|
|
254
|
+
arguments: Tool arguments
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Dict with 'success', 'output', and optionally 'error' keys.
|
|
258
|
+
"""
|
|
259
|
+
if not self.is_connected:
|
|
260
|
+
raise RuntimeError("Not connected to MCP server")
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
result = await self._session.call_tool(name, arguments or {})
|
|
264
|
+
|
|
265
|
+
# Process the result
|
|
266
|
+
if result.isError:
|
|
267
|
+
# Extract error message from content
|
|
268
|
+
error_msg = self._extract_text_content(result.content)
|
|
269
|
+
return {
|
|
270
|
+
"success": False,
|
|
271
|
+
"output": None,
|
|
272
|
+
"error": error_msg or "Tool execution failed",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Extract output from content
|
|
276
|
+
output = self._extract_content(result)
|
|
277
|
+
return {
|
|
278
|
+
"success": True,
|
|
279
|
+
"output": output,
|
|
280
|
+
"error": None,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"MCP tool call failed: {name} - {e}")
|
|
285
|
+
return {
|
|
286
|
+
"success": False,
|
|
287
|
+
"output": None,
|
|
288
|
+
"error": str(e),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def _extract_text_content(self, content: List) -> Optional[str]:
|
|
292
|
+
"""Extract text from MCP content list."""
|
|
293
|
+
if not content:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
texts = []
|
|
297
|
+
for item in content:
|
|
298
|
+
if hasattr(item, "text"):
|
|
299
|
+
texts.append(item.text)
|
|
300
|
+
elif hasattr(item, "type") and item.type == "text":
|
|
301
|
+
texts.append(getattr(item, "text", str(item)))
|
|
302
|
+
|
|
303
|
+
return "\n".join(texts) if texts else None
|
|
304
|
+
|
|
305
|
+
def _extract_content(self, result) -> Any:
|
|
306
|
+
"""Extract output from MCP CallToolResult."""
|
|
307
|
+
# If there's structured content, prefer that
|
|
308
|
+
if hasattr(result, "structuredContent") and result.structuredContent:
|
|
309
|
+
return result.structuredContent
|
|
310
|
+
|
|
311
|
+
# Otherwise extract from content list
|
|
312
|
+
if not result.content:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
# Single text item -> return as string
|
|
316
|
+
if len(result.content) == 1:
|
|
317
|
+
item = result.content[0]
|
|
318
|
+
if hasattr(item, "text"):
|
|
319
|
+
return item.text
|
|
320
|
+
elif hasattr(item, "data"):
|
|
321
|
+
# Binary/image content
|
|
322
|
+
return {"type": getattr(item, "type", "blob"), "data": item.data}
|
|
323
|
+
|
|
324
|
+
# Multiple items -> return as list
|
|
325
|
+
outputs = []
|
|
326
|
+
for item in result.content:
|
|
327
|
+
if hasattr(item, "text"):
|
|
328
|
+
outputs.append(item.text)
|
|
329
|
+
elif hasattr(item, "data"):
|
|
330
|
+
outputs.append({"type": getattr(item, "type", "blob"), "data": item.data})
|
|
331
|
+
else:
|
|
332
|
+
outputs.append(str(item))
|
|
333
|
+
|
|
334
|
+
return outputs
|