py-mcpdock-cli 1.0.13__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.
- cli/__init__.py +0 -0
- cli/commands/__init__.py +0 -0
- cli/commands/install.py +182 -0
- cli/commands/run.py +148 -0
- cli/config/__init__.py +0 -0
- cli/config/app_config.py +11 -0
- cli/config/client_config.py +248 -0
- cli/main.py +12 -0
- cli/mock_servers.json +186 -0
- cli/registry.py +136 -0
- cli/runners/__init__.py +21 -0
- cli/runners/command_runner.py +172 -0
- cli/runners/stdio_runner.py +494 -0
- cli/runners/stream_http_runner.py +166 -0
- cli/runners/ws_runner.py +43 -0
- cli/types/__init__.py +0 -0
- cli/types/registry.py +69 -0
- cli/utils/__init__.py +0 -0
- cli/utils/client.py +0 -0
- cli/utils/config.py +441 -0
- cli/utils/logger.py +79 -0
- cli/utils/runtime.py +163 -0
- py_mcpdock_cli-1.0.13.dist-info/METADATA +28 -0
- py_mcpdock_cli-1.0.13.dist-info/RECORD +27 -0
- py_mcpdock_cli-1.0.13.dist-info/WHEEL +5 -0
- py_mcpdock_cli-1.0.13.dist-info/entry_points.txt +2 -0
- py_mcpdock_cli-1.0.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,494 @@
|
|
1
|
+
"""
|
2
|
+
STDIO Runner implementation for local MCP servers
|
3
|
+
|
4
|
+
This module provides functionality for creating and managing a connection with an MCP server
|
5
|
+
through standard input/output pipes.
|
6
|
+
"""
|
7
|
+
import json
|
8
|
+
import asyncio
|
9
|
+
from logging import error
|
10
|
+
import signal
|
11
|
+
import sys
|
12
|
+
import os
|
13
|
+
import anyio
|
14
|
+
from typing import Dict, Any, Optional, Awaitable
|
15
|
+
from rich import print as rprint
|
16
|
+
from contextlib import AsyncExitStack
|
17
|
+
from pydantic import BaseModel
|
18
|
+
from ..utils.logger import verbose
|
19
|
+
from ..types.registry import RegistryServer
|
20
|
+
from ..utils.runtime import get_runtime_environment
|
21
|
+
from mcp import StdioServerParameters
|
22
|
+
from mcp.client.stdio import stdio_client
|
23
|
+
from mcp.types import ClientRequest, ServerRequest, JSONRPCRequest, JSONRPCMessage
|
24
|
+
|
25
|
+
|
26
|
+
# 定义一个通用的 Model,用于接收任何 JSON 响应
|
27
|
+
class DictModel(BaseModel):
|
28
|
+
@classmethod
|
29
|
+
def model_validate(cls, value):
|
30
|
+
if isinstance(value, dict):
|
31
|
+
return value
|
32
|
+
return dict(value)
|
33
|
+
|
34
|
+
|
35
|
+
# 处理客户端请求
|
36
|
+
async def process_client_request(message, session):
|
37
|
+
"""处理从客户端接收的请求并转发到服务器"""
|
38
|
+
original_id = message.get("id")
|
39
|
+
method = message.get("method")
|
40
|
+
params = message.get("params", {})
|
41
|
+
|
42
|
+
# 添加特殊处理,记录初始化请求的详细信息
|
43
|
+
if method == "initialize":
|
44
|
+
verbose(f"[Runner] 收到初始化请求 ID: {original_id}, 详细内容: {json.dumps(message)}")
|
45
|
+
|
46
|
+
# 对初始化请求使用SDK的initialize方法,而不是简单转发
|
47
|
+
try:
|
48
|
+
verbose("[Runner] 使用SDK的initialize方法处理初始化请求")
|
49
|
+
# 直接调用session.initialize()获取规范的响应
|
50
|
+
init_result = await session.initialize()
|
51
|
+
verbose(f"[Runner] SDK初始化原始响应类型: {type(init_result)}")
|
52
|
+
verbose(f"[Runner] SDK初始化原始响应属性: {dir(init_result)}")
|
53
|
+
|
54
|
+
# 检查响应内容的具体格式
|
55
|
+
if hasattr(init_result, "model_dump"):
|
56
|
+
result_content = init_result.model_dump()
|
57
|
+
verbose(f"[Runner] 使用model_dump()解析结果: {json.dumps(result_content)[:200]}...")
|
58
|
+
elif hasattr(init_result, "dict"):
|
59
|
+
result_content = init_result.dict()
|
60
|
+
verbose(f"[Runner] 使用dict()解析结果: {json.dumps(result_content)[:200]}...")
|
61
|
+
else:
|
62
|
+
result_content = init_result
|
63
|
+
verbose(
|
64
|
+
f"[Runner] 直接使用结果: {json.dumps(result_content)[:200] if isinstance(result_content, dict) else str(result_content)[:200]}...")
|
65
|
+
|
66
|
+
# 构造完整的JSON-RPC响应
|
67
|
+
response = json.dumps({
|
68
|
+
"jsonrpc": "2.0",
|
69
|
+
"id": original_id,
|
70
|
+
"result": result_content
|
71
|
+
})
|
72
|
+
|
73
|
+
verbose(f"[Runner] 通过SDK获取到规范的初始化响应: {response[:200]}...")
|
74
|
+
|
75
|
+
# 再额外发送一个initialized通知给上游客户端吗?
|
76
|
+
# 通常客户端接收到initialize响应后,会自己发送initialized通知
|
77
|
+
# 所以这里不需要主动发送
|
78
|
+
|
79
|
+
return response
|
80
|
+
except Exception as e:
|
81
|
+
error(f"[Runner] SDK初始化失败: {str(e)}")
|
82
|
+
import traceback
|
83
|
+
error(f"[Runner] 异常堆栈: {traceback.format_exc()}")
|
84
|
+
# 如果SDK初始化失败,回退到常规请求处理
|
85
|
+
verbose("[Runner] 回退到常规请求处理方式")
|
86
|
+
|
87
|
+
verbose(f"[stdin] Processing request with id: {original_id}, method: {method}")
|
88
|
+
|
89
|
+
# 常规请求处理:确定请求类型和构建请求对象
|
90
|
+
req_obj = create_request_object(message, method)
|
91
|
+
|
92
|
+
# 发送请求并处理响应
|
93
|
+
try:
|
94
|
+
verbose(f"[Runner] 向下游服务器发送请求,method: {method}, id: {original_id}")
|
95
|
+
result = await send_request_with_timeout(session, req_obj, original_id)
|
96
|
+
|
97
|
+
if method == "initialize":
|
98
|
+
verbose(f"[Runner] 收到初始化响应: {result}")
|
99
|
+
|
100
|
+
return result
|
101
|
+
except Exception as e:
|
102
|
+
error(f"[Runner] 请求处理异常 ({method}): {str(e)}")
|
103
|
+
raise
|
104
|
+
|
105
|
+
|
106
|
+
# 创建请求对象
|
107
|
+
def create_request_object(message, method):
|
108
|
+
"""根据方法类型创建适当的请求对象"""
|
109
|
+
# 作为代理,我们不需要严格验证方法是否符合标准列表
|
110
|
+
# 直接创建ClientRequest对象,透明转发所有请求
|
111
|
+
msg = dict(message)
|
112
|
+
msg.pop("jsonrpc", None) # 移除 jsonrpc 字段,SDK会自动添加
|
113
|
+
msg.pop("id", None) # 移除 id 字段,我们会在响应中重新添加
|
114
|
+
|
115
|
+
try:
|
116
|
+
# 尝试创建 ClientRequest
|
117
|
+
return ClientRequest(**msg)
|
118
|
+
except Exception as e:
|
119
|
+
# 如果创建失败,回退到 ServerRequest
|
120
|
+
verbose(f"[Runner] 创建 ClientRequest 失败,回退到 ServerRequest: {str(e)}")
|
121
|
+
return ServerRequest(method=method, params=message.get("params", {}))
|
122
|
+
|
123
|
+
|
124
|
+
async def send_request_with_timeout(session, req_obj, original_id, timeout_seconds=60):
|
125
|
+
"""发送请求并处理超时和错误情况"""
|
126
|
+
try:
|
127
|
+
# 初始化 resp 为 None,防止超时时未定义
|
128
|
+
resp = None
|
129
|
+
# 使用超时机制
|
130
|
+
with anyio.move_on_after(timeout_seconds):
|
131
|
+
# 记录请求信息
|
132
|
+
verbose(f"[Runner] 发送请求,原始ID={original_id}, 方法={req_obj.method if hasattr(req_obj, 'method') else '未知'}")
|
133
|
+
|
134
|
+
# 发送请求并等待响应
|
135
|
+
resp = await session.send_request(req_obj, DictModel)
|
136
|
+
|
137
|
+
if resp:
|
138
|
+
# 直接使用原始ID构造响应
|
139
|
+
return json.dumps({
|
140
|
+
"id": original_id,
|
141
|
+
"jsonrpc": "2.0",
|
142
|
+
"result": resp
|
143
|
+
})
|
144
|
+
else:
|
145
|
+
return json.dumps({
|
146
|
+
"id": original_id,
|
147
|
+
"jsonrpc": "2.0",
|
148
|
+
"error": {
|
149
|
+
"code": -32000,
|
150
|
+
"message": "Request timed out or empty response"
|
151
|
+
}
|
152
|
+
})
|
153
|
+
|
154
|
+
except Exception as e:
|
155
|
+
# 处理请求错误
|
156
|
+
error_msg = str(e)
|
157
|
+
error_code = -32603
|
158
|
+
|
159
|
+
# 分类错误类型
|
160
|
+
if "timed out" in error_msg.lower():
|
161
|
+
error_code = -32001
|
162
|
+
elif "connection" in error_msg.lower():
|
163
|
+
error_code = -32002
|
164
|
+
|
165
|
+
return json.dumps({
|
166
|
+
"id": original_id,
|
167
|
+
"jsonrpc": "2.0",
|
168
|
+
"error": {
|
169
|
+
"code": error_code,
|
170
|
+
"message": f"Request failed: {error_msg}"
|
171
|
+
}
|
172
|
+
})
|
173
|
+
|
174
|
+
|
175
|
+
# 初始化 MCP 会话
|
176
|
+
async def initialize_session(session):
|
177
|
+
"""初始化 MCP 协议会话,转发上游客户端的初始化请求到下游服务器"""
|
178
|
+
try:
|
179
|
+
# 关键点: 作为代理,我们不应该主动调用 session.initialize()
|
180
|
+
# 上游客户端会发送初始化请求,我们应该在 handle_stdin 函数中处理
|
181
|
+
verbose("[Runner] MCP 代理准备就绪,等待上游客户端的初始化请求...")
|
182
|
+
return True
|
183
|
+
except Exception as init_error:
|
184
|
+
error_msg = f"代理初始化失败: {str(init_error)}"
|
185
|
+
error(f"[Runner] {error_msg}")
|
186
|
+
return False
|
187
|
+
|
188
|
+
|
189
|
+
# 处理来自标准输入的消息
|
190
|
+
async def handle_stdin(session, is_shutting_down):
|
191
|
+
"""处理从标准输入接收的消息并转发到服务器"""
|
192
|
+
loop = asyncio.get_event_loop()
|
193
|
+
|
194
|
+
while not is_shutting_down:
|
195
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
196
|
+
if not line:
|
197
|
+
break
|
198
|
+
|
199
|
+
try:
|
200
|
+
message = json.loads(line)
|
201
|
+
verbose(f"[stdin] Received message: {line.strip()}")
|
202
|
+
|
203
|
+
method = message.get("method", "")
|
204
|
+
# 根据消息类型处理
|
205
|
+
if "id" in message: # 这是请求,需要响应
|
206
|
+
response = await process_client_request(message, session)
|
207
|
+
sys.stdout.write(response + "\n")
|
208
|
+
sys.stdout.flush()
|
209
|
+
verbose(f"[stdin] Response sent for method: {method}")
|
210
|
+
else: # 这是通知,不需要响应
|
211
|
+
await session.send(message)
|
212
|
+
verbose(f"[stdin] Notification sent for method: {method}")
|
213
|
+
|
214
|
+
verbose(f"[stdin] Processed: {line.strip()}")
|
215
|
+
|
216
|
+
except json.JSONDecodeError as e:
|
217
|
+
error(f"[stdin] JSON decode error: {e}")
|
218
|
+
except Exception as e:
|
219
|
+
error(f"[stdin] Error processing input: {e}")
|
220
|
+
# 如果是请求(有ID),才需要发送错误响应
|
221
|
+
try:
|
222
|
+
if 'message' in locals() and isinstance(message, dict) and "id" in message:
|
223
|
+
error_resp = json.dumps({
|
224
|
+
"jsonrpc": "2.0",
|
225
|
+
"id": message.get("id"),
|
226
|
+
"error": {
|
227
|
+
"code": -32700,
|
228
|
+
"message": f"Parse error: {str(e)}"
|
229
|
+
}
|
230
|
+
})
|
231
|
+
sys.stdout.write(error_resp + "\n")
|
232
|
+
sys.stdout.flush()
|
233
|
+
verbose(f"[stdin] Sent error response for parse error")
|
234
|
+
except Exception as err:
|
235
|
+
error(f"[stdin] Failed to send error response: {err}")
|
236
|
+
|
237
|
+
|
238
|
+
# 处理单个服务器消息
|
239
|
+
async def handle_single_server_message(data):
|
240
|
+
"""处理单个从服务器接收的消息并输出到标准输出"""
|
241
|
+
try:
|
242
|
+
# 记录接收到的原始数据类型,帮助调试
|
243
|
+
verbose(f"[server_raw] Received data type: {type(data)}")
|
244
|
+
|
245
|
+
# 尝试获取原始数据的字符串表示用于调试
|
246
|
+
raw_data_str = str(data)
|
247
|
+
if len(raw_data_str) > 500:
|
248
|
+
raw_data_str = raw_data_str[:500] + "..."
|
249
|
+
verbose(f"[server_raw] 原始数据: {raw_data_str}")
|
250
|
+
|
251
|
+
# 根据数据类型进行处理
|
252
|
+
if hasattr(data, "model_dump"):
|
253
|
+
content = data.model_dump()
|
254
|
+
verbose(f"[server_raw] Processed pydantic v2 model: {type(data)}")
|
255
|
+
elif hasattr(data, "dict"):
|
256
|
+
content = data.dict()
|
257
|
+
verbose(f"[server_raw] Processed pydantic v1 model: {type(data)}")
|
258
|
+
elif isinstance(data, dict):
|
259
|
+
content = data
|
260
|
+
verbose(f"[server_raw] Processed dict with {len(data)} keys")
|
261
|
+
else:
|
262
|
+
# 尝试转换为字符串,然后解析为JSON
|
263
|
+
try:
|
264
|
+
content = json.loads(str(data))
|
265
|
+
verbose(f"[server_raw] Converted to JSON: {type(data)}")
|
266
|
+
except:
|
267
|
+
content = {"data": str(data)}
|
268
|
+
verbose(f"[server_raw] Used raw string for unknown type: {type(data)}")
|
269
|
+
|
270
|
+
# 检查是否是初始化响应
|
271
|
+
is_init_response = False
|
272
|
+
if isinstance(content, dict):
|
273
|
+
if "result" in content and isinstance(content["result"], dict):
|
274
|
+
result = content["result"]
|
275
|
+
if "protocolVersion" in result or "serverInfo" in result:
|
276
|
+
is_init_response = True
|
277
|
+
verbose(f"[server] 检测到初始化响应: {json.dumps(content)[:200]}...")
|
278
|
+
|
279
|
+
# 特别检查是否包含tools字段,这对于VSCode非常重要
|
280
|
+
if "tools" in result:
|
281
|
+
verbose(f"[server] 检测到tools字段,工具数量: {len(result['tools'])}")
|
282
|
+
|
283
|
+
# 检查数据是否已经是标准的 JSON-RPC 消息
|
284
|
+
if isinstance(content, dict):
|
285
|
+
if "jsonrpc" in content and ("id" in content or "method" in content):
|
286
|
+
# 已经是标准格式,直接输出
|
287
|
+
output = json.dumps(content)
|
288
|
+
verbose(f"[server] Standard JSON-RPC message detected, id: {content.get('id')}")
|
289
|
+
|
290
|
+
elif "result" in content and not "jsonrpc" in content:
|
291
|
+
# 是结果但缺少 jsonrpc 字段,构造标准响应
|
292
|
+
output = json.dumps({
|
293
|
+
"jsonrpc": "2.0",
|
294
|
+
"id": 1, # 默认ID,应该不会被用到
|
295
|
+
"result": content["result"] if "result" in content else content
|
296
|
+
})
|
297
|
+
verbose(f"[server] Fixed response format by adding jsonrpc")
|
298
|
+
|
299
|
+
else:
|
300
|
+
# 其他类型的消息,包装为通知
|
301
|
+
output = json.dumps(content)
|
302
|
+
verbose("[server] Passing through data as-is")
|
303
|
+
else:
|
304
|
+
# 非字典类型,直接序列化
|
305
|
+
output = json.dumps(content)
|
306
|
+
|
307
|
+
# 写入 stdout 并立即刷新,确保 VS Code 能收到
|
308
|
+
sys.stdout.write(output + "\n")
|
309
|
+
sys.stdout.flush()
|
310
|
+
verbose(f"[server] Response sent to stdout: {output}")
|
311
|
+
|
312
|
+
except Exception as e:
|
313
|
+
error(f"[server] Error processing server message: {e}")
|
314
|
+
import traceback
|
315
|
+
error(f"[server] 异常堆栈: {traceback.format_exc()}")
|
316
|
+
# 尝试发送错误响应
|
317
|
+
try:
|
318
|
+
error_resp = json.dumps({
|
319
|
+
"jsonrpc": "2.0",
|
320
|
+
"id": 1, # 使用默认ID
|
321
|
+
"error": {
|
322
|
+
"code": -32603,
|
323
|
+
"message": f"Internal error: {str(e)}"
|
324
|
+
}
|
325
|
+
})
|
326
|
+
sys.stdout.write(error_resp + "\n")
|
327
|
+
sys.stdout.flush()
|
328
|
+
verbose(f"[server] Sent error response due to: {e}")
|
329
|
+
except:
|
330
|
+
error("[server] Failed to send error response")
|
331
|
+
|
332
|
+
|
333
|
+
async def create_stdio_runner(
|
334
|
+
server_details: RegistryServer,
|
335
|
+
config: Dict[str, Any],
|
336
|
+
api_key: Optional[str] = None,
|
337
|
+
analytics_enabled: bool = False
|
338
|
+
) -> Awaitable[None]:
|
339
|
+
"""创建并运行 STDIO 代理服务器"""
|
340
|
+
verbose(f"Starting STDIO proxy runner: {server_details.qualifiedName}")
|
341
|
+
is_shutting_down = False
|
342
|
+
exit_stack = AsyncExitStack()
|
343
|
+
|
344
|
+
def handle_error(error: Exception, context: str) -> Exception:
|
345
|
+
verbose(f"[Runner] {context}: {error}")
|
346
|
+
return error
|
347
|
+
|
348
|
+
async def cleanup() -> None:
|
349
|
+
nonlocal is_shutting_down
|
350
|
+
if is_shutting_down:
|
351
|
+
verbose("[Runner] Cleanup already in progress, skipping...")
|
352
|
+
return
|
353
|
+
verbose("[Runner] Starting cleanup...")
|
354
|
+
is_shutting_down = True
|
355
|
+
try:
|
356
|
+
await exit_stack.aclose()
|
357
|
+
verbose("[Runner] Resources closed successfully")
|
358
|
+
except Exception as error:
|
359
|
+
handle_error(error, "Error during cleanup")
|
360
|
+
verbose("[Runner] Cleanup completed")
|
361
|
+
|
362
|
+
def handle_sigint(sig, frame):
|
363
|
+
verbose("[Runner] Received interrupt signal, shutting down...")
|
364
|
+
asyncio.create_task(cleanup())
|
365
|
+
# 立即打印一条确认消息,让用户知道CTRL+C已被捕获
|
366
|
+
print("\n[CTRL+C] 正在关闭服务,请稍候...", flush=True)
|
367
|
+
# 可选:设置一个短暂的超时,然后强制退出
|
368
|
+
import threading
|
369
|
+
threading.Timer(2.0, lambda: os._exit(0)).start()
|
370
|
+
|
371
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
372
|
+
|
373
|
+
# 获取连接配置
|
374
|
+
stdio_connection = next((conn for conn in server_details.connections if conn.type == "stdio"), None)
|
375
|
+
if not stdio_connection:
|
376
|
+
raise ValueError("No STDIO connection found")
|
377
|
+
|
378
|
+
from ..registry import fetch_connection
|
379
|
+
formatted_config = config
|
380
|
+
verbose(f"Formatted config: {formatted_config}")
|
381
|
+
server_config = await fetch_connection(server_details.qualifiedName, formatted_config)
|
382
|
+
|
383
|
+
if not server_config or not isinstance(server_config, dict):
|
384
|
+
raise ValueError("Failed to get valid stdio server configuration")
|
385
|
+
|
386
|
+
command = server_config.get("command", "python")
|
387
|
+
args = server_config.get("args", ["-m", server_details.qualifiedName])
|
388
|
+
env_vars = server_config.get("env", {})
|
389
|
+
env = get_runtime_environment(env_vars)
|
390
|
+
|
391
|
+
verbose(f"Using environment: {json.dumps({k: '***' if k.lower().endswith('key') else v for k, v in env.items()})}")
|
392
|
+
verbose(f"Executing: {command} {' '.join(args)}")
|
393
|
+
|
394
|
+
try:
|
395
|
+
# 创建服务器进程
|
396
|
+
server_params = StdioServerParameters(
|
397
|
+
command=command,
|
398
|
+
args=args,
|
399
|
+
env=env,
|
400
|
+
encoding="utf-8"
|
401
|
+
)
|
402
|
+
|
403
|
+
verbose(f"Setting up stdio proxy client for {server_details.qualifiedName}")
|
404
|
+
async with stdio_client(server_params, errlog=sys.stderr) as (read_stream, write_stream):
|
405
|
+
verbose("Stdio proxy client connection established")
|
406
|
+
|
407
|
+
# 创建 MCP 客户端会话
|
408
|
+
from mcp import ClientSession
|
409
|
+
session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
|
410
|
+
|
411
|
+
# 注册消息处理回调
|
412
|
+
def handle_server_message(msg):
|
413
|
+
rprint(f"[magenta][server][/magenta] {json.dumps(msg, ensure_ascii=False)}")
|
414
|
+
session.on_message = handle_server_message
|
415
|
+
|
416
|
+
# 初始化 MCP 协议
|
417
|
+
if not await initialize_session(session):
|
418
|
+
return
|
419
|
+
|
420
|
+
# 使用简单的同步阻塞循环处理输入和服务器消息
|
421
|
+
verbose("[Runner] 开始处理循环,使用同步阻塞模式")
|
422
|
+
|
423
|
+
# 打印启动消息
|
424
|
+
rprint("[cyan]MCP client running. Press Ctrl+C to stop.[/cyan]")
|
425
|
+
|
426
|
+
# 循环处理客户端请求,直到关闭
|
427
|
+
while not is_shutting_down:
|
428
|
+
try:
|
429
|
+
# 从标准输入读取一行 (同步阻塞)
|
430
|
+
line = sys.stdin.readline()
|
431
|
+
if not line:
|
432
|
+
verbose("[Runner] 标准输入关闭,结束处理")
|
433
|
+
break
|
434
|
+
|
435
|
+
# 处理客户端请求
|
436
|
+
message = json.loads(line)
|
437
|
+
verbose(f"[stdin] Received message: {line.strip()}")
|
438
|
+
|
439
|
+
method = message.get("method", "")
|
440
|
+
# 根据消息类型处理
|
441
|
+
if "id" in message: # 这是请求,需要响应
|
442
|
+
response = await process_client_request(message, session)
|
443
|
+
sys.stdout.write(response + "\n")
|
444
|
+
sys.stdout.flush()
|
445
|
+
verbose(f"[stdin] Response sent for method: {method}")
|
446
|
+
else: # 这是通知,不需要响应
|
447
|
+
# 创建通知对象并发送
|
448
|
+
notification_obj = create_request_object(message, method)
|
449
|
+
await session.send_notification(notification_obj)
|
450
|
+
verbose(f"[stdin] Notification sent for method: {method}")
|
451
|
+
|
452
|
+
verbose(f"[stdin] Processed: {line.strip()}")
|
453
|
+
|
454
|
+
except json.JSONDecodeError as e:
|
455
|
+
error(f"[stdin] JSON decode error: {e}")
|
456
|
+
except Exception as e:
|
457
|
+
error(f"[stdin] Error processing input: {e}")
|
458
|
+
# 如果是请求(有ID),才需要发送错误响应
|
459
|
+
try:
|
460
|
+
if 'message' in locals() and isinstance(message, dict) and "id" in message:
|
461
|
+
error_resp = json.dumps({
|
462
|
+
"jsonrpc": "2.0",
|
463
|
+
"id": message.get("id"),
|
464
|
+
"error": {
|
465
|
+
"code": -32700,
|
466
|
+
"message": f"Parse error: {str(e)}"
|
467
|
+
}
|
468
|
+
})
|
469
|
+
sys.stdout.write(error_resp + "\n")
|
470
|
+
sys.stdout.flush()
|
471
|
+
verbose(f"[stdin] Sent error response for parse error")
|
472
|
+
except Exception as err:
|
473
|
+
error(f"[stdin] Failed to send error response: {err}")
|
474
|
+
|
475
|
+
# 检查是否有服务器消息需要处理
|
476
|
+
# 注意:这部分仍需异步,因为我们需要非阻塞地检查服务器消息
|
477
|
+
try:
|
478
|
+
# 使用超时机制非阻塞地检查服务器消息
|
479
|
+
with anyio.fail_after(0.1): # 设置很短的超时
|
480
|
+
message = await read_stream.receive()
|
481
|
+
await handle_single_server_message(message)
|
482
|
+
except TimeoutError:
|
483
|
+
# 超时表示没有消息,继续处理客户端请求
|
484
|
+
pass
|
485
|
+
except Exception as e:
|
486
|
+
error(f"[Runner] 处理服务器消息异常: {e}")
|
487
|
+
|
488
|
+
verbose("[Runner] 处理循环结束")
|
489
|
+
|
490
|
+
except Exception as e:
|
491
|
+
rprint(f"[red]Error running stdio proxy: {e}[/red]")
|
492
|
+
raise
|
493
|
+
finally:
|
494
|
+
await cleanup()
|
@@ -0,0 +1,166 @@
|
|
1
|
+
"""
|
2
|
+
Streamable HTTP Runner implementation for remote MCP servers (streaming HTTP transport, stdio proxy)
|
3
|
+
"""
|
4
|
+
import json
|
5
|
+
import asyncio
|
6
|
+
import signal
|
7
|
+
import sys
|
8
|
+
import time
|
9
|
+
from typing import Dict, Any, Optional, Awaitable
|
10
|
+
|
11
|
+
from rich import print as rprint
|
12
|
+
from ..utils.logger import verbose
|
13
|
+
import aiohttp
|
14
|
+
|
15
|
+
IDLE_TIMEOUT = 10 * 60 # 10分钟,单位:秒
|
16
|
+
MAX_RETRIES = 5
|
17
|
+
RETRY_DELAY = 2 # 秒,指数退避基数
|
18
|
+
|
19
|
+
|
20
|
+
async def create_stream_http_runner(
|
21
|
+
base_url: str,
|
22
|
+
config: Dict[str, Any],
|
23
|
+
api_key: Optional[str] = None
|
24
|
+
) -> Awaitable[None]:
|
25
|
+
"""
|
26
|
+
Creates a streamable HTTP runner for connecting to a remote server using stdio as proxy.
|
27
|
+
"""
|
28
|
+
retry_count = 0
|
29
|
+
stdin_buffer = ""
|
30
|
+
is_ready = False
|
31
|
+
is_shutting_down = False
|
32
|
+
is_client_initiated_close = False
|
33
|
+
last_activity = time.time()
|
34
|
+
idle_check_task = None
|
35
|
+
session = None
|
36
|
+
post_task = None
|
37
|
+
response_task = None
|
38
|
+
stop_event = asyncio.Event()
|
39
|
+
|
40
|
+
def log_with_timestamp(msg):
|
41
|
+
verbose(f"[Runner] {msg}")
|
42
|
+
|
43
|
+
def update_last_activity():
|
44
|
+
nonlocal last_activity
|
45
|
+
last_activity = time.time()
|
46
|
+
|
47
|
+
async def cleanup():
|
48
|
+
nonlocal is_shutting_down, is_client_initiated_close, idle_check_task, session, post_task, response_task
|
49
|
+
if is_shutting_down:
|
50
|
+
log_with_timestamp("Cleanup already in progress, skipping duplicate cleanup...")
|
51
|
+
return
|
52
|
+
log_with_timestamp("Starting cleanup process...")
|
53
|
+
is_shutting_down = True
|
54
|
+
is_client_initiated_close = True
|
55
|
+
if idle_check_task:
|
56
|
+
idle_check_task.cancel()
|
57
|
+
if post_task:
|
58
|
+
post_task.cancel()
|
59
|
+
if response_task:
|
60
|
+
response_task.cancel()
|
61
|
+
if session:
|
62
|
+
await session.close()
|
63
|
+
log_with_timestamp("Cleanup completed")
|
64
|
+
stop_event.set()
|
65
|
+
|
66
|
+
async def handle_exit(*_):
|
67
|
+
log_with_timestamp("Received exit signal, initiating shutdown...")
|
68
|
+
await cleanup()
|
69
|
+
sys.exit(0)
|
70
|
+
|
71
|
+
def start_idle_check():
|
72
|
+
nonlocal idle_check_task
|
73
|
+
|
74
|
+
async def idle_checker():
|
75
|
+
while True:
|
76
|
+
await asyncio.sleep(60)
|
77
|
+
idle_time = time.time() - last_activity
|
78
|
+
if idle_time >= IDLE_TIMEOUT:
|
79
|
+
log_with_timestamp(f"Connection idle for {int(idle_time // 60)} minutes, initiating shutdown")
|
80
|
+
await handle_exit()
|
81
|
+
break
|
82
|
+
idle_check_task = asyncio.create_task(idle_checker())
|
83
|
+
|
84
|
+
async def post_stream():
|
85
|
+
nonlocal is_ready, retry_count, session, response_task
|
86
|
+
log_with_timestamp(f"Connecting to HTTP stream endpoint: {base_url}")
|
87
|
+
url = base_url # TODO: 按需拼接 config/api_key
|
88
|
+
headers = {"Content-Type": "application/json"}
|
89
|
+
if api_key:
|
90
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
91
|
+
# 用队列做流式输入
|
92
|
+
input_queue = asyncio.Queue()
|
93
|
+
|
94
|
+
async def stdin_reader():
|
95
|
+
nonlocal stdin_buffer
|
96
|
+
while not is_shutting_down:
|
97
|
+
data = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.buffer.readline)
|
98
|
+
if not data:
|
99
|
+
log_with_timestamp("STDIN closed (client disconnected)")
|
100
|
+
await handle_exit()
|
101
|
+
break
|
102
|
+
update_last_activity()
|
103
|
+
stdin_buffer += data.decode("utf-8")
|
104
|
+
lines = stdin_buffer.split("\n")
|
105
|
+
stdin_buffer = lines.pop() if lines else ""
|
106
|
+
for line in [l for l in lines if l.strip()]:
|
107
|
+
try:
|
108
|
+
await input_queue.put(json.loads(line))
|
109
|
+
except Exception as e:
|
110
|
+
log_with_timestamp(f"Failed to parse stdin line: {e}")
|
111
|
+
|
112
|
+
async def gen():
|
113
|
+
# 先发 config
|
114
|
+
yield json.dumps({"config": config}) + "\n"
|
115
|
+
while not is_shutting_down:
|
116
|
+
msg = await input_queue.get()
|
117
|
+
yield json.dumps(msg) + "\n"
|
118
|
+
session = aiohttp.ClientSession()
|
119
|
+
try:
|
120
|
+
resp = await session.post(url, data=gen(), headers=headers, timeout=None)
|
121
|
+
is_ready = True
|
122
|
+
log_with_timestamp("HTTP stream connection established")
|
123
|
+
start_idle_check()
|
124
|
+
# 启动响应流处理
|
125
|
+
response_task = asyncio.create_task(response_stream(resp))
|
126
|
+
# 启动stdin读取
|
127
|
+
await stdin_reader()
|
128
|
+
except Exception as e:
|
129
|
+
log_with_timestamp(f"HTTP stream error: {e}")
|
130
|
+
await handle_exit()
|
131
|
+
|
132
|
+
async def response_stream(resp):
|
133
|
+
try:
|
134
|
+
async for line in resp.content:
|
135
|
+
update_last_activity()
|
136
|
+
try:
|
137
|
+
# 只处理非空行
|
138
|
+
s = line.decode("utf-8").strip()
|
139
|
+
if not s:
|
140
|
+
continue
|
141
|
+
# 允许服务端返回多行JSON
|
142
|
+
for l in s.split("\n"):
|
143
|
+
if l.strip():
|
144
|
+
print(l)
|
145
|
+
except Exception as e:
|
146
|
+
log_with_timestamp(f"Error handling response: {e}")
|
147
|
+
except Exception as e:
|
148
|
+
log_with_timestamp(f"HTTP response stream error: {e}")
|
149
|
+
await handle_exit()
|
150
|
+
|
151
|
+
# 信号处理
|
152
|
+
loop = asyncio.get_running_loop()
|
153
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
154
|
+
try:
|
155
|
+
loop.add_signal_handler(sig, lambda: asyncio.create_task(handle_exit()))
|
156
|
+
except NotImplementedError:
|
157
|
+
signal.signal(sig, lambda *_: asyncio.create_task(handle_exit()))
|
158
|
+
|
159
|
+
# 启动主流式POST任务
|
160
|
+
post_task = asyncio.create_task(post_stream())
|
161
|
+
rprint(f"[green]Streamable HTTP connection established: {base_url}[/green]")
|
162
|
+
rprint("Press Ctrl+C to stop the connection")
|
163
|
+
await stop_event.wait()
|
164
|
+
|
165
|
+
# 用法示例:
|
166
|
+
# await create_stream_http_runner("https://example.com/stream", {"foo": "bar"}, api_key="xxx")
|