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.
- strands_mcp_server/__init__.py +203 -0
- strands_mcp_server/cli.py +308 -0
- strands_mcp_server/mcp_client.py +525 -0
- strands_mcp_server/mcp_server.py +698 -0
- strands_mcp_server-0.1.3.dist-info/METADATA +231 -0
- strands_mcp_server-0.1.3.dist-info/RECORD +9 -0
- strands_mcp_server-0.1.3.dist-info/WHEEL +4 -0
- strands_mcp_server-0.1.3.dist-info/entry_points.txt +2 -0
- strands_mcp_server-0.1.3.dist-info/licenses/LICENSE +201 -0
|
@@ -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)}]}
|