strands-mcp-server 0.1.3__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 strands-mcp-server might be problematic. Click here for more details.

@@ -0,0 +1,698 @@
1
+ """MCP Server Tool for Strands Agents.
2
+
3
+ Transforms a Strands Agent into an MCP (Model Context Protocol) server, exposing agent
4
+ tools and capabilities to any MCP-compatible client (Claude Desktop, other agents, etc.).
5
+
6
+ This implementation follows MCP Python SDK patterns and Strands best practices:
7
+ - Uses StreamableHTTPSessionManager for production-ready session handling
8
+ - Supports both stateless (multi-node) and stateful (single-node) modes
9
+ - Implements proper lifecycle management with background threads
10
+ - Comprehensive error handling and logging
11
+ - Context-aware tool execution via agent parameter injection
12
+
13
+ Key Features:
14
+ - **Stateless HTTP Mode**: Multi-node ready, horizontally scalable
15
+ - **Stateful HTTP Mode**: Session persistence across requests
16
+ - **stdio Mode**: Direct stdio communication for Claude Desktop
17
+ - **Tool Filtering**: Expose only specific tools
18
+ - **Agent Invocation**: Optional full agent conversation capability
19
+ - **Auto-cleanup**: Proper resource management via context managers
20
+
21
+ Example:
22
+ ```python
23
+ from strands import Agent
24
+ from strands_tools import shell, calculator, file_read
25
+ from tools.mcp_server import mcp_server
26
+
27
+ agent = Agent(tools=[shell, calculator, file_read, mcp_server])
28
+
29
+ # Via agent invocation (recommended - proper context injection)
30
+ agent("start mcp server on port 8000")
31
+
32
+ # Stateless mode for production (multi-node ready)
33
+ agent("start stateless mcp server on port 8000")
34
+
35
+ # With specific tools only
36
+ agent("start mcp server with tools: calculator, file_read")
37
+
38
+ # Without agent invocation (tools only)
39
+ agent("start mcp server without agent invocation")
40
+
41
+ # Check status
42
+ agent("mcp server status")
43
+ ```
44
+
45
+ References:
46
+ - MCP Specification: https://spec.modelcontextprotocol.io/
47
+ - Strands MCP Client: sdk-python/src/strands/tools/mcp/mcp_client.py
48
+ - MCP Server: python-sdk/src/mcp/server/lowlevel/server.py
49
+ - StreamableHTTPSessionManager: python-sdk/src/mcp/server/streamable_http_manager.py
50
+ """
51
+
52
+ import contextlib
53
+ import logging
54
+ import threading
55
+ import time
56
+ import traceback
57
+ from collections.abc import AsyncIterator
58
+ from typing import Any, Optional
59
+
60
+ from strands import Agent, tool
61
+ from strands.types.tools import ToolContext
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # MCP imports with error handling
66
+ MCP_IMPORT_ERROR = ""
67
+ try:
68
+ import uvicorn
69
+ from mcp import types
70
+ from mcp.server.lowlevel import Server
71
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
72
+ from starlette.applications import Starlette
73
+ from starlette.middleware.cors import CORSMiddleware
74
+ from starlette.routing import Mount
75
+ from starlette.types import Receive, Scope, Send
76
+
77
+ HAS_MCP = True
78
+ except ImportError as e:
79
+ HAS_MCP = False
80
+ MCP_IMPORT_ERROR = str(e)
81
+
82
+
83
+ # Global state to track MCP servers
84
+ _server_state = {
85
+ "servers": {}, # Map of server_id -> server_info
86
+ "default_server": None, # ID of the default server
87
+ }
88
+
89
+
90
+ @tool
91
+ def mcp_server(
92
+ action: str,
93
+ server_id: str = "default",
94
+ transport: str = "http",
95
+ port: int = 8000,
96
+ tools: Optional[list[str]] = None,
97
+ expose_agent: bool = True,
98
+ stateless: bool = False,
99
+ agent: Any = None,
100
+ ) -> dict[str, Any]:
101
+ """Turn the agent into an MCP server, exposing agent tools as MCP tools.
102
+
103
+ This tool follows the MCP (Model Context Protocol) specification and implements
104
+ production-ready server patterns using StreamableHTTPSessionManager.
105
+
106
+ Transports:
107
+ - **http + stateless=True**: Multi-node ready, horizontally scalable, no session state (background)
108
+ - **http + stateless=False**: Session persistence, single-node deployments (background)
109
+ - **stdio**: Direct stdin/stdout communication for local MCP clients (foreground, blocking)
110
+
111
+ Args:
112
+ action: Action to perform - "start", "stop", "status", "list"
113
+ server_id: Unique identifier for this server instance (default: "default")
114
+ transport: Transport type - "http" (StreamableHTTP, background) or "stdio" (foreground, blocking)
115
+ port: Port for HTTP server (only used when transport="http", default: 8000)
116
+ tools: Optional list of tool names to expose. If None, exposes all tools except mcp_server itself
117
+ expose_agent: Whether to expose "invoke_agent" tool for full agent conversations (default: True)
118
+ stateless: If True, creates fresh transport per request with no session state.
119
+ Enables horizontal scaling across multiple nodes (default: False)
120
+ agent: Parent agent instance (auto-injected by Strands framework)
121
+
122
+ Returns:
123
+ Result dictionary with status and content
124
+
125
+ Examples:
126
+ # Start HTTP server (background thread)
127
+ agent("start mcp server on port 8000")
128
+
129
+ # Start stdio server (foreground, blocking - for CLI/Claude Desktop)
130
+ agent.tool.mcp_server(action="start", transport="stdio", agent=agent)
131
+
132
+ # Start stateless server (production, multi-node ready)
133
+ agent("start stateless mcp server on port 8000")
134
+
135
+ # Start with specific tools only
136
+ agent("start mcp server with tools: calculator, file_read")
137
+
138
+ Notes:
139
+ - **stdio transport**: Runs in FOREGROUND (blocks current thread) - use for CLI entrypoints
140
+ - **http transport**: Runs in BACKGROUND (daemon thread) - use for long-running servers
141
+ - **stateless mode**: Recommended for production deployments with load balancing
142
+ - **stateful mode**: Recommended for development and single-node deployments
143
+ - Agent parameter is auto-injected by Strands - don't pass manually
144
+ """
145
+ try:
146
+ # Check if MCP is installed
147
+ if not HAS_MCP:
148
+ return {
149
+ "status": "error",
150
+ "content": [
151
+ {
152
+ "text": f"❌ MCP not installed: {MCP_IMPORT_ERROR}\n\n"
153
+ f"Install with: pip install mcp starlette uvicorn"
154
+ }
155
+ ],
156
+ }
157
+
158
+ # Route to appropriate handler
159
+ if action == "start":
160
+ return _start_mcp_server(server_id, transport, port, tools, expose_agent, stateless, agent)
161
+ elif action == "stop":
162
+ return _stop_mcp_server(server_id)
163
+ elif action == "status":
164
+ return _get_mcp_status()
165
+ elif action == "list":
166
+ return _list_mcp_servers()
167
+ else:
168
+ return {
169
+ "status": "error",
170
+ "content": [{"text": f"❌ Unknown action: {action}\n\nValid actions: start, stop, status, list"}],
171
+ }
172
+
173
+ except Exception as e:
174
+ logger.exception("MCP server tool error")
175
+ return {
176
+ "status": "error",
177
+ "content": [{"text": f"❌ Error: {str(e)}\n\n{traceback.format_exc()}"}],
178
+ }
179
+
180
+
181
+ def _start_mcp_server(
182
+ server_id: str,
183
+ transport: str,
184
+ port: int,
185
+ tools_filter: Optional[list[str]],
186
+ expose_agent: bool,
187
+ stateless: bool,
188
+ agent: Any,
189
+ ) -> dict[str, Any]:
190
+ """Start an MCP server exposing agent tools.
191
+
192
+ This function implements the core server startup logic following MCP SDK patterns:
193
+ 1. Validates agent and tool availability
194
+ 2. Creates MCP Server instance with tool handlers
195
+ 3. Starts server:
196
+ - **stdio**: Runs in FOREGROUND (blocks current thread) via asyncio.run()
197
+ - **http**: Runs in BACKGROUND (daemon thread) for non-blocking operation
198
+ 4. Returns status with connection details
199
+
200
+ Args:
201
+ server_id: Unique identifier for this server
202
+ transport: "http" for StreamableHTTP (background) or "stdio" for stdin/stdout (foreground)
203
+ port: HTTP port (only for http transport)
204
+ tools_filter: Optional list of tool names to expose
205
+ expose_agent: Whether to expose invoke_agent capability
206
+ stateless: If True, creates fresh transport per request (multi-node ready)
207
+ agent: Parent Strands agent instance
208
+
209
+ Returns:
210
+ Status dictionary with server details or error message
211
+ """
212
+ if server_id in _server_state["servers"]:
213
+ return {
214
+ "status": "error",
215
+ "content": [{"text": f"❌ Server '{server_id}' is already running"}],
216
+ }
217
+
218
+ if not agent:
219
+ return {
220
+ "status": "error",
221
+ "content": [{"text": "❌ Tool context not available"}],
222
+ }
223
+
224
+ # Get all agent tools
225
+ all_tools = agent.tool_registry.get_all_tools_config()
226
+ if not all_tools:
227
+ return {"status": "error", "content": [{"text": "❌ No tools found in agent"}]}
228
+
229
+ # Filter tools based on tools_filter parameter
230
+ if tools_filter:
231
+ # Only include specified tools
232
+ agent_tools = {name: spec for name, spec in all_tools.items() if name in tools_filter and name != "mcp_server"}
233
+ if not agent_tools and not expose_agent:
234
+ return {
235
+ "status": "error",
236
+ "content": [{"text": f"❌ No matching tools found. Available: {list(all_tools.keys())}"}],
237
+ }
238
+ else:
239
+ # Exclude mcp_server tool itself to avoid recursion
240
+ agent_tools = {name: spec for name, spec in all_tools.items() if name != "mcp_server"}
241
+
242
+ logger.debug(f"Creating MCP server with {len(agent_tools)} tools: {list(agent_tools.keys())}")
243
+
244
+ try:
245
+ # Create low-level MCP server following MCP SDK patterns
246
+ # This uses mcp.server.lowlevel.Server which provides the @server.list_tools()
247
+ # and @server.call_tool() decorators for registering handlers
248
+ server = Server(f"strands-agent-{server_id}")
249
+
250
+ # Create MCP Tool objects from agent tools
251
+ mcp_tools = []
252
+ for tool_name, tool_spec in agent_tools.items():
253
+ description = tool_spec.get("description", f"Agent tool: {tool_name}")
254
+ input_schema = {}
255
+
256
+ if "inputSchema" in tool_spec:
257
+ if "json" in tool_spec["inputSchema"]:
258
+ input_schema = tool_spec["inputSchema"]["json"]
259
+ else:
260
+ input_schema = tool_spec["inputSchema"]
261
+
262
+ mcp_tools.append(
263
+ types.Tool(
264
+ name=tool_name,
265
+ description=description,
266
+ inputSchema=input_schema,
267
+ )
268
+ )
269
+
270
+ # Add agent invocation tool if requested
271
+ if expose_agent:
272
+ agent_invoke_tool = types.Tool(
273
+ name="invoke_agent",
274
+ description=(
275
+ f"Invoke the full {agent.name} agent with a natural language prompt. "
276
+ "Use this for complex queries that require reasoning across multiple tools "
277
+ "or when you need a conversational response from the agent."
278
+ ),
279
+ inputSchema={
280
+ "type": "object",
281
+ "properties": {
282
+ "prompt": {
283
+ "type": "string",
284
+ "description": "The prompt or query to send to the agent",
285
+ }
286
+ },
287
+ "required": ["prompt"],
288
+ },
289
+ )
290
+ mcp_tools.append(agent_invoke_tool)
291
+
292
+ logger.debug(f"Created {len(mcp_tools)} MCP tools (agent invocation: {expose_agent})")
293
+
294
+ # Capture transport in closure for call_tool handler
295
+ _transport = transport
296
+
297
+ # Register list_tools handler following MCP SDK pattern
298
+ @server.list_tools()
299
+ async def list_tools() -> list[types.Tool]:
300
+ """Return list of available MCP tools.
301
+
302
+ This handler is called when MCP clients request the available tools.
303
+ It returns the pre-built list of MCP Tool objects converted from
304
+ Strands agent tools.
305
+ """
306
+ logger.debug(f"list_tools called, returning {len(mcp_tools)} tools")
307
+ return mcp_tools
308
+
309
+ # Register call_tool handler
310
+ @server.call_tool()
311
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
312
+ """Handle tool calls from MCP clients.
313
+
314
+ This handler:
315
+ 1. Validates tool existence
316
+ 2. Handles agent invocation specially
317
+ 3. Calls agent tools with proper error handling
318
+ 4. Converts results to MCP TextContent format
319
+ 5. Forces non-interactive mode for stdio to avoid stream conflicts
320
+ """
321
+ try:
322
+ logger.debug(f"call_tool: name={name}, arguments={arguments}")
323
+
324
+ # Handle agent invocation tool
325
+ if name == "invoke_agent" and expose_agent:
326
+ prompt = arguments.get("prompt")
327
+ if not prompt:
328
+ return [
329
+ types.TextContent(
330
+ type="text",
331
+ text="❌ Error: 'prompt' parameter is required",
332
+ )
333
+ ]
334
+
335
+ logger.debug(f"Invoking agent with prompt: {prompt[:100]}...")
336
+
337
+ # Get the parent agent's configuration
338
+ # Access tools directly from registry dictionary
339
+ tools_for_invocation = [
340
+ agent.tool_registry.registry[tool_name]
341
+ for tool_name in agent_tools.keys()
342
+ if tool_name in agent.tool_registry.registry
343
+ ]
344
+
345
+ # Prepare extra kwargs for observability and callbacks
346
+ extra_kwargs = {}
347
+ if hasattr(agent, "callback_handler") and agent.callback_handler:
348
+ extra_kwargs["callback_handler"] = agent.callback_handler
349
+
350
+ # Create fresh agent with same configuration but clean message history
351
+ # Inherits: model, tools, trace_attributes, callback_handler
352
+ fresh_agent = Agent(
353
+ name=f"{agent.name}-invocation",
354
+ model=agent.model,
355
+ messages=[], # Empty message history (clean state)
356
+ tools=tools_for_invocation,
357
+ system_prompt=agent.system_prompt if hasattr(agent, "system_prompt") else None,
358
+ trace_attributes=agent.trace_attributes if hasattr(agent, "trace_attributes") else {},
359
+ **extra_kwargs,
360
+ )
361
+
362
+ # Call the fresh agent
363
+ result = fresh_agent(prompt)
364
+
365
+ # Extract text response from agent result
366
+ response_text = str(result)
367
+
368
+ logger.debug(f"Agent invocation complete, response length: {len(response_text)}")
369
+
370
+ return [types.TextContent(type="text", text=response_text)]
371
+
372
+ # Check if tool exists in agent
373
+ if name not in agent_tools:
374
+ return [types.TextContent(type="text", text=f"❌ Unknown tool: {name}")]
375
+
376
+ # Call the agent tool
377
+ # Note: For stdio transport, we should force non_interactive=True to avoid
378
+ # stdin/stdout conflicts. However, most Strands tools don't have this
379
+ # parameter yet, so we call normally and let the tool handle it.
380
+ tool_caller = getattr(agent.tool, name.replace("-", "_"))
381
+
382
+ # For stdio transport, try to pass non_interactive=True if the tool supports it
383
+ # This prevents tools like shell from trying to use stdin/stdout
384
+ if _transport == "stdio":
385
+ try:
386
+ # Try calling with non_interactive parameter
387
+ result = tool_caller(**arguments, non_interactive=True)
388
+ except TypeError:
389
+ # Tool doesn't support non_interactive, call normally
390
+ logger.debug(f"Tool '{name}' doesn't support non_interactive parameter")
391
+ result = tool_caller(**arguments)
392
+ else:
393
+ result = tool_caller(**arguments)
394
+
395
+ logger.debug(f"Tool '{name}' execution complete")
396
+
397
+ # Convert result to MCP TextContent format
398
+ mcp_content = []
399
+ if isinstance(result, dict) and "content" in result:
400
+ # Strands tool result format
401
+ for item in result.get("content", []):
402
+ if isinstance(item, dict) and "text" in item:
403
+ mcp_content.append(types.TextContent(type="text", text=item["text"]))
404
+ else:
405
+ # Direct string or other result
406
+ mcp_content.append(types.TextContent(type="text", text=str(result)))
407
+
408
+ return (
409
+ mcp_content
410
+ if mcp_content
411
+ else [types.TextContent(type="text", text="βœ… Tool executed successfully")]
412
+ )
413
+
414
+ except Exception as e:
415
+ logger.exception(f"Error calling tool '{name}'")
416
+ return [types.TextContent(type="text", text=f"❌ Error: {str(e)}")]
417
+
418
+ # Record server state
419
+ _server_state["servers"][server_id] = {
420
+ "server": server,
421
+ "transport": transport,
422
+ "port": port,
423
+ "stateless": stateless,
424
+ "tools": list(agent_tools.keys()),
425
+ "start_time": time.time(),
426
+ "status": "starting",
427
+ "expose_agent": expose_agent,
428
+ }
429
+
430
+ if _server_state["default_server"] is None:
431
+ _server_state["default_server"] = server_id
432
+
433
+ # For stdio transport: Run in FOREGROUND (blocks current thread)
434
+ # This is used by CLI entrypoints that need to keep stdio server alive
435
+ if transport == "stdio":
436
+ logger.info(f"Starting MCP server '{server_id}' in stdio mode (foreground, blocking)")
437
+ _server_state["servers"][server_id]["status"] = "running"
438
+
439
+ # Run stdio server directly - BLOCKS until terminated
440
+ import asyncio
441
+ from mcp.server.stdio import stdio_server
442
+
443
+ async def run_stdio() -> None:
444
+ """Run stdio server in foreground."""
445
+ async with stdio_server() as streams:
446
+ await server.run(streams[0], streams[1], server.create_initialization_options())
447
+
448
+ # This blocks the current thread - perfect for CLI entrypoints!
449
+ asyncio.run(run_stdio())
450
+
451
+ # When we get here, server has stopped
452
+ if server_id in _server_state["servers"]:
453
+ del _server_state["servers"][server_id]
454
+
455
+ return {
456
+ "status": "success",
457
+ "content": [{"text": f"βœ… MCP server '{server_id}' stopped"}],
458
+ }
459
+
460
+ # For http transport: Run in BACKGROUND (daemon thread, non-blocking)
461
+ # This allows the agent to continue processing other tasks
462
+ server_thread = threading.Thread(
463
+ target=_run_mcp_server,
464
+ args=(server, transport, port, stateless, server_id, len(mcp_tools)),
465
+ daemon=True,
466
+ )
467
+
468
+ _server_state["servers"][server_id]["thread"] = server_thread
469
+ server_thread.start()
470
+
471
+ # Give server time to start
472
+ time.sleep(2)
473
+
474
+ # Check status
475
+ if server_id not in _server_state["servers"]:
476
+ return {
477
+ "status": "error",
478
+ "content": [{"text": f"❌ Server '{server_id}' failed to start"}],
479
+ }
480
+
481
+ server_info = _server_state["servers"][server_id]
482
+ if server_info["status"] == "error":
483
+ error_msg = server_info.get("error", "Unknown error")
484
+ return {
485
+ "status": "error",
486
+ "content": [{"text": f"❌ Server '{server_id}' failed: {error_msg}"}],
487
+ }
488
+
489
+ # Update to running
490
+ _server_state["servers"][server_id]["status"] = "running"
491
+
492
+ # Build status message
493
+ tool_list = "\n".join(f" β€’ {tool.name}" for tool in mcp_tools[:10])
494
+ if len(mcp_tools) > 10:
495
+ tool_list += f"\n ... and {len(mcp_tools) - 10} more"
496
+
497
+ if expose_agent:
498
+ tool_list += "\n β€’ invoke_agent (full agent invocation) ✨"
499
+
500
+ mode_desc = "stateless (multi-node ready)" if stateless else "stateful (session persistence)"
501
+ message = (
502
+ f"βœ… MCP server '{server_id}' started on port {port}\n\n"
503
+ f"πŸ“Š Mode: {mode_desc}\n"
504
+ f"πŸ”§ Exposed {len(mcp_tools)} tools:\n"
505
+ f"{tool_list}\n\n"
506
+ f"πŸ”— Connect at: http://localhost:{port}/mcp"
507
+ )
508
+
509
+ return {"status": "success", "content": [{"text": message}]}
510
+
511
+ except Exception as e:
512
+ logger.exception("Error starting MCP server")
513
+
514
+ if server_id in _server_state["servers"]:
515
+ _server_state["servers"][server_id]["status"] = "error"
516
+ _server_state["servers"][server_id]["error"] = str(e)
517
+
518
+ return {
519
+ "status": "error",
520
+ "content": [{"text": f"❌ Failed to start MCP server: {str(e)}"}],
521
+ }
522
+
523
+
524
+ def _run_mcp_server(
525
+ server: "Server", transport: str, port: int, stateless: bool, server_id: str, tool_count: int
526
+ ) -> None:
527
+ """Run MCP server in background thread with StreamableHTTPSessionManager.
528
+
529
+ This function follows MCP SDK patterns for server execution:
530
+ - HTTP transport: Uses StreamableHTTPSessionManager with Starlette + Uvicorn (background)
531
+ - stdio transport: Not used here - stdio runs in foreground via _start_mcp_server()
532
+
533
+ The server runs in a daemon background thread to avoid blocking the main agent.
534
+
535
+ Args:
536
+ server: MCP Server instance with registered handlers
537
+ transport: "http" (only http supported here, stdio runs in foreground)
538
+ port: HTTP port
539
+ stateless: If True, creates fresh transport per request (no session state)
540
+ server_id: Server identifier for logging and tracking
541
+ tool_count: Number of exposed tools (for logging)
542
+ """
543
+ try:
544
+ logger.debug(
545
+ f"Starting MCP server: server_id={server_id}, transport={transport}, port={port}, stateless={stateless}"
546
+ )
547
+
548
+ if transport == "http":
549
+ # HTTP mode using StreamableHTTPSessionManager
550
+ # This follows the pattern from python-sdk/src/mcp/server/streamable_http_manager.py
551
+
552
+ # Create session manager with configurable stateless mode
553
+ # - stateless=True: Multi-node ready, no session persistence
554
+ # - stateless=False: Session persistence, single-node deployments
555
+ session_manager = StreamableHTTPSessionManager(
556
+ app=server,
557
+ event_store=None, # No resumability support for now
558
+ json_response=False, # Use SSE streams (not pure JSON)
559
+ stateless=stateless, # Configurable stateless mode
560
+ )
561
+
562
+ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
563
+ """Handle streamable HTTP requests.
564
+
565
+ This is the ASGI application handler that processes incoming HTTP
566
+ requests and routes them through the session manager.
567
+ """
568
+ await session_manager.handle_request(scope, receive, send)
569
+
570
+ @contextlib.asynccontextmanager
571
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
572
+ """Lifespan context manager for session manager.
573
+
574
+ This manages the lifecycle of the StreamableHTTPSessionManager,
575
+ ensuring proper startup and shutdown of resources.
576
+ """
577
+ async with session_manager.run():
578
+ logger.info(
579
+ f"MCP server '{server_id}' running with StreamableHTTPSessionManager (stateless={stateless})"
580
+ )
581
+ try:
582
+ yield
583
+ finally:
584
+ logger.info(f"MCP server '{server_id}' shutting down...")
585
+
586
+ # Create ASGI application following Starlette patterns
587
+ starlette_app = Starlette(
588
+ debug=True,
589
+ routes=[
590
+ Mount("/mcp", app=handle_streamable_http),
591
+ ],
592
+ lifespan=lifespan,
593
+ )
594
+
595
+ # Wrap with CORS middleware for cross-origin support
596
+ # This allows browser-based MCP clients to connect
597
+ starlette_app = CORSMiddleware(
598
+ starlette_app,
599
+ allow_origins=["*"], # Allow all origins - adjust for production
600
+ allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods
601
+ expose_headers=["Mcp-Session-Id"], # Expose session ID header
602
+ )
603
+
604
+ logger.debug(f"Starting Uvicorn server on 0.0.0.0:{port}")
605
+ uvicorn.run(starlette_app, host="0.0.0.0", port=port, log_level="info")
606
+ else:
607
+ logger.error(f"Unsupported transport: {transport} (only 'http' supported in background thread)")
608
+
609
+ except Exception as e:
610
+ logger.exception("Error in _run_mcp_server")
611
+
612
+ if server_id in _server_state["servers"]:
613
+ _server_state["servers"][server_id]["status"] = "error"
614
+ _server_state["servers"][server_id]["error"] = str(e)
615
+
616
+
617
+ def _stop_mcp_server(server_id: str) -> dict[str, Any]:
618
+ """Stop a running MCP server."""
619
+ if server_id not in _server_state["servers"]:
620
+ return {
621
+ "status": "error",
622
+ "content": [{"text": f"❌ Server '{server_id}' is not running"}],
623
+ }
624
+
625
+ server_info = _server_state["servers"][server_id]
626
+ server_info["status"] = "stopping"
627
+
628
+ # Note: Graceful shutdown is complex with threading + async
629
+ # For now, daemon threads will be cleaned up on process exit
630
+
631
+ del _server_state["servers"][server_id]
632
+
633
+ if _server_state["default_server"] == server_id:
634
+ _server_state["default_server"] = next(iter(_server_state["servers"])) if _server_state["servers"] else None
635
+
636
+ return {
637
+ "status": "success",
638
+ "content": [{"text": f"βœ… MCP server '{server_id}' stopped"}],
639
+ }
640
+
641
+
642
+ def _get_mcp_status() -> dict[str, Any]:
643
+ """Get status of all MCP servers."""
644
+ if not _server_state["servers"]:
645
+ return {"status": "success", "content": [{"text": "ℹ️ No MCP servers running"}]}
646
+
647
+ lines = ["πŸ“‘ **MCP Server Status**\n"]
648
+
649
+ for server_id, server_info in _server_state["servers"].items():
650
+ uptime = time.time() - server_info["start_time"]
651
+ uptime_str = f"{int(uptime // 60)}m {int(uptime % 60)}s"
652
+
653
+ default_marker = " (default)" if server_id == _server_state["default_server"] else ""
654
+ status_emoji = {
655
+ "running": "βœ…",
656
+ "starting": "πŸ”„",
657
+ "stopping": "⏸️",
658
+ "error": "❌",
659
+ }.get(server_info["status"], "❓")
660
+
661
+ lines.append(f"\n**{server_id}{default_marker}**")
662
+ lines.append(f" β€’ Status: {status_emoji} {server_info['status']}")
663
+ lines.append(f" β€’ Transport: {server_info['transport']}")
664
+
665
+ if server_info["transport"] == "http":
666
+ lines.append(f" β€’ Port: {server_info['port']}")
667
+ lines.append(f" β€’ Connect: http://localhost:{server_info['port']}/mcp")
668
+ mode_type = "stateless (multi-node)" if server_info.get("stateless", False) else "stateful (single-node)"
669
+ lines.append(f" β€’ Type: {mode_type}")
670
+
671
+ lines.append(f" β€’ Uptime: {uptime_str}")
672
+ lines.append(f" β€’ Tools: {len(server_info['tools'])} exposed")
673
+
674
+ if server_info.get("expose_agent"):
675
+ lines.append(f" β€’ Agent Invocation: βœ… Enabled")
676
+
677
+ if server_info["status"] == "error" and "error" in server_info:
678
+ lines.append(f" β€’ Error: {server_info['error']}")
679
+
680
+ return {"status": "success", "content": [{"text": "\n".join(lines)}]}
681
+
682
+
683
+ def _list_mcp_servers() -> dict[str, Any]:
684
+ """List running MCP servers."""
685
+ if not _server_state["servers"]:
686
+ return {"status": "success", "content": [{"text": "ℹ️ No MCP servers running"}]}
687
+
688
+ lines = ["πŸ“‹ **Running MCP Servers**\n"]
689
+
690
+ for server_id, server_info in _server_state["servers"].items():
691
+ default_marker = " (default)" if server_id == _server_state["default_server"] else ""
692
+ mode_info = f"port {server_info['port']}" if server_info["transport"] == "http" else "stdio"
693
+
694
+ lines.append(
695
+ f"β€’ {server_id}{default_marker}: {server_info['status']}, " f"{server_info['transport']} ({mode_info})"
696
+ )
697
+
698
+ return {"status": "success", "content": [{"text": "\n".join(lines)}]}