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.
@@ -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,3 @@
1
+ # mcp
2
+
3
+ support for mcps
@@ -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