rusticai-mcp 1.1.0__tar.gz
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.
- rusticai_mcp-1.1.0/PKG-INFO +22 -0
- rusticai_mcp-1.1.0/README.md +3 -0
- rusticai_mcp-1.1.0/pyproject.toml +75 -0
- rusticai_mcp-1.1.0/src/rustic_ai/mcp/__init__.py +0 -0
- rusticai_mcp-1.1.0/src/rustic_ai/mcp/agent.py +69 -0
- rusticai_mcp-1.1.0/src/rustic_ai/mcp/client.py +183 -0
- rusticai_mcp-1.1.0/src/rustic_ai/mcp/models.py +46 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rusticai-mcp
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: support for mcps
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Author: Dragonscale Industries Inc.
|
|
7
|
+
Author-email: dev@dragonscale.ai
|
|
8
|
+
Requires-Python: >=3.13,<3.14
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: mcp (>=1.0.0,<2.0.0)
|
|
13
|
+
Requires-Dist: rusticai-core (>=1.1.0,<1.2.0)
|
|
14
|
+
Project-URL: Homepage, https://www.rustic.ai/
|
|
15
|
+
Project-URL: Repository, https://github.com/dragonscale-ai/rustic-ai
|
|
16
|
+
Project-URL: Rustic AI Core, https://pypi.org/project/rusticai-core/
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# mcp
|
|
20
|
+
|
|
21
|
+
support for mcps
|
|
22
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "rusticai-mcp"
|
|
8
|
+
version = "1.1.0"
|
|
9
|
+
description = "support for mcps"
|
|
10
|
+
authors = [{name = "Dragonscale Industries Inc.", email = "dev@dragonscale.ai"}]
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.13,<3.14"
|
|
14
|
+
urls = { Homepage = "https://www.rustic.ai/", Repository = "https://github.com/dragonscale-ai/rustic-ai", "Rustic AI Core" = "https://pypi.org/project/rusticai-core/" }
|
|
15
|
+
dynamic = ["dependencies"]
|
|
16
|
+
|
|
17
|
+
[tool.poetry]
|
|
18
|
+
packages = [{ include = "rustic_ai", from = "src" }]
|
|
19
|
+
|
|
20
|
+
[tool.poetry.dependencies]
|
|
21
|
+
rusticai-core = { version = "1.1.0"}
|
|
22
|
+
mcp = "^1.0.0"
|
|
23
|
+
|
|
24
|
+
[tool.poetry-monorepo.deps]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
[tool.poetry.group.dev]
|
|
28
|
+
optional = true
|
|
29
|
+
|
|
30
|
+
[tool.poetry.group.dev.dependencies]
|
|
31
|
+
pytest = "^8.3.4"
|
|
32
|
+
black = { version = "^24.2.0", extras = ["jupyter"] }
|
|
33
|
+
flake8 = "^7.1.2"
|
|
34
|
+
tox = "^4.24.1"
|
|
35
|
+
isort = "^6.0.0"
|
|
36
|
+
mypy = "^1.15.0"
|
|
37
|
+
flaky = "^3.8.1"
|
|
38
|
+
pytest-asyncio = "^0.26.0"
|
|
39
|
+
rusticai-testing = { version = "1.1.0"}
|
|
40
|
+
|
|
41
|
+
[tool.poetry.extras]
|
|
42
|
+
test = ["pytest"]
|
|
43
|
+
|
|
44
|
+
[tool.black]
|
|
45
|
+
line-length = 120
|
|
46
|
+
target-version = ['py313']
|
|
47
|
+
include = '\.pyi?$'
|
|
48
|
+
exclude = '''
|
|
49
|
+
/(
|
|
50
|
+
\.git
|
|
51
|
+
| \.mypy_cache
|
|
52
|
+
| \.tox
|
|
53
|
+
| \.venv
|
|
54
|
+
| _build
|
|
55
|
+
| buck-out
|
|
56
|
+
| build
|
|
57
|
+
| dist
|
|
58
|
+
)/
|
|
59
|
+
'''
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.13"
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
check_untyped_defs = true
|
|
65
|
+
plugins = "pydantic.mypy"
|
|
66
|
+
explicit_package_bases = true
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
[tool.isort]
|
|
70
|
+
profile = "black"
|
|
71
|
+
force_sort_within_sections = true # For the sorting order issues
|
|
72
|
+
lines_between_sections = 1 # For the newline issue
|
|
73
|
+
known_third_party = ["pydantic"]
|
|
74
|
+
known_first_party = ["rustic_ai.core"]
|
|
75
|
+
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from rustic_ai.core.agents.commons.message_formats import ErrorMessage
|
|
2
|
+
from rustic_ai.core.guild import agent
|
|
3
|
+
from rustic_ai.core.guild.agent import Agent, ProcessContext
|
|
4
|
+
|
|
5
|
+
from .client import MCPClient
|
|
6
|
+
from .models import (
|
|
7
|
+
CallToolRequest,
|
|
8
|
+
MCPAgentConfig,
|
|
9
|
+
MCPServerConfig,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MCPAgent(Agent[MCPAgentConfig]):
|
|
14
|
+
"""
|
|
15
|
+
Agent that connects to a single MCP server and exposes its capabilities.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._mcp_client: MCPClient = MCPClient(self.server_config)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def server_config(self) -> MCPServerConfig:
|
|
23
|
+
return self.config.server
|
|
24
|
+
|
|
25
|
+
def _ensure_client(self):
|
|
26
|
+
if not self._mcp_client:
|
|
27
|
+
self._mcp_client = MCPClient(self.server_config)
|
|
28
|
+
|
|
29
|
+
@agent.processor(CallToolRequest)
|
|
30
|
+
async def handle_tool_call(self, ctx: ProcessContext[CallToolRequest]):
|
|
31
|
+
request = ctx.payload
|
|
32
|
+
|
|
33
|
+
# Verify server name matches
|
|
34
|
+
if request.server_name != self.server_config.name:
|
|
35
|
+
self.logger.warning(
|
|
36
|
+
f"Received request for unknown server: {request.server_name}. "
|
|
37
|
+
f"This agent is connected to: {self.server_config.name}"
|
|
38
|
+
)
|
|
39
|
+
ctx.send_error(
|
|
40
|
+
ErrorMessage(
|
|
41
|
+
agent_type=self.get_qualified_class_name(),
|
|
42
|
+
error_type="UnsupportedMcpServer",
|
|
43
|
+
error_message=f"Unsupported mcp server {request.server_name}. This agent is connected to: {self.server_config.name}",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
self._ensure_client()
|
|
49
|
+
if not self._mcp_client:
|
|
50
|
+
ctx.send_error(
|
|
51
|
+
ErrorMessage(
|
|
52
|
+
agent_type=self.get_qualified_class_name(),
|
|
53
|
+
error_type="MCPClientNotFound",
|
|
54
|
+
error_message="MCP Client not found!",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
response = await self._mcp_client.call_tool(request)
|
|
60
|
+
if response.is_error:
|
|
61
|
+
ctx.send_error(
|
|
62
|
+
ErrorMessage(
|
|
63
|
+
agent_type=self.get_qualified_class_name(),
|
|
64
|
+
error_type="ErrorProcessingMCPRequest",
|
|
65
|
+
error_message=response.error,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
ctx.send(response)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
from mcp.client.sse import sse_client
|
|
9
|
+
from mcp.client.stdio import stdio_client
|
|
10
|
+
|
|
11
|
+
from .models import (
|
|
12
|
+
CallToolRequest,
|
|
13
|
+
CallToolResponse,
|
|
14
|
+
MCPClientType,
|
|
15
|
+
MCPServerConfig,
|
|
16
|
+
ToolResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_error_message(exc: Exception) -> str:
|
|
23
|
+
"""Extract a meaningful error message from an exception."""
|
|
24
|
+
# Check for HTTPStatusError in exception chain or ExceptionGroup
|
|
25
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
26
|
+
return f"HTTP {exc.response.status_code} {exc.response.reason_phrase} for {exc.request.url}"
|
|
27
|
+
|
|
28
|
+
return str(exc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MCPClient:
|
|
32
|
+
"""
|
|
33
|
+
Client for connecting to an MCP server and executing tools.
|
|
34
|
+
Manages the session in a background task to satisfy anyio/asyncio constraints.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: MCPServerConfig):
|
|
38
|
+
self.config = config
|
|
39
|
+
self._session: Optional[ClientSession] = None
|
|
40
|
+
self._task: Optional[asyncio.Task] = None
|
|
41
|
+
# Lazy-initialized asyncio primitives (created in the correct event loop when first accessed)
|
|
42
|
+
self._shutdown_event: Optional[asyncio.Event] = None
|
|
43
|
+
self._ready_event: Optional[asyncio.Event] = None
|
|
44
|
+
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
45
|
+
self._last_error: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
def _ensure_events(self):
|
|
48
|
+
"""
|
|
49
|
+
Ensures that asyncio Event objects are created in the current event loop.
|
|
50
|
+
Recreates them if the event loop has changed since they were last created.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
current_loop = asyncio.get_running_loop()
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
# No event loop running, cannot create events yet
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# If events don't exist or were created in a different loop, recreate them
|
|
59
|
+
if self._event_loop is None or self._event_loop != current_loop:
|
|
60
|
+
self._shutdown_event = asyncio.Event()
|
|
61
|
+
self._ready_event = asyncio.Event()
|
|
62
|
+
self._event_loop = current_loop
|
|
63
|
+
logger.debug(f"Created asyncio Events for MCP client {self.config.name} in event loop {id(current_loop)}")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def shutdown_event(self) -> asyncio.Event:
|
|
67
|
+
"""Lazy property for shutdown event."""
|
|
68
|
+
self._ensure_events()
|
|
69
|
+
return self._shutdown_event # type: ignore[return-value]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ready_event(self) -> asyncio.Event:
|
|
73
|
+
"""Lazy property for ready event."""
|
|
74
|
+
self._ensure_events()
|
|
75
|
+
return self._ready_event # type: ignore[return-value]
|
|
76
|
+
|
|
77
|
+
async def _run_session(self):
|
|
78
|
+
"""Background task to maintain the session."""
|
|
79
|
+
try:
|
|
80
|
+
transport_context = None
|
|
81
|
+
if self.config.type == MCPClientType.STDIO:
|
|
82
|
+
server_params = StdioServerParameters(
|
|
83
|
+
command=self.config.command,
|
|
84
|
+
args=self.config.args,
|
|
85
|
+
env=self.config.env,
|
|
86
|
+
)
|
|
87
|
+
transport_context = stdio_client(server_params)
|
|
88
|
+
elif self.config.type == MCPClientType.SSE:
|
|
89
|
+
if not self.config.url:
|
|
90
|
+
raise ValueError(f"URL is required for SSE connection: {self.config.name}")
|
|
91
|
+
transport_context = sse_client(url=self.config.url, headers=self.config.headers)
|
|
92
|
+
else:
|
|
93
|
+
logger.warning(f"Unsupported MCP client type: {self.config.type}")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
client_type = self.config.type
|
|
97
|
+
|
|
98
|
+
async with transport_context as (read, write):
|
|
99
|
+
async with ClientSession(read, write) as session:
|
|
100
|
+
await session.initialize()
|
|
101
|
+
|
|
102
|
+
# Verify connection by listing tools
|
|
103
|
+
result = await session.list_tools()
|
|
104
|
+
tool_names = [tool.name for tool in result.tools]
|
|
105
|
+
logger.info(f"Connected to {self.config.name} ({client_type.value}). Available tools: {tool_names}")
|
|
106
|
+
|
|
107
|
+
self._session = session
|
|
108
|
+
self.ready_event.set()
|
|
109
|
+
|
|
110
|
+
# Wait until shutdown is requested
|
|
111
|
+
await self.shutdown_event.wait()
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if not self.shutdown_event.is_set():
|
|
115
|
+
detailed_error = _extract_error_message(e)
|
|
116
|
+
error_msg = f"MCP session error for {self.config.name}: {detailed_error}"
|
|
117
|
+
self._last_error = error_msg
|
|
118
|
+
logger.error(error_msg, exc_info=True)
|
|
119
|
+
finally:
|
|
120
|
+
self._session = None
|
|
121
|
+
if self._ready_event: # Only clear if event was created
|
|
122
|
+
self._ready_event.clear()
|
|
123
|
+
|
|
124
|
+
async def connect(self) -> Optional[ClientSession]:
|
|
125
|
+
"""Ensures connection to the server exists."""
|
|
126
|
+
if self._session:
|
|
127
|
+
return self._session
|
|
128
|
+
|
|
129
|
+
if not self._task or self._task.done():
|
|
130
|
+
# Ensure events are created in the current event loop
|
|
131
|
+
self._ensure_events()
|
|
132
|
+
self.shutdown_event.clear()
|
|
133
|
+
self._last_error = None # Clear previous errors
|
|
134
|
+
self._task = asyncio.create_task(self._run_session())
|
|
135
|
+
|
|
136
|
+
# Wait for session to be ready
|
|
137
|
+
try:
|
|
138
|
+
await asyncio.wait_for(self.ready_event.wait(), timeout=10.0)
|
|
139
|
+
except asyncio.TimeoutError:
|
|
140
|
+
error_detail = f" Last error: {self._last_error}" if self._last_error else ""
|
|
141
|
+
logger.error(f"Timeout waiting for MCP connection to {self.config.name}.{error_detail}")
|
|
142
|
+
# Cleanup if timeout
|
|
143
|
+
self.shutdown_event.set()
|
|
144
|
+
if self._task:
|
|
145
|
+
try:
|
|
146
|
+
await self._task
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return self._session
|
|
152
|
+
|
|
153
|
+
async def call_tool(self, request: CallToolRequest) -> CallToolResponse:
|
|
154
|
+
session = await self.connect()
|
|
155
|
+
|
|
156
|
+
if not session:
|
|
157
|
+
error_msg = "mcp session not found!"
|
|
158
|
+
if self._last_error:
|
|
159
|
+
error_msg += f" Reason: {self._last_error}"
|
|
160
|
+
return CallToolResponse(results=[], is_error=True, error=error_msg)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = await session.call_tool(request.tool_name, arguments=request.arguments)
|
|
164
|
+
|
|
165
|
+
tool_results = []
|
|
166
|
+
for content in result.content:
|
|
167
|
+
if content.type == "text":
|
|
168
|
+
tool_results.append(ToolResult(type="text", content=content.text))
|
|
169
|
+
elif content.type == "image":
|
|
170
|
+
tool_results.append(ToolResult(type="image", content=content.data))
|
|
171
|
+
elif content.type == "resource":
|
|
172
|
+
tool_results.append(ToolResult(type="resource", content=content.resource.uri))
|
|
173
|
+
|
|
174
|
+
return CallToolResponse(results=tool_results)
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(
|
|
178
|
+
f"Error calling tool {request.tool_name} on {self.config.name}: {e}",
|
|
179
|
+
exc_info=True,
|
|
180
|
+
)
|
|
181
|
+
return CallToolResponse(
|
|
182
|
+
results=[], is_error=True, error=f"Error calling tool {request.tool_name} on {self.config.name}: {e}"
|
|
183
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Dict, List, Literal, Optional, Union
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from rustic_ai.core.guild.dsl import BaseAgentProps
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MCPClientType(str, Enum):
|
|
10
|
+
STDIO = "stdio"
|
|
11
|
+
SSE = "sse"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MCPServerConfig(BaseModel):
|
|
15
|
+
name: str = Field(description="Name of the MCP server")
|
|
16
|
+
type: MCPClientType = Field(default=MCPClientType.STDIO, description="Type of connection")
|
|
17
|
+
|
|
18
|
+
# Stdio config
|
|
19
|
+
command: Optional[str] = Field(None, description="Command to execute for stdio")
|
|
20
|
+
args: List[str] = Field(default_factory=list, description="Arguments for the command")
|
|
21
|
+
env: Dict[str, str] = Field(default_factory=dict, description="Environment variables")
|
|
22
|
+
|
|
23
|
+
# SSE config
|
|
24
|
+
url: Optional[str] = Field(None, description="URL for SSE connection")
|
|
25
|
+
headers: Dict[str, str] = Field(default_factory=dict, description="Headers for SSE connection")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MCPAgentConfig(BaseAgentProps):
|
|
29
|
+
server: MCPServerConfig = Field(description="MCP server configuration")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CallToolRequest(BaseModel):
|
|
33
|
+
server_name: str
|
|
34
|
+
tool_name: str
|
|
35
|
+
arguments: Dict = Field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolResult(BaseModel):
|
|
39
|
+
type: Literal["text", "image", "resource"]
|
|
40
|
+
content: Union[str, Dict]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CallToolResponse(BaseModel):
|
|
44
|
+
results: List[ToolResult]
|
|
45
|
+
is_error: bool = False
|
|
46
|
+
error: Optional[str] = None
|