ida-pro-mcp-xjoker 1.0.1__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.
- ida_pro_mcp/__init__.py +0 -0
- ida_pro_mcp/__main__.py +6 -0
- ida_pro_mcp/ida_mcp/__init__.py +68 -0
- ida_pro_mcp/ida_mcp/api_analysis.py +1296 -0
- ida_pro_mcp/ida_mcp/api_core.py +337 -0
- ida_pro_mcp/ida_mcp/api_debug.py +617 -0
- ida_pro_mcp/ida_mcp/api_memory.py +304 -0
- ida_pro_mcp/ida_mcp/api_modify.py +406 -0
- ida_pro_mcp/ida_mcp/api_python.py +179 -0
- ida_pro_mcp/ida_mcp/api_resources.py +295 -0
- ida_pro_mcp/ida_mcp/api_stack.py +167 -0
- ida_pro_mcp/ida_mcp/api_types.py +480 -0
- ida_pro_mcp/ida_mcp/auth.py +166 -0
- ida_pro_mcp/ida_mcp/cache.py +232 -0
- ida_pro_mcp/ida_mcp/config.py +228 -0
- ida_pro_mcp/ida_mcp/framework.py +547 -0
- ida_pro_mcp/ida_mcp/http.py +859 -0
- ida_pro_mcp/ida_mcp/port_utils.py +104 -0
- ida_pro_mcp/ida_mcp/rpc.py +187 -0
- ida_pro_mcp/ida_mcp/server_manager.py +339 -0
- ida_pro_mcp/ida_mcp/sync.py +233 -0
- ida_pro_mcp/ida_mcp/tests/__init__.py +14 -0
- ida_pro_mcp/ida_mcp/tests/test_api_analysis.py +336 -0
- ida_pro_mcp/ida_mcp/tests/test_api_core.py +237 -0
- ida_pro_mcp/ida_mcp/tests/test_api_memory.py +207 -0
- ida_pro_mcp/ida_mcp/tests/test_api_modify.py +123 -0
- ida_pro_mcp/ida_mcp/tests/test_api_resources.py +199 -0
- ida_pro_mcp/ida_mcp/tests/test_api_stack.py +77 -0
- ida_pro_mcp/ida_mcp/tests/test_api_types.py +249 -0
- ida_pro_mcp/ida_mcp/ui.py +357 -0
- ida_pro_mcp/ida_mcp/utils.py +1186 -0
- ida_pro_mcp/ida_mcp/zeromcp/__init__.py +5 -0
- ida_pro_mcp/ida_mcp/zeromcp/jsonrpc.py +384 -0
- ida_pro_mcp/ida_mcp/zeromcp/mcp.py +883 -0
- ida_pro_mcp/ida_mcp.py +186 -0
- ida_pro_mcp/idalib_server.py +354 -0
- ida_pro_mcp/idalib_session_manager.py +259 -0
- ida_pro_mcp/server.py +1060 -0
- ida_pro_mcp/test.py +170 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/METADATA +405 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/RECORD +45 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/WHEEL +5 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/entry_points.txt +4 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/licenses/LICENSE +21 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""端口冲突自动递增工具
|
|
2
|
+
|
|
3
|
+
多个 IDA Pro 实例同时加载 MCP 插件时,提供端口自动递增重试功能。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import errno
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .zeromcp.mcp import McpServer
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# 端口已占用的 errno 值(跨平台)
|
|
16
|
+
# macOS: 48, Linux: 98, Windows: 10048
|
|
17
|
+
_ADDR_IN_USE_ERRNOS = {errno.EADDRINUSE, 48, 98, 10048}
|
|
18
|
+
|
|
19
|
+
DEFAULT_MAX_RETRIES = 10
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def try_serve_with_port_retry(
|
|
23
|
+
mcp_server: "McpServer",
|
|
24
|
+
host: str,
|
|
25
|
+
base_port: int,
|
|
26
|
+
*,
|
|
27
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
28
|
+
request_handler=None,
|
|
29
|
+
) -> tuple[int, list[int]]:
|
|
30
|
+
"""尝试启动 MCP 服务器,端口冲突时自动递增重试。
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
mcp_server: McpServer 实例
|
|
34
|
+
host: 绑定地址
|
|
35
|
+
base_port: 起始端口
|
|
36
|
+
max_retries: 最大重试次数(包含首次尝试共 max_retries 次)
|
|
37
|
+
request_handler: HTTP 请求处理器类
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
(actual_port, failed_ports) 元组:
|
|
41
|
+
- actual_port: 实际绑定成功的端口
|
|
42
|
+
- failed_ports: 绑定失败的端口列表(为空表示首次即成功)
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
OSError: 所有端口尝试均失败,或遇到非端口冲突的错误
|
|
46
|
+
"""
|
|
47
|
+
kwargs = {}
|
|
48
|
+
if request_handler is not None:
|
|
49
|
+
kwargs["request_handler"] = request_handler
|
|
50
|
+
|
|
51
|
+
failed_ports: list[int] = []
|
|
52
|
+
last_error: OSError | None = None
|
|
53
|
+
|
|
54
|
+
for i in range(max_retries):
|
|
55
|
+
port = base_port + i
|
|
56
|
+
if port > 65535:
|
|
57
|
+
break
|
|
58
|
+
try:
|
|
59
|
+
mcp_server.serve(host, port, **kwargs)
|
|
60
|
+
return port, failed_ports
|
|
61
|
+
except OSError as e:
|
|
62
|
+
if e.errno in _ADDR_IN_USE_ERRNOS:
|
|
63
|
+
logger.debug(f"Port {port} in use, trying next")
|
|
64
|
+
failed_ports.append(port)
|
|
65
|
+
last_error = e
|
|
66
|
+
else:
|
|
67
|
+
# 非端口冲突错误,立即抛出
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
# 所有端口都失败了
|
|
71
|
+
assert last_error is not None
|
|
72
|
+
raise last_error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_port_exhausted_message(
|
|
76
|
+
host: str, base_port: int, failed_ports: list[int]
|
|
77
|
+
) -> str:
|
|
78
|
+
"""生成端口耗尽时的用户友好提示信息。
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
host: 绑定地址
|
|
82
|
+
base_port: 起始端口
|
|
83
|
+
failed_ports: 尝试失败的端口列表
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
格式化的错误提示字符串
|
|
87
|
+
"""
|
|
88
|
+
if not failed_ports:
|
|
89
|
+
return f"[MCP] Error: Could not bind to port on {host}"
|
|
90
|
+
|
|
91
|
+
first = failed_ports[0]
|
|
92
|
+
last = failed_ports[-1]
|
|
93
|
+
ports_str = ", ".join(str(p) for p in failed_ports)
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
f"[MCP] Error: Could not find an available port.\n"
|
|
97
|
+
f" Tried ports: {ports_str}\n"
|
|
98
|
+
f" All ports in range {first}-{last} are in use.\n"
|
|
99
|
+
f"\n"
|
|
100
|
+
f" To manually set a different port, run in IDA Python console:\n"
|
|
101
|
+
f" from ida_mcp.http import set_server_config\n"
|
|
102
|
+
f" set_server_config({{'host': '{host}', 'port': 15000}})\n"
|
|
103
|
+
f" Then toggle: Edit -> Plugins -> MCP Server"
|
|
104
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from .zeromcp import McpRpcRegistry, McpServer, McpToolError, McpHttpRequestHandler
|
|
5
|
+
|
|
6
|
+
MCP_UNSAFE: set[str] = set()
|
|
7
|
+
MCP_EXTENSIONS: dict[str, set[str]] = {} # group -> set of function names
|
|
8
|
+
MCP_SERVER = McpServer("ida-pro-mcp", extensions=MCP_EXTENSIONS)
|
|
9
|
+
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# Output Size Limiting
|
|
12
|
+
# ============================================================================
|
|
13
|
+
|
|
14
|
+
OUTPUT_LIMIT_MAX_CHARS = 50000
|
|
15
|
+
OUTPUT_CACHE_MAX_SIZE = 100
|
|
16
|
+
_output_cache: dict[str, Any] = {}
|
|
17
|
+
_download_base_url: str = os.environ.get("IDA_MCP_URL", "http://127.0.0.1:13337")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_download_base_url(url: str) -> None:
|
|
21
|
+
global _download_base_url
|
|
22
|
+
_download_base_url = url.rstrip("/")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_download_base_url() -> str:
|
|
26
|
+
return _download_base_url
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _generate_output_id() -> str:
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
return str(uuid.uuid4())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
OUTPUT_LIMIT_PREVIEW_ITEMS = 10
|
|
36
|
+
OUTPUT_LIMIT_PREVIEW_STR_LEN = 1000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate_value(value: Any, depth: int = 0) -> Any:
|
|
40
|
+
if depth > 5:
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
if isinstance(value, str) and len(value) > OUTPUT_LIMIT_PREVIEW_STR_LEN:
|
|
44
|
+
return value[:OUTPUT_LIMIT_PREVIEW_STR_LEN] + f"... [{len(value)} chars total]"
|
|
45
|
+
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
truncated_list = [
|
|
48
|
+
_truncate_value(item, depth + 1)
|
|
49
|
+
for item in value[:OUTPUT_LIMIT_PREVIEW_ITEMS]
|
|
50
|
+
]
|
|
51
|
+
if len(value) > OUTPUT_LIMIT_PREVIEW_ITEMS:
|
|
52
|
+
truncated_list.append(
|
|
53
|
+
{
|
|
54
|
+
"_truncated": f"... and {len(value) - OUTPUT_LIMIT_PREVIEW_ITEMS} more items"
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
return truncated_list
|
|
58
|
+
|
|
59
|
+
if isinstance(value, dict):
|
|
60
|
+
return {k: _truncate_value(v, depth + 1) for k, v in value.items()}
|
|
61
|
+
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _add_download_info(result: Any, output_id: str, total_chars: int) -> Any:
|
|
66
|
+
download_url = f"{_download_base_url}/output/{output_id}.json"
|
|
67
|
+
info = {
|
|
68
|
+
"_output_truncated": True,
|
|
69
|
+
"_total_chars": total_chars,
|
|
70
|
+
"_output_id": output_id,
|
|
71
|
+
"_download_url": download_url,
|
|
72
|
+
"_download_hint": f"Output truncated. Run: curl -o .ida-mcp/{output_id}.json {download_url}",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if isinstance(result, dict):
|
|
76
|
+
return {**result, **info}
|
|
77
|
+
|
|
78
|
+
if isinstance(result, list) and result:
|
|
79
|
+
result = list(result)
|
|
80
|
+
if isinstance(result[0], dict):
|
|
81
|
+
result[0] = {**result[0], **info}
|
|
82
|
+
else:
|
|
83
|
+
result.insert(0, info)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
return {"_preview": result, **info}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_cached_output(output_id: str) -> Optional[Any]:
|
|
90
|
+
return _output_cache.get(output_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cache_output(output_id: str, data: Any) -> None:
|
|
94
|
+
if len(_output_cache) >= OUTPUT_CACHE_MAX_SIZE:
|
|
95
|
+
oldest_key = next(iter(_output_cache))
|
|
96
|
+
del _output_cache[oldest_key]
|
|
97
|
+
_output_cache[output_id] = data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _install_tools_call_patch() -> None:
|
|
101
|
+
original = MCP_SERVER.registry.methods["tools/call"]
|
|
102
|
+
|
|
103
|
+
def patched(
|
|
104
|
+
name: str, arguments: Optional[dict] = None, _meta: Optional[dict] = None
|
|
105
|
+
) -> dict:
|
|
106
|
+
response = original(name, arguments, _meta)
|
|
107
|
+
|
|
108
|
+
if response.get("isError"):
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
structured = response.get("structuredContent")
|
|
112
|
+
if structured is None:
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
serialized = json.dumps(structured)
|
|
116
|
+
if len(serialized) <= OUTPUT_LIMIT_MAX_CHARS:
|
|
117
|
+
return response
|
|
118
|
+
|
|
119
|
+
output_id = _generate_output_id()
|
|
120
|
+
_cache_output(output_id, structured)
|
|
121
|
+
|
|
122
|
+
preview = _truncate_value(structured)
|
|
123
|
+
preview = _add_download_info(preview, output_id, len(serialized))
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"structuredContent": preview,
|
|
127
|
+
"content": response.get("content", []),
|
|
128
|
+
"isError": False,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
MCP_SERVER.registry.methods["tools/call"] = patched
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Install the output limiting patch
|
|
135
|
+
_install_tools_call_patch()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ============================================================================
|
|
139
|
+
# Decorators
|
|
140
|
+
# ============================================================================
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def tool(func):
|
|
144
|
+
return MCP_SERVER.tool(func)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resource(uri):
|
|
148
|
+
return MCP_SERVER.resource(uri)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def unsafe(func):
|
|
152
|
+
MCP_UNSAFE.add(func.__name__)
|
|
153
|
+
return func
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ext(group: str):
|
|
157
|
+
"""Mark a tool as belonging to an extension group.
|
|
158
|
+
|
|
159
|
+
Tools in extension groups are hidden by default. Enable via ?ext=group query param.
|
|
160
|
+
Example: @ext("dbg") marks debugger tools that require ?ext=dbg to be visible.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def decorator(func):
|
|
164
|
+
if group not in MCP_EXTENSIONS:
|
|
165
|
+
MCP_EXTENSIONS[group] = set()
|
|
166
|
+
MCP_EXTENSIONS[group].add(func.__name__)
|
|
167
|
+
return func
|
|
168
|
+
|
|
169
|
+
return decorator
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"McpRpcRegistry",
|
|
174
|
+
"McpServer",
|
|
175
|
+
"McpToolError",
|
|
176
|
+
"McpHttpRequestHandler",
|
|
177
|
+
"MCP_SERVER",
|
|
178
|
+
"MCP_UNSAFE",
|
|
179
|
+
"MCP_EXTENSIONS",
|
|
180
|
+
"tool",
|
|
181
|
+
"unsafe",
|
|
182
|
+
"ext",
|
|
183
|
+
"resource",
|
|
184
|
+
"get_cached_output",
|
|
185
|
+
"set_download_base_url",
|
|
186
|
+
"get_download_base_url",
|
|
187
|
+
]
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""IDA MCP Server Manager
|
|
2
|
+
|
|
3
|
+
Manages multiple MCP server instances with support for:
|
|
4
|
+
- Starting/stopping individual servers
|
|
5
|
+
- Status monitoring
|
|
6
|
+
- Configuration-based server management
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional, Callable, TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from .config import ServerInstanceConfig, McpConfig, get_config, save_config
|
|
15
|
+
from .auth import AuthMiddleware
|
|
16
|
+
from .port_utils import try_serve_with_port_retry
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .zeromcp.mcp import McpServer
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ServerInstance:
|
|
26
|
+
"""Represents a running server instance"""
|
|
27
|
+
|
|
28
|
+
config: ServerInstanceConfig
|
|
29
|
+
server: Optional["McpServer"] = None
|
|
30
|
+
auth: AuthMiddleware = field(default_factory=AuthMiddleware)
|
|
31
|
+
error: Optional[str] = None
|
|
32
|
+
_actual_port: Optional[int] = field(default=None, repr=False)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def actual_port(self) -> int:
|
|
36
|
+
"""实际绑定的端口(可能因端口冲突自动递增)。"""
|
|
37
|
+
if self._actual_port is not None:
|
|
38
|
+
return self._actual_port
|
|
39
|
+
return self.config.port
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_running(self) -> bool:
|
|
43
|
+
return self.server is not None and self.server._running
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def status(self) -> str:
|
|
47
|
+
if self.error:
|
|
48
|
+
return f"error: {self.error}"
|
|
49
|
+
if self.is_running:
|
|
50
|
+
return "running"
|
|
51
|
+
return "stopped"
|
|
52
|
+
|
|
53
|
+
def to_status_dict(self) -> dict:
|
|
54
|
+
result = {
|
|
55
|
+
"instance_id": self.config.instance_id,
|
|
56
|
+
"host": self.config.host,
|
|
57
|
+
"port": self.config.port,
|
|
58
|
+
"status": self.status,
|
|
59
|
+
"auth_enabled": self.config.auth_enabled,
|
|
60
|
+
"address": self.config.address,
|
|
61
|
+
}
|
|
62
|
+
if self._actual_port is not None and self._actual_port != self.config.port:
|
|
63
|
+
result["actual_port"] = self._actual_port
|
|
64
|
+
result["address"] = f"{self.config.host}:{self._actual_port}"
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerManager:
|
|
69
|
+
"""Manages multiple MCP server instances.
|
|
70
|
+
|
|
71
|
+
Thread-safe manager for starting, stopping, and monitoring
|
|
72
|
+
multiple MCP server instances.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, create_server_func: Optional[Callable[[], "McpServer"]] = None):
|
|
76
|
+
"""Initialize the server manager.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
create_server_func: Factory function to create new McpServer instances.
|
|
80
|
+
If None, uses default McpServer constructor.
|
|
81
|
+
"""
|
|
82
|
+
self._instances: dict[str, ServerInstance] = {}
|
|
83
|
+
self._lock = threading.RLock()
|
|
84
|
+
self._create_server = create_server_func
|
|
85
|
+
self._request_handler_class: Optional[type] = None
|
|
86
|
+
|
|
87
|
+
def set_server_factory(self, factory: Callable[[], "McpServer"]) -> None:
|
|
88
|
+
"""Set the server factory function."""
|
|
89
|
+
self._create_server = factory
|
|
90
|
+
|
|
91
|
+
def set_request_handler(self, handler_class: type) -> None:
|
|
92
|
+
"""Set the request handler class for new servers."""
|
|
93
|
+
self._request_handler_class = handler_class
|
|
94
|
+
|
|
95
|
+
def _create_server_instance(self, config: ServerInstanceConfig) -> ServerInstance:
|
|
96
|
+
"""Create a new server instance from configuration."""
|
|
97
|
+
instance = ServerInstance(
|
|
98
|
+
config=config,
|
|
99
|
+
auth=AuthMiddleware(config.api_key, config.auth_enabled),
|
|
100
|
+
)
|
|
101
|
+
return instance
|
|
102
|
+
|
|
103
|
+
def add_server(self, config: ServerInstanceConfig) -> str:
|
|
104
|
+
"""Add a new server configuration.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
config: Server instance configuration
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The instance ID
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If instance ID already exists
|
|
114
|
+
"""
|
|
115
|
+
with self._lock:
|
|
116
|
+
if config.instance_id in self._instances:
|
|
117
|
+
raise ValueError(f"Server instance '{config.instance_id}' already exists")
|
|
118
|
+
|
|
119
|
+
instance = self._create_server_instance(config)
|
|
120
|
+
self._instances[config.instance_id] = instance
|
|
121
|
+
return config.instance_id
|
|
122
|
+
|
|
123
|
+
def remove_server(self, instance_id: str) -> bool:
|
|
124
|
+
"""Remove a server instance (stops it first if running).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
instance_id: The instance ID to remove
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if removed, False if not found
|
|
131
|
+
"""
|
|
132
|
+
with self._lock:
|
|
133
|
+
if instance_id not in self._instances:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
# Stop if running
|
|
137
|
+
self.stop_server(instance_id)
|
|
138
|
+
|
|
139
|
+
del self._instances[instance_id]
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
def start_server(self, instance_id: str) -> bool:
|
|
143
|
+
"""Start a server instance.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
instance_id: The instance ID to start
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if started successfully, False otherwise
|
|
150
|
+
"""
|
|
151
|
+
with self._lock:
|
|
152
|
+
instance = self._instances.get(instance_id)
|
|
153
|
+
if not instance:
|
|
154
|
+
logger.error(f"Server instance '{instance_id}' not found")
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if instance.is_running:
|
|
158
|
+
logger.info(f"Server '{instance_id}' is already running")
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
if not self._create_server:
|
|
162
|
+
logger.error("No server factory configured")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# Create new server
|
|
167
|
+
server = self._create_server()
|
|
168
|
+
|
|
169
|
+
# Configure authentication
|
|
170
|
+
if hasattr(server, '_auth'):
|
|
171
|
+
server._auth = instance.auth
|
|
172
|
+
|
|
173
|
+
# Start the server with port retry
|
|
174
|
+
kwargs = {}
|
|
175
|
+
if self._request_handler_class:
|
|
176
|
+
kwargs["request_handler"] = self._request_handler_class
|
|
177
|
+
|
|
178
|
+
actual_port, failed_ports = try_serve_with_port_retry(
|
|
179
|
+
server,
|
|
180
|
+
instance.config.host,
|
|
181
|
+
instance.config.port,
|
|
182
|
+
**kwargs,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
instance.server = server
|
|
186
|
+
instance.error = None
|
|
187
|
+
instance._actual_port = actual_port if actual_port != instance.config.port else None
|
|
188
|
+
|
|
189
|
+
if failed_ports:
|
|
190
|
+
logger.info(
|
|
191
|
+
f"Server '{instance_id}': port {instance.config.port} in use, "
|
|
192
|
+
f"auto-selected port {actual_port}"
|
|
193
|
+
)
|
|
194
|
+
logger.info(
|
|
195
|
+
f"Started server '{instance_id}' on {instance.config.host}:{actual_port}"
|
|
196
|
+
)
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
except OSError as e:
|
|
200
|
+
if e.errno in (48, 98, 10048): # Address already in use
|
|
201
|
+
instance.error = (
|
|
202
|
+
f"All ports {instance.config.port}-{instance.config.port + 9} are in use"
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
instance.error = str(e)
|
|
206
|
+
logger.error(f"Failed to start server '{instance_id}': {instance.error}")
|
|
207
|
+
return False
|
|
208
|
+
except Exception as e:
|
|
209
|
+
instance.error = str(e)
|
|
210
|
+
logger.error(f"Failed to start server '{instance_id}': {e}")
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def stop_server(self, instance_id: str) -> bool:
|
|
214
|
+
"""Stop a server instance.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
instance_id: The instance ID to stop
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if stopped, False if not found or not running
|
|
221
|
+
"""
|
|
222
|
+
with self._lock:
|
|
223
|
+
instance = self._instances.get(instance_id)
|
|
224
|
+
if not instance:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
if not instance.is_running:
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
instance.server.stop()
|
|
232
|
+
instance.server = None
|
|
233
|
+
instance.error = None
|
|
234
|
+
logger.info(f"Stopped server '{instance_id}'")
|
|
235
|
+
return True
|
|
236
|
+
except Exception as e:
|
|
237
|
+
instance.error = str(e)
|
|
238
|
+
logger.error(f"Error stopping server '{instance_id}': {e}")
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
def restart_server(self, instance_id: str) -> bool:
|
|
242
|
+
"""Restart a server instance.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
instance_id: The instance ID to restart
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if restarted successfully
|
|
249
|
+
"""
|
|
250
|
+
self.stop_server(instance_id)
|
|
251
|
+
return self.start_server(instance_id)
|
|
252
|
+
|
|
253
|
+
def get_status(self) -> dict[str, dict]:
|
|
254
|
+
"""Get status of all server instances.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Dictionary mapping instance IDs to their status
|
|
258
|
+
"""
|
|
259
|
+
with self._lock:
|
|
260
|
+
return {
|
|
261
|
+
instance_id: instance.to_status_dict()
|
|
262
|
+
for instance_id, instance in self._instances.items()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
def get_instance(self, instance_id: str) -> Optional[ServerInstance]:
|
|
266
|
+
"""Get a server instance by ID.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
instance_id: The instance ID
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The server instance or None
|
|
273
|
+
"""
|
|
274
|
+
with self._lock:
|
|
275
|
+
return self._instances.get(instance_id)
|
|
276
|
+
|
|
277
|
+
def load_from_config(self, config: Optional[McpConfig] = None) -> None:
|
|
278
|
+
"""Load server instances from configuration.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
config: Configuration to load from (uses global config if None)
|
|
282
|
+
"""
|
|
283
|
+
if config is None:
|
|
284
|
+
config = get_config()
|
|
285
|
+
|
|
286
|
+
with self._lock:
|
|
287
|
+
# Stop and remove existing instances
|
|
288
|
+
for instance_id in list(self._instances.keys()):
|
|
289
|
+
self.remove_server(instance_id)
|
|
290
|
+
|
|
291
|
+
# Add instances from config
|
|
292
|
+
for server_config in config.servers:
|
|
293
|
+
if server_config.enabled:
|
|
294
|
+
self.add_server(server_config)
|
|
295
|
+
|
|
296
|
+
def start_auto_servers(self) -> int:
|
|
297
|
+
"""Start all servers marked with auto_start.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Number of servers started
|
|
301
|
+
"""
|
|
302
|
+
count = 0
|
|
303
|
+
with self._lock:
|
|
304
|
+
for instance_id, instance in self._instances.items():
|
|
305
|
+
if instance.config.auto_start and not instance.is_running:
|
|
306
|
+
if self.start_server(instance_id):
|
|
307
|
+
count += 1
|
|
308
|
+
return count
|
|
309
|
+
|
|
310
|
+
def stop_all(self) -> None:
|
|
311
|
+
"""Stop all running server instances."""
|
|
312
|
+
with self._lock:
|
|
313
|
+
for instance_id in list(self._instances.keys()):
|
|
314
|
+
self.stop_server(instance_id)
|
|
315
|
+
|
|
316
|
+
def __len__(self) -> int:
|
|
317
|
+
return len(self._instances)
|
|
318
|
+
|
|
319
|
+
def __contains__(self, instance_id: str) -> bool:
|
|
320
|
+
return instance_id in self._instances
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# Global server manager instance
|
|
324
|
+
_manager: Optional[ServerManager] = None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def get_server_manager() -> ServerManager:
|
|
328
|
+
"""Get the global server manager instance."""
|
|
329
|
+
global _manager
|
|
330
|
+
if _manager is None:
|
|
331
|
+
_manager = ServerManager()
|
|
332
|
+
return _manager
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = [
|
|
336
|
+
"ServerInstance",
|
|
337
|
+
"ServerManager",
|
|
338
|
+
"get_server_manager",
|
|
339
|
+
]
|