py-mcpdock-cli 1.0.13__py3-none-any.whl → 1.0.18__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/utils/config.py CHANGED
@@ -1,7 +1,11 @@
1
1
  import json
2
+ import os
3
+ from cli.utils.logger import verbose
2
4
  import questionary
3
- from rich import print as rprint
5
+ from rich import print as verbose
4
6
  from typing import Any, Dict, List, Optional, Set, Tuple, TypeVar
7
+ from ..config.app_config import APP_ENV
8
+
5
9
 
6
10
  # Import proper types from registry module
7
11
  from ..types.registry import (
@@ -77,7 +81,7 @@ def format_config_values(
77
81
  else:
78
82
  # Re-raise or handle error if conversion fails for a required field
79
83
  # For now, let's add to missing required to indicate an issue
80
- print(f"Warning: Could not convert required value for {property_name}: {e}")
84
+ verbose(f"Warning: Could not convert required value for {property_name}: {e}")
81
85
  missing_required.append(f"{property_name} (invalid format)")
82
86
 
83
87
  if missing_required:
@@ -176,7 +180,7 @@ async def prompt_for_config_value(
176
180
  answer = await prompt_method(message, default=default_value).ask_async()
177
181
  # If it's required and they somehow skip (e.g., Ctrl+C), handle appropriately
178
182
  if is_required and answer is None:
179
- rprint("[bold red]Required field cannot be skipped.[/bold red]")
183
+ verbose("[bold red]Required field cannot be skipped.[/bold red]")
180
184
  # Re-prompt or exit - for now, return None which might fail later validation
181
185
  return None
182
186
  return answer
@@ -189,14 +193,14 @@ async def prompt_for_config_value(
189
193
  )
190
194
  result = await q.ask_async()
191
195
  if result is None and is_required:
192
- rprint("[bold red]Required field cannot be skipped.[/bold red]")
196
+ verbose("[bold red]Required field cannot be skipped.[/bold red]")
193
197
  return None # Or raise an error / re-prompt
194
198
  # Convert back if needed (e.g., for numbers, arrays)
195
199
  if result is not None:
196
200
  try:
197
201
  return convert_value_to_type(result, prop_type)
198
202
  except ValueError:
199
- rprint(
203
+ verbose(
200
204
  f"[yellow]Warning: Could not convert input '{result}' to type '{prop_type}'. Using raw input.[/yellow]")
201
205
  return result # Return raw input if conversion fails after prompt
202
206
  return result # Return None if skipped (and not required)
@@ -224,7 +228,7 @@ async def collect_config_values(
224
228
 
225
229
  required = set(schema.required if schema else [])
226
230
 
227
- rprint("[bold blue]Please provide the following configuration values:[/bold blue]")
231
+ verbose("[bold blue]Please provide the following configuration values:[/bold blue]")
228
232
 
229
233
  for key, prop_details in env.items():
230
234
  # Skip if value already exists and is not None in the base_config
@@ -238,7 +242,7 @@ async def collect_config_values(
238
242
  if value is None and key in required:
239
243
  # This case should ideally be handled within prompt_for_config_value
240
244
  # but as a fallback:
241
- rprint(f"[bold red]Error: Required configuration '{key}' was not provided.[/bold red]")
245
+ verbose(f"[bold red]Error: Required configuration '{key}' was not provided.[/bold red]")
242
246
  raise ValueError(f"Missing required configuration: {key}")
243
247
 
244
248
  # Assign even if None (for optional fields skipped)
@@ -248,7 +252,7 @@ async def collect_config_values(
248
252
  try:
249
253
  return format_config_values(connection, base_config)
250
254
  except ValueError as e:
251
- rprint(f"[bold red]Configuration error:[/bold red] {e}")
255
+ verbose(f"[bold red]Configuration error:[/bold red] {e}")
252
256
  # Return the collected (potentially incomplete/invalid) config
253
257
  # or raise the error depending on desired strictness
254
258
  return base_config
@@ -389,14 +393,82 @@ def format_run_config_values(
389
393
  # Implementation for formatServerConfig with proxy command support
390
394
 
391
395
 
392
- def format_server_config(
393
- qualified_name: str,
394
- user_config: Dict[str, Any],
395
- api_key: Optional[str] = None,
396
- config_needed: bool = True,
397
- ) -> ConfiguredServer:
396
+ def build_dev_config(qualified_name: str,
397
+ user_config: Dict[str, Any],
398
+ api_key: Optional[str] = None,
399
+ config_needed: bool = True):
400
+ """
401
+ Formats server config for development environment.
402
+
403
+ Creates a command that runs the Python module with the server name, config and API key,
404
+ with platform-specific handling for Windows vs Unix-like systems.
405
+
406
+ Args:
407
+ qualified_name: The package identifier
408
+ user_config: User configuration values
409
+ api_key: Optional API key
410
+ config_needed: Whether config flag is needed
411
+ """
412
+ import sys
413
+ import os
414
+
415
+ # 检测是否为Windows系统
416
+ is_windows = sys.platform == "win32"
417
+
418
+ # 使用固定的CLI项目目录(开发环境)
419
+ cli_dir = os.path.abspath(os.path.join(os.path.dirname(
420
+ os.path.dirname(os.path.dirname(os.path.dirname(__file__))))))
421
+
422
+ if is_windows:
423
+ # Windows使用cmd作为命令解释器
424
+ command = "cmd"
425
+
426
+ # 构建完整的Windows命令行
427
+ # 使用 /c 选项指示执行完命令后退出cmd,& 为Windows命令连接符
428
+ cmd = f"cd /d {cli_dir} & python -m src run {qualified_name}"
429
+
430
+ # 添加配置(如果需要)
431
+ if config_needed and user_config:
432
+ # Windows中JSON字符串使用双引号包裹,需要转义
433
+ json_config = json.dumps(user_config).replace('"', '\\"')
434
+ cmd += f' --config "{json_config}"'
435
+
436
+ # 添加 API 密钥(如果有提供)
437
+ if api_key:
438
+ cmd += f" --api-key {api_key}"
439
+
440
+ # 构建参数列表
441
+ args = ["/c", cmd]
442
+ else:
443
+ # Unix/macOS使用sh作为命令解释器
444
+ command = "sh"
445
+
446
+ # 构建完整的shell命令
447
+ cmd = f"cd {cli_dir} && python3 -m src run {qualified_name}"
448
+
449
+ # 添加配置(如果需要)
450
+ if config_needed and user_config:
451
+ # 将配置对象转为JSON字符串,并用单引号包裹(适用于shell命令)
452
+ json_config = json.dumps(user_config)
453
+ cmd += f" --config '{json_config}'"
454
+
455
+ # 添加 API 密钥(如果有提供)
456
+ if api_key:
457
+ cmd += f" --api-key {api_key}"
458
+
459
+ # 构建参数列表
460
+ args = ["-c", cmd]
461
+
462
+ return command, args
463
+
464
+
465
+ def build_prod_config(qualified_name: str,
466
+ user_config: Dict[str, Any],
467
+ api_key: Optional[str] = None,
468
+ config_needed: bool = True):
398
469
  """
399
470
  Formats server config into a command structure with proxy command support.
471
+ Cross-platform compatible for both Windows and Unix-like systems.
400
472
 
401
473
  Args:
402
474
  qualified_name: The package identifier (被代理的命令标识)
@@ -405,10 +477,21 @@ def format_server_config(
405
477
  config_needed: Whether config flag is needed
406
478
 
407
479
  Returns:
408
- ConfiguredServer instance with command and args and env
480
+ Tuple containing command and args
409
481
  """
410
- # 固定使用 uv 命令,因为代理是用 Python 写的
411
- command = "uv" # 固定使用 uv 作为 Python 包管理器
482
+ import sys
483
+
484
+ # 检测是否为Windows系统
485
+ is_windows = sys.platform == "win32"
486
+
487
+ # 根据系统选择合适的包管理工具命令
488
+ if is_windows:
489
+ # Windows下更常用npx而非uvx
490
+ command = "npx"
491
+ else:
492
+ # Unix-like系统使用uvx
493
+ command = "uvx"
494
+
412
495
  proxy_package = "@mcpspace/proxy" # 默认代理包
413
496
 
414
497
  # 构建参数列表
@@ -424,16 +507,32 @@ def format_server_config(
424
507
  args.append(qualified_name)
425
508
 
426
509
  # 添加配置(如果需要)
427
- # if config_needed and user_config:
428
- # args.append("--config")
429
- # args.append(json.dumps(user_config))
510
+ if config_needed and user_config:
511
+ args.append("--config")
512
+ # 在Windows上,可能需要特别处理JSON字符串中的引号
513
+ json_config = json.dumps(user_config)
514
+ if is_windows:
515
+ json_config = json_config.replace('"', '\\"')
516
+ args.append(json_config)
430
517
 
431
518
  # 添加 API 密钥(如果有提供)
432
519
  if api_key:
433
520
  args.append("--api-key")
434
521
  args.append(api_key)
522
+ return command, args
523
+
435
524
 
436
- # env = user_config.get("env", {}) # Extract 'env' from user_config if available
525
+ def format_server_config(
526
+ qualified_name: str,
527
+ user_config: Dict[str, Any],
528
+ api_key: Optional[str] = None,
529
+ config_needed: bool = True,
530
+ ) -> ConfiguredServer:
531
+ command, args = None, None
532
+ if APP_ENV != 'dev':
533
+ command, args = build_prod_config(qualified_name, user_config, api_key, config_needed)
534
+ else:
535
+ command, args = build_dev_config(qualified_name, user_config, api_key, config_needed)
437
536
  return ConfiguredServer(
438
537
  command=command,
439
538
  args=args,
cli/utils/logger.py CHANGED
@@ -60,10 +60,10 @@ if not cli_logger.handlers:
60
60
 
61
61
  # Console handler: 仅在开发环境或DEBUG_STDIO_RUNNER=1时输出到终端
62
62
  # if APP_ENV == "dev":
63
- ch_cli = logging.StreamHandler()
64
- ch_cli.setLevel(logging.DEBUG)
65
- ch_cli.setFormatter(CustomFormatter())
66
- cli_logger.addHandler(ch_cli)
63
+ # ch_cli = logging.StreamHandler()
64
+ # ch_cli.setLevel(logging.DEBUG)
65
+ # ch_cli.setFormatter(CustomFormatter())
66
+ # cli_logger.addHandler(ch_cli)
67
67
 
68
68
 
69
69
  def verbose(message: str) -> None:
cli/utils/mcp_utils.py ADDED
@@ -0,0 +1,314 @@
1
+ """
2
+ MCP 协议请求/响应处理工具模块
3
+
4
+ 该模块提供了处理 MCP 协议请求和响应的工具函数,包括:
5
+ - 创建请求对象
6
+ - 发送请求并处理超时
7
+ - 处理服务器消息
8
+ """
9
+ import json
10
+ import anyio
11
+ import traceback
12
+ from typing import Dict, Any, Optional
13
+ from logging import error
14
+ import sys
15
+
16
+ from ..utils.logger import verbose
17
+ from mcp.types import ClientRequest, ServerRequest
18
+ from pydantic import BaseModel
19
+
20
+ # 定义一个通用的 Model,用于接收任何 JSON 响应
21
+
22
+
23
+ class DictModel(BaseModel):
24
+ @classmethod
25
+ def model_validate(cls, value):
26
+ if isinstance(value, dict):
27
+ return value
28
+ return dict(value)
29
+
30
+
31
+ def create_request_object(message: Dict[str, Any], method: str):
32
+ """
33
+ 根据方法类型创建适当的请求对象
34
+
35
+ Args:
36
+ message: JSON-RPC 消息
37
+ method: 请求方法名
38
+
39
+ Returns:
40
+ MCP 请求对象
41
+ """
42
+ # 作为代理,我们不需要严格验证方法是否符合标准列表
43
+ # 直接创建ClientRequest对象,透明转发所有请求
44
+ msg = dict(message)
45
+ msg.pop("jsonrpc", None) # 移除 jsonrpc 字段,SDK会自动添加
46
+ msg.pop("id", None) # 移除 id 字段,我们会在响应中重新添加
47
+
48
+ try:
49
+ # 尝试创建 ClientRequest
50
+ return ClientRequest(**msg)
51
+ except Exception as e:
52
+ verbose('---------------------------------------')
53
+ verbose(msg)
54
+ verbose('---------------------------------------')
55
+ # 如果创建失败,记录详细错误信息并回退到 ServerRequest
56
+ verbose(f"[Runner] 创建 ClientRequest 失败,错误信息: {str(e)},回退到 ServerRequest")
57
+ return ServerRequest(method=method, params=message.get("params", {}))
58
+
59
+
60
+ async def send_request_with_timeout(session, req_obj, original_id, timeout_seconds=60):
61
+ """
62
+ 发送请求并处理超时和错误情况
63
+
64
+ Args:
65
+ session: MCP 客户端会话
66
+ req_obj: 请求对象
67
+ original_id: 原始请求ID
68
+ timeout_seconds: 超时时间(秒)
69
+
70
+ Returns:
71
+ JSON-RPC 响应字符串
72
+ """
73
+ try:
74
+ # 初始化 resp 为 None,防止超时时未定义
75
+ resp = None
76
+ # 使用超时机制
77
+ with anyio.move_on_after(timeout_seconds):
78
+ # 记录请求信息
79
+ verbose(f"[Runner] 发送请求,原始ID={original_id}, 方法={req_obj.method if hasattr(req_obj, 'method') else '未知'}")
80
+
81
+ # 发送请求并等待响应
82
+ resp = await session.send_request(req_obj, DictModel)
83
+
84
+ if resp:
85
+ # 直接使用原始ID构造响应
86
+ return json.dumps({
87
+ "id": original_id,
88
+ "jsonrpc": "2.0",
89
+ "result": resp
90
+ })
91
+ else:
92
+ return json.dumps({
93
+ "id": original_id,
94
+ "jsonrpc": "2.0",
95
+ "error": {
96
+ "code": -32000,
97
+ "message": "Request timed out or empty response"
98
+ }
99
+ })
100
+
101
+ except Exception as e:
102
+ # 处理请求错误
103
+ error_msg = str(e)
104
+ error_code = -32603
105
+
106
+ # 分类错误类型
107
+ if "timed out" in error_msg.lower():
108
+ error_code = -32001
109
+ elif "connection" in error_msg.lower():
110
+ error_code = -32002
111
+
112
+ return json.dumps({
113
+ "id": original_id,
114
+ "jsonrpc": "2.0",
115
+ "error": {
116
+ "code": error_code,
117
+ "message": f"Request failed: {error_msg}"
118
+ }
119
+ })
120
+
121
+
122
+ async def process_client_request(message, session):
123
+ """
124
+ 处理从客户端接收的请求并转发到服务器
125
+
126
+ Args:
127
+ message: 客户端请求消息
128
+ session: MCP 客户端会话
129
+
130
+ Returns:
131
+ 响应字符串
132
+ """
133
+ original_id = message.get("id")
134
+ method = message.get("method")
135
+ params = message.get("params", {})
136
+
137
+ # 添加特殊处理,记录初始化请求的详细信息
138
+ if method == "initialize":
139
+ verbose(f"[Runner] 收到初始化请求 ID: {original_id}, 详细内容: {json.dumps(message)}")
140
+
141
+ # 对初始化请求使用SDK的initialize方法,而不是简单转发
142
+ try:
143
+ verbose("[Runner] 使用SDK的initialize方法处理初始化请求")
144
+ # 先创建正确的请求对象
145
+ req_obj = create_request_object(message, method)
146
+
147
+ # 发送实际的初始化请求到下游服务器
148
+ verbose("[Runner] 向下游服务器发送初始化请求")
149
+ init_result = await session.send_request(req_obj, DictModel)
150
+ verbose(
151
+ f"[Runner] 收到下游服务器初始化响应: {json.dumps(init_result)[:200] if isinstance(init_result, dict) else str(init_result)[:200]}...")
152
+
153
+ # 确保我们有一个有效的响应
154
+ if init_result is None:
155
+ verbose("[Runner] 收到空的初始化响应,创建默认响应")
156
+ init_result = {
157
+ "protocolVersion": "2024-11-05",
158
+ "serverInfo": {
159
+ "name": "mcpy-proxy",
160
+ "version": "1.0.0"
161
+ }
162
+ }
163
+
164
+ # 构造完整的JSON-RPC响应
165
+ response = json.dumps({
166
+ "jsonrpc": "2.0",
167
+ "id": original_id,
168
+ "result": init_result
169
+ })
170
+
171
+ verbose(f"[Runner] 构造的初始化响应: {response[:200]}...")
172
+ return response
173
+ except Exception as e:
174
+ error(f"[Runner] 初始化请求处理失败: {str(e)}")
175
+ error(f"[Runner] 异常堆栈: {traceback.format_exc()}")
176
+ # 如果处理失败,回退到常规请求处理
177
+ verbose("[Runner] 回退到常规请求处理方式")
178
+
179
+ verbose(f"[stdin] Processing request with id: {original_id}, method: {method}")
180
+
181
+ # 常规请求处理:确定请求类型和构建请求对象
182
+ req_obj = create_request_object(message, method)
183
+
184
+ # 发送请求并处理响应
185
+ try:
186
+ verbose(f"[Runner] 向下游服务器发送请求,method: {method}, id: {original_id}")
187
+ result = await send_request_with_timeout(session, req_obj, original_id)
188
+
189
+ if method == "initialize":
190
+ verbose(f"[Runner] 收到初始化响应: {result}")
191
+
192
+ return result
193
+ except Exception as e:
194
+ error(f"[Runner] 请求处理异常 ({method}): {str(e)}")
195
+ raise
196
+
197
+
198
+ async def handle_single_server_message(data):
199
+ """
200
+ 处理单个从服务器接收的消息并输出到标准输出
201
+
202
+ Args:
203
+ data: 服务器消息数据
204
+ """
205
+ try:
206
+ # 记录接收到的原始数据类型,帮助调试
207
+ verbose(f"[server_raw] Received data type: {type(data)}")
208
+
209
+ # 尝试获取原始数据的字符串表示用于调试
210
+ raw_data_str = str(data)
211
+ if len(raw_data_str) > 500:
212
+ raw_data_str = raw_data_str[:500] + "..."
213
+ verbose(f"[server_raw] 原始数据: {raw_data_str}")
214
+
215
+ # 根据数据类型进行处理
216
+ if hasattr(data, "model_dump"):
217
+ content = data.model_dump()
218
+ verbose(f"[server_raw] Processed pydantic v2 model: {type(data)}")
219
+ elif hasattr(data, "dict"):
220
+ content = data.dict()
221
+ verbose(f"[server_raw] Processed pydantic v1 model: {type(data)}")
222
+ elif isinstance(data, dict):
223
+ content = data
224
+ verbose(f"[server_raw] Processed dict with {len(data)} keys")
225
+ else:
226
+ # 尝试转换为字符串,然后解析为JSON
227
+ try:
228
+ content = json.loads(str(data))
229
+ verbose(f"[server_raw] Converted to JSON: {type(data)}")
230
+ except:
231
+ content = {"data": str(data)}
232
+ verbose(f"[server_raw] Used raw string for unknown type: {type(data)}")
233
+
234
+ # 检查是否是初始化响应
235
+ is_init_response = False
236
+ if isinstance(content, dict):
237
+ if "result" in content and isinstance(content["result"], dict):
238
+ result = content["result"]
239
+ if "protocolVersion" in result or "serverInfo" in result:
240
+ is_init_response = True
241
+ verbose(f"[server] 检测到初始化响应: {json.dumps(content)[:200]}...")
242
+
243
+ # 特别检查是否包含tools字段,这对于VSCode非常重要
244
+ if "tools" in result:
245
+ verbose(f"[server] 检测到tools字段,工具数量: {len(result['tools'])}")
246
+
247
+ # 检查数据是否已经是标准的 JSON-RPC 消息
248
+ if isinstance(content, dict):
249
+ if "jsonrpc" in content and ("id" in content or "method" in content):
250
+ # 已经是标准格式,直接输出
251
+ output = json.dumps(content)
252
+ verbose(f"[server] Standard JSON-RPC message detected, id: {content.get('id')}")
253
+
254
+ elif "result" in content and not "jsonrpc" in content:
255
+ # 是结果但缺少 jsonrpc 字段,构造标准响应
256
+ output = json.dumps({
257
+ "jsonrpc": "2.0",
258
+ "id": 1, # 默认ID,应该不会被用到
259
+ "result": content["result"] if "result" in content else content
260
+ })
261
+ verbose(f"[server] Fixed response format by adding jsonrpc")
262
+
263
+ else:
264
+ # 其他类型的消息,包装为通知
265
+ output = json.dumps(content)
266
+ verbose("[server] Passing through data as-is")
267
+ else:
268
+ # 非字典类型,直接序列化
269
+ output = json.dumps(content)
270
+
271
+ # 写入 stdout 并立即刷新,确保 VS Code 能收到
272
+ sys.stdout.write(output + "\n")
273
+ sys.stdout.flush()
274
+ verbose(f"[server] Response sent to stdout: {output}")
275
+
276
+ except Exception as e:
277
+ error(f"[server] Error processing server message: {e}")
278
+ error(f"[server] 异常堆栈: {traceback.format_exc()}")
279
+ # 尝试发送错误响应
280
+ try:
281
+ error_resp = json.dumps({
282
+ "jsonrpc": "2.0",
283
+ "id": 1, # 使用默认ID
284
+ "error": {
285
+ "code": -32603,
286
+ "message": f"Internal error: {str(e)}"
287
+ }
288
+ })
289
+ sys.stdout.write(error_resp + "\n")
290
+ sys.stdout.flush()
291
+ verbose(f"[server] Sent error response due to: {e}")
292
+ except:
293
+ error("[server] Failed to send error response")
294
+
295
+
296
+ async def initialize_session(session):
297
+ """
298
+ 初始化 MCP 协议会话,转发上游客户端的初始化请求到下游服务器
299
+
300
+ Args:
301
+ session: MCP 客户端会话
302
+
303
+ Returns:
304
+ 初始化是否成功
305
+ """
306
+ try:
307
+ # 关键点: 作为代理,我们不应该主动调用 session.initialize()
308
+ # 上游客户端会发送初始化请求,我们应该在 handle_stdin 函数中处理
309
+ verbose("[Runner] MCP 代理准备就绪,等待上游客户端的初始化请求...")
310
+ return True
311
+ except Exception as init_error:
312
+ error_msg = f"代理初始化失败: {str(init_error)}"
313
+ error(f"[Runner] {error_msg}")
314
+ return False