strands-mcp-server 0.1.2__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.
@@ -0,0 +1,543 @@
1
+ """MCP Client Tool for Strands Agents.
2
+
3
+ Test and interact with MCP servers from within a Strands Agent. This tool provides
4
+ a complete MCP client implementation that can connect to any MCP server (including
5
+ servers created with the mcp_server tool) and use their exposed tools.
6
+
7
+ Key Features:
8
+ - **Multiple Transports**: HTTP (streamable), stdio, SSE
9
+ - **Connection Management**: Connect, disconnect, list connections
10
+ - **Tool Discovery**: List available tools from connected servers
11
+ - **Tool Execution**: Call tools on remote servers
12
+ - **Session Persistence**: Maintain connections across multiple operations
13
+
14
+ Example:
15
+ ```python
16
+ from strands import Agent
17
+ from tools.mcp_client import mcp_client
18
+ from tools.mcp_server import mcp_server
19
+
20
+ agent = Agent(tools=[mcp_client, mcp_server])
21
+
22
+ # Start a server
23
+ agent("start mcp server on port 8000")
24
+
25
+ # Connect to it
26
+ agent("connect to mcp server at http://localhost:8000/mcp as test-server")
27
+
28
+ # List its tools
29
+ agent("list tools from test-server")
30
+
31
+ # Call a tool
32
+ agent("call calculator tool on test-server with expression: 2 + 2")
33
+ ```
34
+
35
+ Architecture:
36
+ ```
37
+ ┌─────────────────────────────────────┐
38
+ │ Strands Agent (Client) │
39
+ │ ┌──────────────────────────────┐ │
40
+ │ │ mcp_client tool │ │
41
+ │ └──────────────────────────────┘ │
42
+ └─────────────────────────────────────┘
43
+
44
+ MCP Client Connection
45
+
46
+ ┌─────────────────────────────────────┐
47
+ │ Strands Agent (Server) │
48
+ │ ┌──────────────────────────────┐ │
49
+ │ │ mcp_server tool │ │
50
+ │ └──────────────────────────────┘ │
51
+ └─────────────────────────────────────┘
52
+ ```
53
+
54
+ References:
55
+ - MCP Specification: https://spec.modelcontextprotocol.io/
56
+ - MCP Client SDK: python-sdk/src/mcp/client/
57
+ """
58
+
59
+ import logging
60
+ from typing import Any, Optional
61
+
62
+ from strands import tool
63
+
64
+ # Global state for managing MCP connections
65
+ _client_connections: dict[str, Any] = {}
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ @tool
71
+ def mcp_client(
72
+ action: str,
73
+ connection_id: Optional[str] = None,
74
+ transport: Optional[str] = None,
75
+ server_url: Optional[str] = None,
76
+ command: Optional[str] = None,
77
+ args: Optional[list[str]] = None,
78
+ tool_name: Optional[str] = None,
79
+ tool_args: Optional[dict[str, Any]] = None,
80
+ ) -> dict[str, Any]:
81
+ """Test and interact with MCP servers.
82
+
83
+ This tool provides a complete MCP client implementation for testing and using
84
+ MCP servers from within a Strands Agent.
85
+
86
+ Args:
87
+ action: Action to perform - "connect", "disconnect", "list_tools", "call_tool", "list_connections"
88
+ connection_id: Unique identifier for this connection
89
+ transport: Transport type - "http", "stdio", or "sse"
90
+ server_url: URL for HTTP/SSE transport (e.g., "http://localhost:8000/mcp")
91
+ command: Command for stdio transport (e.g., "python")
92
+ args: Arguments for stdio command (e.g., ["mcp_server_stdio.py"])
93
+ tool_name: Name of tool to call (for call_tool action)
94
+ tool_args: Arguments to pass to tool (for call_tool action)
95
+
96
+ Returns:
97
+ Result dictionary with status and content
98
+
99
+ Examples:
100
+ # Connect to HTTP server
101
+ mcp_client(
102
+ action="connect",
103
+ connection_id="my-server",
104
+ transport="http",
105
+ server_url="http://localhost:8000/mcp"
106
+ )
107
+
108
+ # Connect to stdio server
109
+ mcp_client(
110
+ action="connect",
111
+ connection_id="stdio-server",
112
+ transport="stdio",
113
+ command="python",
114
+ args=["mcp_server_stdio.py"]
115
+ )
116
+
117
+ # List tools from connection
118
+ mcp_client(action="list_tools", connection_id="my-server")
119
+
120
+ # Call a tool
121
+ mcp_client(
122
+ action="call_tool",
123
+ connection_id="my-server",
124
+ tool_name="calculator",
125
+ tool_args={"expression": "2 + 2"}
126
+ )
127
+
128
+ # List all connections
129
+ mcp_client(action="list_connections")
130
+
131
+ # Disconnect
132
+ mcp_client(action="disconnect", connection_id="my-server")
133
+
134
+ Notes:
135
+ - stdio transport: Server must be launchable as subprocess
136
+ - HTTP transport: Server must be already running
137
+ - Connections are maintained in global state for reuse
138
+ """
139
+ if action == "connect":
140
+ return _connect(connection_id, transport, server_url, command, args)
141
+ elif action == "disconnect":
142
+ return _disconnect(connection_id)
143
+ elif action == "list_tools":
144
+ return _list_tools(connection_id)
145
+ elif action == "call_tool":
146
+ return _call_tool(connection_id, tool_name, tool_args)
147
+ elif action == "list_connections":
148
+ return _list_connections()
149
+ else:
150
+ return {
151
+ "status": "error",
152
+ "content": [
153
+ {
154
+ "text": f"❌ Unknown action: {action}\n\n"
155
+ "Available actions: connect, disconnect, list_tools, call_tool, list_connections"
156
+ }
157
+ ],
158
+ }
159
+
160
+
161
+ def _connect(
162
+ connection_id: Optional[str],
163
+ transport: Optional[str],
164
+ server_url: Optional[str],
165
+ command: Optional[str],
166
+ args: Optional[list[str]],
167
+ ) -> dict[str, Any]:
168
+ """Connect to an MCP server.
169
+
170
+ This function establishes a connection to an MCP server using the specified
171
+ transport type. The connection is stored in global state for reuse.
172
+
173
+ Follows patterns from:
174
+ - Strands MCPClient: sdk-python/src/strands/tools/mcp/mcp_client.py
175
+ - MCP client transports: python-sdk/src/mcp/client/
176
+ """
177
+ try:
178
+ if not connection_id:
179
+ return {
180
+ "status": "error",
181
+ "content": [{"text": "❌ connection_id is required"}],
182
+ }
183
+
184
+ if not transport:
185
+ return {
186
+ "status": "error",
187
+ "content": [{"text": "❌ transport is required (http, stdio, or sse)"}],
188
+ }
189
+
190
+ if connection_id in _client_connections:
191
+ return {
192
+ "status": "error",
193
+ "content": [{"text": f"❌ Connection '{connection_id}' already exists"}],
194
+ }
195
+
196
+ # Import MCP client components
197
+ from mcp.client.session import ClientSession
198
+ from mcp.client.stdio import StdioServerParameters, stdio_client
199
+ from mcp.client.streamable_http import streamablehttp_client
200
+
201
+ if transport == "http":
202
+ if not server_url:
203
+ return {
204
+ "status": "error",
205
+ "content": [{"text": "❌ server_url is required for HTTP transport"}],
206
+ }
207
+
208
+ logger.debug(f"Connecting to HTTP server: {server_url}")
209
+
210
+ # Create transport callable following Strands MCPClient pattern
211
+ def transport_callable():
212
+ return streamablehttp_client(server_url)
213
+
214
+ connection_info = {
215
+ "transport": "http",
216
+ "server_url": server_url,
217
+ "transport_callable": transport_callable,
218
+ "session": None, # Will be initialized on first use
219
+ }
220
+
221
+ elif transport == "stdio":
222
+ if not command:
223
+ return {
224
+ "status": "error",
225
+ "content": [{"text": "❌ command is required for stdio transport"}],
226
+ }
227
+
228
+ logger.debug(f"Connecting to stdio server: {command} {args or []}")
229
+
230
+ # Create stdio server parameters
231
+ server_params = StdioServerParameters(command=command, args=args or [], env=None)
232
+
233
+ # Create transport callable
234
+ def transport_callable():
235
+ return stdio_client(server_params)
236
+
237
+ connection_info = {
238
+ "transport": "stdio",
239
+ "command": command,
240
+ "args": args,
241
+ "transport_callable": transport_callable,
242
+ "session": None,
243
+ }
244
+
245
+ elif transport == "sse":
246
+ if not server_url:
247
+ return {
248
+ "status": "error",
249
+ "content": [{"text": "❌ server_url is required for SSE transport"}],
250
+ }
251
+
252
+ logger.debug(f"Connecting to SSE server: {server_url}")
253
+
254
+ from mcp.client.sse import sse_client
255
+
256
+ def transport_callable():
257
+ return sse_client(server_url)
258
+
259
+ connection_info = {
260
+ "transport": "sse",
261
+ "server_url": server_url,
262
+ "transport_callable": transport_callable,
263
+ "session": None,
264
+ }
265
+
266
+ else:
267
+ return {
268
+ "status": "error",
269
+ "content": [{"text": f"❌ Unknown transport: {transport}\n\nSupported: http, stdio, sse"}],
270
+ }
271
+
272
+ # Test the connection by listing tools
273
+ logger.debug(f"Testing connection by listing tools...")
274
+ tools = []
275
+ try:
276
+ # Create a temporary session to test and get tool count
277
+ import asyncio
278
+
279
+ async def test_connection():
280
+ async with connection_info["transport_callable"]() as (
281
+ read_stream,
282
+ write_stream,
283
+ get_session_id, # StreamableHTTP returns 3 values
284
+ ):
285
+ async with ClientSession(read_stream, write_stream) as session:
286
+ await session.initialize()
287
+ result = await session.list_tools()
288
+ return result.tools
289
+
290
+ tools = asyncio.run(test_connection())
291
+ logger.debug(f"Successfully connected, found {len(tools)} tools")
292
+
293
+ except Exception as e:
294
+ logger.exception("Failed to connect to MCP server")
295
+ return {
296
+ "status": "error",
297
+ "content": [{"text": f"❌ Failed to connect: {str(e)}"}],
298
+ }
299
+
300
+ # Store connection
301
+ _client_connections[connection_id] = connection_info
302
+
303
+ # Build response
304
+ tool_list = "\n".join(f" • {tool.name}" for tool in tools[:10])
305
+ if len(tools) > 10:
306
+ tool_list += f"\n ... and {len(tools) - 10} more"
307
+
308
+ transport_info = (
309
+ f"URL: {server_url}" if transport in ["http", "sse"] else f"Command: {command} {' '.join(args or [])}"
310
+ )
311
+
312
+ message = (
313
+ f"✅ Connected to MCP server '{connection_id}'\n\n"
314
+ f"📊 Transport: {transport}\n"
315
+ f"🔗 {transport_info}\n"
316
+ f"🔧 Available tools ({len(tools)}):\n"
317
+ f"{tool_list}"
318
+ )
319
+
320
+ return {"status": "success", "content": [{"text": message}]}
321
+
322
+ except Exception as e:
323
+ logger.exception("Error in connect action")
324
+ return {
325
+ "status": "error",
326
+ "content": [{"text": f"❌ Error: {str(e)}"}],
327
+ }
328
+
329
+
330
+ def _disconnect(connection_id: Optional[str]) -> dict[str, Any]:
331
+ """Disconnect from an MCP server.
332
+
333
+ Removes the connection from global state. The actual transport cleanup
334
+ happens automatically via context managers.
335
+ """
336
+ try:
337
+ if not connection_id:
338
+ return {
339
+ "status": "error",
340
+ "content": [{"text": "❌ connection_id is required"}],
341
+ }
342
+
343
+ if connection_id not in _client_connections:
344
+ return {
345
+ "status": "error",
346
+ "content": [{"text": f"❌ Connection '{connection_id}' not found"}],
347
+ }
348
+
349
+ # Remove connection
350
+ del _client_connections[connection_id]
351
+ logger.debug(f"Disconnected from '{connection_id}'")
352
+
353
+ return {
354
+ "status": "success",
355
+ "content": [{"text": f"✅ Disconnected from '{connection_id}'"}],
356
+ }
357
+
358
+ except Exception as e:
359
+ logger.exception("Error in disconnect action")
360
+ return {
361
+ "status": "error",
362
+ "content": [{"text": f"❌ Error: {str(e)}"}],
363
+ }
364
+
365
+
366
+ def _list_tools(connection_id: Optional[str]) -> dict[str, Any]:
367
+ """List tools from a connected MCP server.
368
+
369
+ This creates a temporary session to query the server for its available tools.
370
+ """
371
+ try:
372
+ if not connection_id:
373
+ return {
374
+ "status": "error",
375
+ "content": [{"text": "❌ connection_id is required"}],
376
+ }
377
+
378
+ if connection_id not in _client_connections:
379
+ return {
380
+ "status": "error",
381
+ "content": [{"text": f"❌ Connection '{connection_id}' not found"}],
382
+ }
383
+
384
+ connection_info = _client_connections[connection_id]
385
+ logger.debug(f"Listing tools from '{connection_id}'")
386
+
387
+ # Create session and list tools
388
+ import asyncio
389
+
390
+ from mcp.client.session import ClientSession
391
+
392
+ async def list_tools_async():
393
+ async with connection_info["transport_callable"]() as (
394
+ read_stream,
395
+ write_stream,
396
+ get_session_id, # StreamableHTTP returns 3 values
397
+ ):
398
+ async with ClientSession(read_stream, write_stream) as session:
399
+ await session.initialize()
400
+ result = await session.list_tools()
401
+ return result.tools
402
+
403
+ tools = asyncio.run(list_tools_async())
404
+ logger.debug(f"Found {len(tools)} tools")
405
+
406
+ # Build detailed tool list
407
+ tool_details = []
408
+ for tool in tools:
409
+ details = f"**{tool.name}**"
410
+ if tool.description:
411
+ details += f"\n Description: {tool.description}"
412
+ if tool.inputSchema:
413
+ # Show required parameters
414
+ schema = tool.inputSchema
415
+ if "required" in schema:
416
+ details += f"\n Required: {', '.join(schema['required'])}"
417
+ if "properties" in schema:
418
+ details += f"\n Parameters: {', '.join(schema['properties'].keys())}"
419
+ tool_details.append(details)
420
+
421
+ tools_text = "\n\n".join(tool_details)
422
+
423
+ message = f"📋 **Tools from '{connection_id}'**\n\n" f"Found {len(tools)} tools:\n\n" f"{tools_text}"
424
+
425
+ return {"status": "success", "content": [{"text": message}]}
426
+
427
+ except Exception as e:
428
+ logger.exception("Error listing tools")
429
+ return {
430
+ "status": "error",
431
+ "content": [{"text": f"❌ Error: {str(e)}"}],
432
+ }
433
+
434
+
435
+ def _call_tool(
436
+ connection_id: Optional[str],
437
+ tool_name: Optional[str],
438
+ tool_args: Optional[dict[str, Any]],
439
+ ) -> dict[str, Any]:
440
+ """Call a tool on a connected MCP server.
441
+
442
+ This establishes a session, calls the specified tool with provided arguments,
443
+ and returns the result.
444
+ """
445
+ try:
446
+ if not connection_id:
447
+ return {
448
+ "status": "error",
449
+ "content": [{"text": "❌ connection_id is required"}],
450
+ }
451
+
452
+ if not tool_name:
453
+ return {
454
+ "status": "error",
455
+ "content": [{"text": "❌ tool_name is required"}],
456
+ }
457
+
458
+ if connection_id not in _client_connections:
459
+ return {
460
+ "status": "error",
461
+ "content": [{"text": f"❌ Connection '{connection_id}' not found"}],
462
+ }
463
+
464
+ connection_info = _client_connections[connection_id]
465
+ logger.debug(f"Calling tool '{tool_name}' on '{connection_id}' with args: {tool_args}")
466
+
467
+ # Call the tool
468
+ import asyncio
469
+
470
+ from mcp.client.session import ClientSession
471
+
472
+ async def call_tool_async():
473
+ async with connection_info["transport_callable"]() as (
474
+ read_stream,
475
+ write_stream,
476
+ get_session_id,
477
+ ):
478
+ async with ClientSession(read_stream, write_stream) as session:
479
+ await session.initialize()
480
+ result = await session.call_tool(tool_name, tool_args or {})
481
+ return result
482
+
483
+ result = asyncio.run(call_tool_async())
484
+ logger.debug(f"Tool call complete, got {len(result.content)} content items")
485
+
486
+ # Extract result content
487
+ result_text = []
488
+ for content in result.content:
489
+ if hasattr(content, "text"):
490
+ result_text.append(content.text)
491
+ else:
492
+ result_text.append(str(content))
493
+
494
+ combined_result = "\n".join(result_text)
495
+
496
+ message = f"✅ **Tool '{tool_name}' executed on '{connection_id}'**\n\n" f"Result:\n{combined_result}"
497
+
498
+ return {"status": "success", "content": [{"text": message}]}
499
+
500
+ except Exception as e:
501
+ logger.exception(f"Error calling tool '{tool_name}'")
502
+ return {
503
+ "status": "error",
504
+ "content": [{"text": f"❌ Error: {str(e)}"}],
505
+ }
506
+
507
+
508
+ def _list_connections() -> dict[str, Any]:
509
+ """List all active MCP connections.
510
+
511
+ Shows connection details including transport type and available tools.
512
+ """
513
+ try:
514
+ if not _client_connections:
515
+ return {
516
+ "status": "success",
517
+ "content": [{"text": "📭 No active MCP connections"}],
518
+ }
519
+
520
+ lines = [f"📡 **Active MCP Connections** ({len(_client_connections)})\n"]
521
+
522
+ for conn_id, conn_info in _client_connections.items():
523
+ lines.append(f"\n**{conn_id}**")
524
+ lines.append(f" • Transport: {conn_info['transport']}")
525
+
526
+ if conn_info["transport"] == "http":
527
+ lines.append(f" • URL: {conn_info['server_url']}")
528
+ elif conn_info["transport"] == "sse":
529
+ lines.append(f" • URL: {conn_info['server_url']}")
530
+ elif conn_info["transport"] == "stdio":
531
+ cmd = f"{conn_info['command']} {' '.join(conn_info.get('args', []))}"
532
+ lines.append(f" • Command: {cmd}")
533
+
534
+ message = "\n".join(lines)
535
+
536
+ return {"status": "success", "content": [{"text": message}]}
537
+
538
+ except Exception as e:
539
+ logger.exception("Error listing connections")
540
+ return {
541
+ "status": "error",
542
+ "content": [{"text": f"❌ Error: {str(e)}"}],
543
+ }