ouroboros-ai 0.2.3__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of ouroboros-ai might be problematic. Click here for more details.
- ouroboros/__init__.py +1 -1
- ouroboros/bigbang/__init__.py +9 -0
- ouroboros/bigbang/interview.py +16 -18
- ouroboros/bigbang/ontology.py +180 -0
- ouroboros/cli/commands/__init__.py +2 -0
- ouroboros/cli/commands/init.py +162 -97
- ouroboros/cli/commands/mcp.py +161 -0
- ouroboros/cli/commands/run.py +165 -27
- ouroboros/cli/main.py +2 -1
- ouroboros/core/ontology_aspect.py +455 -0
- ouroboros/core/ontology_questions.py +462 -0
- ouroboros/evaluation/__init__.py +16 -1
- ouroboros/evaluation/consensus.py +569 -11
- ouroboros/evaluation/models.py +81 -0
- ouroboros/events/ontology.py +135 -0
- ouroboros/mcp/__init__.py +83 -0
- ouroboros/mcp/client/__init__.py +20 -0
- ouroboros/mcp/client/adapter.py +632 -0
- ouroboros/mcp/client/manager.py +600 -0
- ouroboros/mcp/client/protocol.py +161 -0
- ouroboros/mcp/errors.py +377 -0
- ouroboros/mcp/resources/__init__.py +22 -0
- ouroboros/mcp/resources/handlers.py +328 -0
- ouroboros/mcp/server/__init__.py +21 -0
- ouroboros/mcp/server/adapter.py +408 -0
- ouroboros/mcp/server/protocol.py +291 -0
- ouroboros/mcp/server/security.py +636 -0
- ouroboros/mcp/tools/__init__.py +24 -0
- ouroboros/mcp/tools/definitions.py +351 -0
- ouroboros/mcp/tools/registry.py +269 -0
- ouroboros/mcp/types.py +333 -0
- ouroboros/orchestrator/__init__.py +31 -0
- ouroboros/orchestrator/events.py +40 -0
- ouroboros/orchestrator/mcp_config.py +419 -0
- ouroboros/orchestrator/mcp_tools.py +483 -0
- ouroboros/orchestrator/runner.py +119 -2
- ouroboros/providers/claude_code_adapter.py +75 -0
- ouroboros/strategies/__init__.py +23 -0
- ouroboros/strategies/devil_advocate.py +197 -0
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/METADATA +73 -17
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/RECORD +44 -19
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/WHEEL +0 -0
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/entry_points.txt +0 -0
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""MCP Client adapter implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the MCPClientAdapter class that implements the MCPClient
|
|
4
|
+
protocol using the MCP SDK. It handles connection management, retries, and
|
|
5
|
+
error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Sequence
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
import stamina
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from ouroboros.core.types import Result
|
|
16
|
+
from ouroboros.mcp.errors import (
|
|
17
|
+
MCPClientError,
|
|
18
|
+
MCPConnectionError,
|
|
19
|
+
MCPTimeoutError,
|
|
20
|
+
)
|
|
21
|
+
from ouroboros.mcp.types import (
|
|
22
|
+
ContentType,
|
|
23
|
+
MCPCapabilities,
|
|
24
|
+
MCPContentItem,
|
|
25
|
+
MCPPromptArgument,
|
|
26
|
+
MCPPromptDefinition,
|
|
27
|
+
MCPResourceContent,
|
|
28
|
+
MCPResourceDefinition,
|
|
29
|
+
MCPServerConfig,
|
|
30
|
+
MCPServerInfo,
|
|
31
|
+
MCPToolDefinition,
|
|
32
|
+
MCPToolParameter,
|
|
33
|
+
MCPToolResult,
|
|
34
|
+
ToolInputType,
|
|
35
|
+
TransportType,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
log = structlog.get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
# Exceptions that are safe to retry
|
|
41
|
+
RETRIABLE_EXCEPTIONS = (
|
|
42
|
+
TimeoutError,
|
|
43
|
+
ConnectionError,
|
|
44
|
+
OSError,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MCPClientAdapter:
|
|
49
|
+
"""Concrete implementation of MCPClient protocol.
|
|
50
|
+
|
|
51
|
+
Uses the MCP SDK to connect to MCP servers and provides automatic retry
|
|
52
|
+
logic using stamina for transient failures.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
config = MCPServerConfig(
|
|
56
|
+
name="my-server",
|
|
57
|
+
transport=TransportType.STDIO,
|
|
58
|
+
command="my-mcp-server",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async with MCPClientAdapter() as client:
|
|
62
|
+
result = await client.connect(config)
|
|
63
|
+
if result.is_ok:
|
|
64
|
+
tools = await client.list_tools()
|
|
65
|
+
|
|
66
|
+
# Or use as regular async context manager
|
|
67
|
+
adapter = MCPClientAdapter()
|
|
68
|
+
await adapter.__aenter__()
|
|
69
|
+
try:
|
|
70
|
+
await adapter.connect(config)
|
|
71
|
+
finally:
|
|
72
|
+
await adapter.__aexit__(None, None, None)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
max_retries: int = 3,
|
|
79
|
+
retry_wait_initial: float = 1.0,
|
|
80
|
+
retry_wait_max: float = 10.0,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Initialize the adapter.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
max_retries: Maximum number of retry attempts for transient failures.
|
|
86
|
+
retry_wait_initial: Initial wait time between retries in seconds.
|
|
87
|
+
retry_wait_max: Maximum wait time between retries in seconds.
|
|
88
|
+
"""
|
|
89
|
+
self._max_retries = max_retries
|
|
90
|
+
self._retry_wait_initial = retry_wait_initial
|
|
91
|
+
self._retry_wait_max = retry_wait_max
|
|
92
|
+
self._session: Any = None
|
|
93
|
+
self._read_stream: Any = None
|
|
94
|
+
self._write_stream: Any = None
|
|
95
|
+
self._server_info: MCPServerInfo | None = None
|
|
96
|
+
self._config: MCPServerConfig | None = None
|
|
97
|
+
|
|
98
|
+
async def __aenter__(self) -> Self:
|
|
99
|
+
"""Enter async context manager."""
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def __aexit__(
|
|
103
|
+
self,
|
|
104
|
+
exc_type: type[BaseException] | None,
|
|
105
|
+
exc_val: BaseException | None,
|
|
106
|
+
exc_tb: Any,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Exit async context manager, ensuring disconnect."""
|
|
109
|
+
await self.disconnect()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_connected(self) -> bool:
|
|
113
|
+
"""Return True if currently connected to a server."""
|
|
114
|
+
return self._session is not None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def server_info(self) -> MCPServerInfo | None:
|
|
118
|
+
"""Return information about the connected server."""
|
|
119
|
+
return self._server_info
|
|
120
|
+
|
|
121
|
+
async def connect(
|
|
122
|
+
self,
|
|
123
|
+
config: MCPServerConfig,
|
|
124
|
+
) -> Result[MCPServerInfo, MCPClientError]:
|
|
125
|
+
"""Connect to an MCP server.
|
|
126
|
+
|
|
127
|
+
Establishes a connection using the appropriate transport (stdio, SSE, etc.)
|
|
128
|
+
and initializes the session. Uses stamina for automatic retries.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config: Configuration for the server connection.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Result containing server info on success or MCPClientError on failure.
|
|
135
|
+
"""
|
|
136
|
+
if self._session is not None:
|
|
137
|
+
disconnect_result = await self.disconnect()
|
|
138
|
+
if disconnect_result.is_err:
|
|
139
|
+
log.warning(
|
|
140
|
+
"mcp.disconnect_before_connect_failed",
|
|
141
|
+
error=disconnect_result.error,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self._config = config
|
|
145
|
+
|
|
146
|
+
@stamina.retry(
|
|
147
|
+
on=RETRIABLE_EXCEPTIONS,
|
|
148
|
+
attempts=self._max_retries,
|
|
149
|
+
wait_initial=self._retry_wait_initial,
|
|
150
|
+
wait_max=self._retry_wait_max,
|
|
151
|
+
wait_jitter=1.0,
|
|
152
|
+
)
|
|
153
|
+
async def _connect_with_retry() -> None:
|
|
154
|
+
await self._raw_connect(config)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
await _connect_with_retry()
|
|
158
|
+
log.info(
|
|
159
|
+
"mcp.connected",
|
|
160
|
+
server=config.name,
|
|
161
|
+
transport=config.transport.value,
|
|
162
|
+
)
|
|
163
|
+
return Result.ok(self._server_info) # type: ignore[arg-type]
|
|
164
|
+
except TimeoutError as e:
|
|
165
|
+
timeout_error = MCPTimeoutError(
|
|
166
|
+
f"Connection timeout: {e}",
|
|
167
|
+
server_name=config.name,
|
|
168
|
+
timeout_seconds=config.timeout,
|
|
169
|
+
operation="connect",
|
|
170
|
+
)
|
|
171
|
+
timeout_error.__cause__ = e
|
|
172
|
+
return Result.err(timeout_error)
|
|
173
|
+
except ConnectionError as e:
|
|
174
|
+
conn_error = MCPConnectionError(
|
|
175
|
+
f"Connection failed: {e}",
|
|
176
|
+
server_name=config.name,
|
|
177
|
+
transport=config.transport.value,
|
|
178
|
+
)
|
|
179
|
+
conn_error.__cause__ = e
|
|
180
|
+
return Result.err(conn_error)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
client_error = MCPClientError.from_exception(
|
|
183
|
+
e,
|
|
184
|
+
server_name=config.name,
|
|
185
|
+
is_retriable=False,
|
|
186
|
+
)
|
|
187
|
+
return Result.err(client_error)
|
|
188
|
+
|
|
189
|
+
async def _raw_connect(self, config: MCPServerConfig) -> None:
|
|
190
|
+
"""Perform the actual connection without retry logic.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
config: Server configuration.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
Various exceptions depending on connection issues.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
from mcp import ClientSession, StdioServerParameters
|
|
200
|
+
from mcp.client.stdio import stdio_client
|
|
201
|
+
except ImportError as e:
|
|
202
|
+
msg = "mcp package not installed. Install with: pip install mcp"
|
|
203
|
+
raise ImportError(msg) from e
|
|
204
|
+
|
|
205
|
+
if config.transport == TransportType.STDIO:
|
|
206
|
+
if not config.command:
|
|
207
|
+
msg = "command is required for stdio transport"
|
|
208
|
+
raise ValueError(msg)
|
|
209
|
+
|
|
210
|
+
server_params = StdioServerParameters(
|
|
211
|
+
command=config.command,
|
|
212
|
+
args=list(config.args),
|
|
213
|
+
env=config.env if config.env else None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
self._read_stream, self._write_stream = await stdio_client(server_params).__aenter__()
|
|
217
|
+
self._session = ClientSession(self._read_stream, self._write_stream)
|
|
218
|
+
await self._session.__aenter__()
|
|
219
|
+
|
|
220
|
+
# Initialize the session
|
|
221
|
+
result = await self._session.initialize()
|
|
222
|
+
self._server_info = self._parse_server_info(result, config.name)
|
|
223
|
+
|
|
224
|
+
elif config.transport in (TransportType.SSE, TransportType.STREAMABLE_HTTP):
|
|
225
|
+
# SSE/HTTP transport would be implemented here
|
|
226
|
+
msg = f"Transport {config.transport} not yet implemented"
|
|
227
|
+
raise NotImplementedError(msg)
|
|
228
|
+
else:
|
|
229
|
+
msg = f"Unknown transport: {config.transport}"
|
|
230
|
+
raise ValueError(msg)
|
|
231
|
+
|
|
232
|
+
def _parse_server_info(self, init_result: Any, server_name: str) -> MCPServerInfo:
|
|
233
|
+
"""Parse server info from initialization result.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
init_result: Result from session.initialize().
|
|
237
|
+
server_name: Name of the server.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Parsed MCPServerInfo.
|
|
241
|
+
"""
|
|
242
|
+
capabilities = MCPCapabilities(
|
|
243
|
+
tools=getattr(init_result.capabilities, "tools", None) is not None,
|
|
244
|
+
resources=getattr(init_result.capabilities, "resources", None) is not None,
|
|
245
|
+
prompts=getattr(init_result.capabilities, "prompts", None) is not None,
|
|
246
|
+
logging=getattr(init_result.capabilities, "logging", None) is not None,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return MCPServerInfo(
|
|
250
|
+
name=server_name,
|
|
251
|
+
version=getattr(init_result, "protocolVersion", "1.0.0"),
|
|
252
|
+
capabilities=capabilities,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
async def disconnect(self) -> Result[None, MCPClientError]:
|
|
256
|
+
"""Disconnect from the current MCP server.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Result containing None on success or MCPClientError on failure.
|
|
260
|
+
"""
|
|
261
|
+
if self._session is None:
|
|
262
|
+
return Result.ok(None)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
await self._session.__aexit__(None, None, None)
|
|
266
|
+
self._session = None
|
|
267
|
+
self._server_info = None
|
|
268
|
+
log.info("mcp.disconnected", server=self._config.name if self._config else "unknown")
|
|
269
|
+
return Result.ok(None)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
return Result.err(
|
|
272
|
+
MCPClientError.from_exception(
|
|
273
|
+
e,
|
|
274
|
+
server_name=self._config.name if self._config else None,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _ensure_connected(self) -> Result[None, MCPClientError]:
|
|
279
|
+
"""Ensure we're connected to a server.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Result.ok(None) if connected, Result.err otherwise.
|
|
283
|
+
"""
|
|
284
|
+
if self._session is None:
|
|
285
|
+
return Result.err(
|
|
286
|
+
MCPConnectionError(
|
|
287
|
+
"Not connected to any server",
|
|
288
|
+
server_name=self._config.name if self._config else None,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
return Result.ok(None)
|
|
292
|
+
|
|
293
|
+
async def list_tools(self) -> Result[Sequence[MCPToolDefinition], MCPClientError]:
|
|
294
|
+
"""List available tools from the connected server.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Result containing sequence of tool definitions or MCPClientError.
|
|
298
|
+
"""
|
|
299
|
+
connected = self._ensure_connected()
|
|
300
|
+
if connected.is_err:
|
|
301
|
+
return Result.err(connected.error)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
result = await self._session.list_tools()
|
|
305
|
+
tools = tuple(
|
|
306
|
+
self._parse_tool_definition(tool, self._config.name if self._config else None)
|
|
307
|
+
for tool in result.tools
|
|
308
|
+
)
|
|
309
|
+
return Result.ok(tools)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
return Result.err(
|
|
312
|
+
MCPClientError.from_exception(
|
|
313
|
+
e,
|
|
314
|
+
server_name=self._config.name if self._config else None,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _parse_tool_definition(
|
|
319
|
+
self, tool: Any, server_name: str | None
|
|
320
|
+
) -> MCPToolDefinition:
|
|
321
|
+
"""Parse a tool definition from the MCP SDK format.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
tool: Tool object from MCP SDK.
|
|
325
|
+
server_name: Name of the server providing this tool.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Parsed MCPToolDefinition.
|
|
329
|
+
"""
|
|
330
|
+
parameters: list[MCPToolParameter] = []
|
|
331
|
+
|
|
332
|
+
if hasattr(tool, "inputSchema") and tool.inputSchema:
|
|
333
|
+
schema = tool.inputSchema
|
|
334
|
+
properties = schema.get("properties", {})
|
|
335
|
+
required = set(schema.get("required", []))
|
|
336
|
+
|
|
337
|
+
for name, prop in properties.items():
|
|
338
|
+
param_type = ToolInputType(prop.get("type", "string"))
|
|
339
|
+
parameters.append(
|
|
340
|
+
MCPToolParameter(
|
|
341
|
+
name=name,
|
|
342
|
+
type=param_type,
|
|
343
|
+
description=prop.get("description", ""),
|
|
344
|
+
required=name in required,
|
|
345
|
+
default=prop.get("default"),
|
|
346
|
+
enum=tuple(prop["enum"]) if "enum" in prop else None,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return MCPToolDefinition(
|
|
351
|
+
name=tool.name,
|
|
352
|
+
description=getattr(tool, "description", "") or "",
|
|
353
|
+
parameters=tuple(parameters),
|
|
354
|
+
server_name=server_name,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
async def call_tool(
|
|
358
|
+
self,
|
|
359
|
+
name: str,
|
|
360
|
+
arguments: dict[str, Any] | None = None,
|
|
361
|
+
) -> Result[MCPToolResult, MCPClientError]:
|
|
362
|
+
"""Call a tool on the connected server.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
name: Name of the tool to call.
|
|
366
|
+
arguments: Arguments to pass to the tool.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Result containing tool result or MCPClientError.
|
|
370
|
+
"""
|
|
371
|
+
connected = self._ensure_connected()
|
|
372
|
+
if connected.is_err:
|
|
373
|
+
return Result.err(connected.error)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
result = await self._session.call_tool(name, arguments or {})
|
|
377
|
+
return Result.ok(self._parse_tool_result(result, name))
|
|
378
|
+
except Exception as e:
|
|
379
|
+
error_msg = str(e).lower()
|
|
380
|
+
if "not found" in error_msg or "unknown tool" in error_msg:
|
|
381
|
+
return Result.err(
|
|
382
|
+
MCPClientError(
|
|
383
|
+
f"Tool not found: {name}",
|
|
384
|
+
server_name=self._config.name if self._config else None,
|
|
385
|
+
is_retriable=False,
|
|
386
|
+
details={"resource_type": "tool", "resource_id": name},
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
return Result.err(
|
|
390
|
+
MCPClientError(
|
|
391
|
+
f"Tool execution failed: {e}",
|
|
392
|
+
server_name=self._config.name if self._config else None,
|
|
393
|
+
is_retriable=False,
|
|
394
|
+
details={"tool_name": name},
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _parse_tool_result(self, result: Any, _tool_name: str) -> MCPToolResult:
|
|
399
|
+
"""Parse a tool result from the MCP SDK format.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
result: Result object from MCP SDK.
|
|
403
|
+
tool_name: Name of the tool that was called.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Parsed MCPToolResult.
|
|
407
|
+
"""
|
|
408
|
+
content_items: list[MCPContentItem] = []
|
|
409
|
+
|
|
410
|
+
for item in getattr(result, "content", []):
|
|
411
|
+
if hasattr(item, "text"):
|
|
412
|
+
content_items.append(
|
|
413
|
+
MCPContentItem(type=ContentType.TEXT, text=item.text)
|
|
414
|
+
)
|
|
415
|
+
elif hasattr(item, "data"):
|
|
416
|
+
content_items.append(
|
|
417
|
+
MCPContentItem(
|
|
418
|
+
type=ContentType.IMAGE,
|
|
419
|
+
data=item.data,
|
|
420
|
+
mime_type=getattr(item, "mimeType", "image/png"),
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
elif hasattr(item, "uri"):
|
|
424
|
+
content_items.append(
|
|
425
|
+
MCPContentItem(type=ContentType.RESOURCE, uri=item.uri)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return MCPToolResult(
|
|
429
|
+
content=tuple(content_items),
|
|
430
|
+
is_error=getattr(result, "isError", False),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def list_resources(self) -> Result[Sequence[MCPResourceDefinition], MCPClientError]:
|
|
434
|
+
"""List available resources from the connected server.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Result containing sequence of resource definitions or MCPClientError.
|
|
438
|
+
"""
|
|
439
|
+
connected = self._ensure_connected()
|
|
440
|
+
if connected.is_err:
|
|
441
|
+
return Result.err(connected.error)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
result = await self._session.list_resources()
|
|
445
|
+
resources = tuple(
|
|
446
|
+
MCPResourceDefinition(
|
|
447
|
+
uri=res.uri,
|
|
448
|
+
name=getattr(res, "name", res.uri),
|
|
449
|
+
description=getattr(res, "description", "") or "",
|
|
450
|
+
mime_type=getattr(res, "mimeType", "text/plain"),
|
|
451
|
+
)
|
|
452
|
+
for res in result.resources
|
|
453
|
+
)
|
|
454
|
+
return Result.ok(resources)
|
|
455
|
+
except Exception as e:
|
|
456
|
+
return Result.err(
|
|
457
|
+
MCPClientError.from_exception(
|
|
458
|
+
e,
|
|
459
|
+
server_name=self._config.name if self._config else None,
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def read_resource(
|
|
464
|
+
self,
|
|
465
|
+
uri: str,
|
|
466
|
+
) -> Result[MCPResourceContent, MCPClientError]:
|
|
467
|
+
"""Read a resource from the connected server.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
uri: URI of the resource to read.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Result containing resource content or MCPClientError.
|
|
474
|
+
"""
|
|
475
|
+
connected = self._ensure_connected()
|
|
476
|
+
if connected.is_err:
|
|
477
|
+
return Result.err(connected.error)
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
result = await self._session.read_resource(uri)
|
|
481
|
+
contents = result.contents
|
|
482
|
+
|
|
483
|
+
if not contents:
|
|
484
|
+
return Result.err(
|
|
485
|
+
MCPClientError(
|
|
486
|
+
f"Resource not found: {uri}",
|
|
487
|
+
server_name=self._config.name if self._config else None,
|
|
488
|
+
is_retriable=False,
|
|
489
|
+
details={"resource_type": "resource", "resource_id": uri},
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
first_content = contents[0]
|
|
494
|
+
return Result.ok(
|
|
495
|
+
MCPResourceContent(
|
|
496
|
+
uri=uri,
|
|
497
|
+
text=getattr(first_content, "text", None),
|
|
498
|
+
blob=getattr(first_content, "blob", None),
|
|
499
|
+
mime_type=getattr(first_content, "mimeType", "text/plain"),
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
except Exception as e:
|
|
503
|
+
error_msg = str(e).lower()
|
|
504
|
+
if "not found" in error_msg:
|
|
505
|
+
return Result.err(
|
|
506
|
+
MCPClientError(
|
|
507
|
+
f"Resource not found: {uri}",
|
|
508
|
+
server_name=self._config.name if self._config else None,
|
|
509
|
+
is_retriable=False,
|
|
510
|
+
details={"resource_type": "resource", "resource_id": uri},
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
return Result.err(
|
|
514
|
+
MCPClientError.from_exception(
|
|
515
|
+
e,
|
|
516
|
+
server_name=self._config.name if self._config else None,
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
async def list_prompts(self) -> Result[Sequence[MCPPromptDefinition], MCPClientError]:
|
|
521
|
+
"""List available prompts from the connected server.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Result containing sequence of prompt definitions or MCPClientError.
|
|
525
|
+
"""
|
|
526
|
+
connected = self._ensure_connected()
|
|
527
|
+
if connected.is_err:
|
|
528
|
+
return Result.err(connected.error)
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
result = await self._session.list_prompts()
|
|
532
|
+
prompts = tuple(
|
|
533
|
+
MCPPromptDefinition(
|
|
534
|
+
name=prompt.name,
|
|
535
|
+
description=getattr(prompt, "description", "") or "",
|
|
536
|
+
arguments=tuple(
|
|
537
|
+
MCPPromptArgument(
|
|
538
|
+
name=arg.name,
|
|
539
|
+
description=getattr(arg, "description", "") or "",
|
|
540
|
+
required=getattr(arg, "required", True),
|
|
541
|
+
)
|
|
542
|
+
for arg in getattr(prompt, "arguments", [])
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
for prompt in result.prompts
|
|
546
|
+
)
|
|
547
|
+
return Result.ok(prompts)
|
|
548
|
+
except Exception as e:
|
|
549
|
+
return Result.err(
|
|
550
|
+
MCPClientError.from_exception(
|
|
551
|
+
e,
|
|
552
|
+
server_name=self._config.name if self._config else None,
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
async def get_prompt(
|
|
557
|
+
self,
|
|
558
|
+
name: str,
|
|
559
|
+
arguments: dict[str, str] | None = None,
|
|
560
|
+
) -> Result[str, MCPClientError]:
|
|
561
|
+
"""Get a prompt from the connected server.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
name: Name of the prompt to get.
|
|
565
|
+
arguments: Arguments to fill in the prompt template.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
Result containing the prompt text or MCPClientError.
|
|
569
|
+
"""
|
|
570
|
+
connected = self._ensure_connected()
|
|
571
|
+
if connected.is_err:
|
|
572
|
+
return Result.err(connected.error)
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
result = await self._session.get_prompt(name, arguments or {})
|
|
576
|
+
# Combine all text messages into a single prompt
|
|
577
|
+
texts = [
|
|
578
|
+
msg.content.text
|
|
579
|
+
for msg in result.messages
|
|
580
|
+
if hasattr(msg.content, "text")
|
|
581
|
+
]
|
|
582
|
+
return Result.ok("\n".join(texts))
|
|
583
|
+
except Exception as e:
|
|
584
|
+
error_msg = str(e).lower()
|
|
585
|
+
if "not found" in error_msg:
|
|
586
|
+
return Result.err(
|
|
587
|
+
MCPClientError(
|
|
588
|
+
f"Prompt not found: {name}",
|
|
589
|
+
server_name=self._config.name if self._config else None,
|
|
590
|
+
is_retriable=False,
|
|
591
|
+
details={"resource_type": "prompt", "resource_id": name},
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
return Result.err(
|
|
595
|
+
MCPClientError.from_exception(
|
|
596
|
+
e,
|
|
597
|
+
server_name=self._config.name if self._config else None,
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@asynccontextmanager
|
|
603
|
+
async def create_mcp_client(
|
|
604
|
+
config: MCPServerConfig,
|
|
605
|
+
*,
|
|
606
|
+
max_retries: int = 3,
|
|
607
|
+
) -> AsyncIterator[MCPClientAdapter]:
|
|
608
|
+
"""Create and connect an MCP client as an async context manager.
|
|
609
|
+
|
|
610
|
+
Convenience function that creates an MCPClientAdapter, connects to
|
|
611
|
+
the specified server, and yields the connected client.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
config: Configuration for the server connection.
|
|
615
|
+
max_retries: Maximum number of retry attempts.
|
|
616
|
+
|
|
617
|
+
Yields:
|
|
618
|
+
Connected MCPClientAdapter.
|
|
619
|
+
|
|
620
|
+
Raises:
|
|
621
|
+
MCPConnectionError: If connection fails after all retries.
|
|
622
|
+
|
|
623
|
+
Example:
|
|
624
|
+
async with create_mcp_client(config) as client:
|
|
625
|
+
result = await client.list_tools()
|
|
626
|
+
"""
|
|
627
|
+
adapter = MCPClientAdapter(max_retries=max_retries)
|
|
628
|
+
async with adapter:
|
|
629
|
+
result = await adapter.connect(config)
|
|
630
|
+
if result.is_err:
|
|
631
|
+
raise result.error
|
|
632
|
+
yield adapter
|