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