mdymcp 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdmcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """mdmcp — MCP Server for Mingdao Collaboration v1 API (cloud-token mode)."""
mdmcp/api_client.py ADDED
@@ -0,0 +1,55 @@
1
+ """HTTP client for Mingdao v1 API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ from .auth import BASE_API_URL, ensure_access_token
11
+
12
+
13
+ def _get(endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
14
+ token = ensure_access_token()
15
+ all_params: dict[str, Any] = {"access_token": token, "format": "json"}
16
+ if params:
17
+ all_params.update({k: v for k, v in params.items() if v is not None and v != ""})
18
+ query = urllib.parse.urlencode(all_params)
19
+ url = f"{BASE_API_URL}{endpoint}?{query}"
20
+ req = urllib.request.Request(
21
+ url,
22
+ headers={"Accept": "application/json", "User-Agent": "mdmcp/0.1"},
23
+ method="GET",
24
+ )
25
+ with urllib.request.urlopen(req, timeout=30) as resp:
26
+ return json.loads(resp.read().decode("utf-8"))
27
+
28
+
29
+ def _post(endpoint: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
30
+ token = ensure_access_token()
31
+ all_data: dict[str, Any] = {"access_token": token, "format": "json"}
32
+ if data:
33
+ all_data.update({k: v for k, v in data.items() if v is not None and v != ""})
34
+ body = urllib.parse.urlencode(all_data).encode("utf-8")
35
+ url = f"{BASE_API_URL}{endpoint}"
36
+ req = urllib.request.Request(
37
+ url,
38
+ data=body,
39
+ headers={
40
+ "Content-Type": "application/x-www-form-urlencoded",
41
+ "Accept": "application/json",
42
+ "User-Agent": "mdmcp/0.1",
43
+ },
44
+ method="POST",
45
+ )
46
+ with urllib.request.urlopen(req, timeout=30) as resp:
47
+ return json.loads(resp.read().decode("utf-8"))
48
+
49
+
50
+ def api_get(endpoint: str, **kwargs: Any) -> dict[str, Any]:
51
+ return _get(endpoint, kwargs if kwargs else None)
52
+
53
+
54
+ def api_post(endpoint: str, **kwargs: Any) -> dict[str, Any]:
55
+ return _post(endpoint, kwargs if kwargs else None)
mdmcp/auth.py ADDED
@@ -0,0 +1,461 @@
1
+ """Token fetcher — 远端 hook 提供 24h token,内存缓存到次日本地 00:00。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import ssl
8
+ import time
9
+ import urllib.request
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ def _ssl_ctx() -> ssl.SSLContext:
16
+ """返回一个带 certifi CA 的 SSL 上下文。
17
+
18
+ macOS 上 python.org 安装的 Python 往往不带根证书,导致 urlopen 直接
19
+ CERTIFICATE_VERIFY_FAILED。优先用 certifi 的 CA bundle;装不上就回落到系统默认。
20
+ """
21
+ try:
22
+ import certifi # type: ignore
23
+ return ssl.create_default_context(cafile=certifi.where())
24
+ except Exception:
25
+ return ssl.create_default_context()
26
+
27
+
28
+ _SSL_CTX = _ssl_ctx()
29
+
30
+ BASE_API_URL = "https://api.mingdao.com"
31
+ HOOK_URL_DEFAULT = "https://api.mingdao.com/workflow/hooks2/NjlkYzQ5NGIwMzM0NzkwYjg4MWY4NTk5"
32
+
33
+ # OAuth 自助注册(mdmcp-auth 命令使用)
34
+ APP_KEY_DEFAULT = "6A228C49DAC4"
35
+ CALLBACK_PORT_DEFAULT = 8080
36
+ REGISTER_URL_DEFAULT = "https://api.mingdao.com/workflow/hooks/NjllNjFkYjM2NTAyMDc5NzgxMGNmZDll"
37
+
38
+ # HAP 网关凭据(独立于 v1 token):refresh_token → register → hap_key → token
39
+ HAP_REGISTER_HOOK_DEFAULT = "https://api.mingdao.com/workflow/hooks2/NjllNjNkYzNiODBlZTc3YjE3NDM1Y2U2"
40
+ HAP_TOKEN_HOOK_DEFAULT = "https://api.mingdao.com/workflow/hooks2/NjllNjQ2NGE2NTAyMDc5NzgxMTFjM2Q3"
41
+
42
+ _cache: dict[str, Any] = {"token": "", "expires_at": 0}
43
+ _hap_cache: dict[str, Any] = {"hap_key": "", "token": "", "expires_at": 0}
44
+
45
+
46
+ MDMCP_USER_HOME = Path.home() / ".mdmcp"
47
+
48
+
49
+ def _load_env() -> None:
50
+ """Lazy load .env from cwd → ~/.mdmcp → package parent (clone repo root)."""
51
+ for d in [Path.cwd(), MDMCP_USER_HOME, Path(__file__).resolve().parent.parent.parent]:
52
+ env = d / ".env"
53
+ if not env.exists():
54
+ continue
55
+ for raw_line in env.read_text(encoding="utf-8").splitlines():
56
+ line = raw_line.strip()
57
+ if not line or line.startswith("#") or "=" not in line:
58
+ continue
59
+ k, v = line.split("=", 1)
60
+ os.environ.setdefault(k.strip(), v.strip())
61
+ return
62
+
63
+
64
+ def _next_local_midnight_ts() -> int:
65
+ now = datetime.now()
66
+ tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
67
+ return int(tomorrow.timestamp())
68
+
69
+
70
+ def ensure_access_token() -> str:
71
+ if _cache["token"] and time.time() < _cache["expires_at"] - 60:
72
+ return str(_cache["token"])
73
+
74
+ _load_env()
75
+ account_id = os.getenv("MD_ACCOUNT_ID", "").strip()
76
+ key = os.getenv("MD_KEY", "").strip()
77
+ hook_url = os.getenv("MD_HOOK_URL", HOOK_URL_DEFAULT).strip()
78
+ if not account_id or not key:
79
+ raise RuntimeError(
80
+ "Missing MD_ACCOUNT_ID or MD_KEY. Set them in .env or environment."
81
+ )
82
+
83
+ body = json.dumps({"account_id": account_id, "key": key}).encode("utf-8")
84
+ req = urllib.request.Request(
85
+ hook_url,
86
+ data=body,
87
+ headers={
88
+ "Content-Type": "application/json",
89
+ "Accept": "application/json",
90
+ "User-Agent": "mdmcp/0.1",
91
+ },
92
+ method="POST",
93
+ )
94
+ with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as resp:
95
+ data = json.loads(resp.read().decode("utf-8"))
96
+
97
+ token = data.get("token")
98
+ if not token:
99
+ raise RuntimeError(f"Token endpoint returned no token: {data!r}")
100
+
101
+ _cache["token"] = token
102
+ _cache["expires_at"] = _next_local_midnight_ts()
103
+ return str(token)
104
+
105
+
106
+ def _hap_post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
107
+ body = json.dumps(payload).encode("utf-8")
108
+ req = urllib.request.Request(
109
+ url,
110
+ data=body,
111
+ headers={
112
+ "Content-Type": "application/json",
113
+ "Accept": "application/json",
114
+ "User-Agent": "mdmcp/0.1",
115
+ },
116
+ method="POST",
117
+ )
118
+ with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as resp:
119
+ return json.loads(resp.read().decode("utf-8"))
120
+
121
+
122
+ def ensure_hap_token() -> str:
123
+ """HAP 网关 token:用 install 时存下来的 hap_key 调 token hook,缓存到次日本地 00:00。
124
+
125
+ register 是一次性配置(install.py 完成后服务端已绑定 refresh_token + token →
126
+ hap_key);运行时只查询,不再 register。
127
+ """
128
+ if _hap_cache["token"] and time.time() < _hap_cache["expires_at"] - 60:
129
+ return str(_hap_cache["token"])
130
+
131
+ _load_env()
132
+ account_id = os.getenv("MD_ACCOUNT_ID", "").strip()
133
+ hap_key = os.getenv("MD_HAP_KEY", "").strip()
134
+ token_url = os.getenv("MD_HAP_TOKEN_HOOK", HAP_TOKEN_HOOK_DEFAULT).strip()
135
+ if not account_id or not hap_key:
136
+ raise RuntimeError(
137
+ "Missing MD_ACCOUNT_ID or MD_HAP_KEY。请重跑 install.py 完成 HAP 注册。"
138
+ )
139
+
140
+ tok = _hap_post(token_url, {"account_id": account_id, "hap_key": hap_key})
141
+ token = tok.get("token") or ""
142
+ if not token:
143
+ raise RuntimeError(
144
+ f"HAP token 接口返回空,hap_key 可能已失效,请重跑 install.py。响应:{tok!r}"
145
+ )
146
+
147
+ _hap_cache["hap_key"] = hap_key
148
+ _hap_cache["token"] = token
149
+ _hap_cache["expires_at"] = _next_local_midnight_ts()
150
+ return token
151
+
152
+
153
+ def hap_register(account_id: str, refresh_token: str, hap_token: str) -> str:
154
+ """一次性注册 HAP 凭据到服务端,返回 hap_key(由 install.py 调用并持久化到 .env)。"""
155
+ _load_env()
156
+ url = os.getenv("MD_HAP_REGISTER_HOOK", HAP_REGISTER_HOOK_DEFAULT).strip()
157
+ reg = _hap_post(url, {
158
+ "account_id": account_id,
159
+ "hap_refresh_token": refresh_token,
160
+ "hap_token": hap_token,
161
+ })
162
+ hap_key = reg.get("hap_key") or ""
163
+ if not hap_key:
164
+ raise RuntimeError(f"HAP register 未返回 hap_key:{reg!r}")
165
+ return hap_key
166
+
167
+
168
+ # ─────────────────────────────────────────────
169
+ # OAuth 自助注册流程(mdmcp-auth 命令)
170
+ # ─────────────────────────────────────────────
171
+
172
+ import secrets
173
+ import shutil
174
+ import subprocess
175
+ import sys
176
+ import urllib.parse
177
+ import webbrowser
178
+ from http.server import BaseHTTPRequestHandler, HTTPServer
179
+ from threading import Thread
180
+
181
+
182
+ _CALLBACK_HTML_OK = """<!doctype html>
183
+ <html><head><meta charset="utf-8"><title>mdmcp 授权成功</title></head>
184
+ <body style="font-family:system-ui;max-width:560px;margin:80px auto;padding:0 24px;color:#222">
185
+ <h2>✅ 授权成功</h2>
186
+ <p>已收到授权码,正在和服务端交换凭据。请回到终端查看结果,本页面可以关闭。</p>
187
+ </body></html>"""
188
+
189
+ _CALLBACK_HTML_ERR = """<!doctype html>
190
+ <html><head><meta charset="utf-8"><title>mdmcp 授权失败</title></head>
191
+ <body style="font-family:system-ui;max-width:560px;margin:80px auto;padding:0 24px;color:#222">
192
+ <h2>❌ 授权失败</h2>
193
+ <p>{msg}</p><p>请回到终端查看详细错误。</p>
194
+ </body></html>"""
195
+
196
+
197
+ def _open_incognito(url: str) -> str:
198
+ """用隐身/无痕窗口打开 URL(避免污染默认浏览器会话)。
199
+
200
+ 依次尝试 Chrome → Edge → Firefox 的隐身模式,全部失败再回落到默认浏览器。
201
+ 无论成败都把 URL 复制到剪贴板,便于手动粘贴。
202
+ """
203
+ plat = sys.platform
204
+ attempts: list[tuple[str, list[str]]] = []
205
+
206
+ if plat == "darwin":
207
+ mac_candidates = [
208
+ ("Chrome 隐身", "Google Chrome", ["--incognito", "--new-window", url]),
209
+ ("Edge InPrivate", "Microsoft Edge", ["--inprivate", "--new-window", url]),
210
+ ("Firefox 隐私窗口", "Firefox", ["-private-window", url]),
211
+ ]
212
+ for label, app_name, args in mac_candidates:
213
+ if not _mac_app_exists(app_name):
214
+ continue
215
+ attempts.append((label, ["open", "-na", app_name, "--args", *args]))
216
+ elif plat.startswith("win"):
217
+ win_candidates = [
218
+ ("Chrome 隐身", "chrome.exe", ["--incognito", "--new-window", url]),
219
+ ("Edge InPrivate", "msedge.exe", ["--inprivate", "--new-window", url]),
220
+ ("Firefox 隐私窗口", "firefox.exe", ["-private-window", url]),
221
+ ]
222
+ for label, exe_name, args in win_candidates:
223
+ exe_path = _win_find_browser(exe_name)
224
+ if not exe_path:
225
+ continue
226
+ attempts.append((label, [exe_path, *args]))
227
+ else:
228
+ for name, exe, flag in [
229
+ ("Chrome 隐身", "google-chrome", "--incognito"),
230
+ ("Chromium 隐身", "chromium-browser", "--incognito"),
231
+ ("Chromium 隐身", "chromium", "--incognito"),
232
+ ("Edge InPrivate", "microsoft-edge", "--inprivate"),
233
+ ("Firefox 隐私窗口", "firefox", "--private-window"),
234
+ ]:
235
+ if shutil.which(exe):
236
+ attempts.append((name, [exe, flag, "--new-window", url]) if "firefox" not in exe else (name, [exe, flag, url]))
237
+
238
+ for label, cmd in attempts:
239
+ try:
240
+ subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
241
+ _copy_to_clipboard(url)
242
+ return label
243
+ except Exception:
244
+ continue
245
+
246
+ try:
247
+ webbrowser.open(url)
248
+ _copy_to_clipboard(url)
249
+ return "默认浏览器(未找到隐身浏览器,已回落)"
250
+ except Exception:
251
+ _copy_to_clipboard(url)
252
+ return "剪贴板(请手动粘贴打开)"
253
+
254
+
255
+ def _mac_app_exists(app_name: str) -> bool:
256
+ for base in ("/Applications", os.path.expanduser("~/Applications")):
257
+ if os.path.isdir(os.path.join(base, f"{app_name}.app")):
258
+ return True
259
+ try:
260
+ r = subprocess.run(
261
+ ["mdfind", f"kMDItemCFBundleIdentifier == '*' && kMDItemDisplayName == '{app_name}.app'"],
262
+ capture_output=True, text=True, timeout=2,
263
+ )
264
+ if r.stdout.strip():
265
+ return True
266
+ except Exception:
267
+ pass
268
+ return False
269
+
270
+
271
+ def _win_find_browser(exe_name: str) -> str | None:
272
+ found = shutil.which(exe_name)
273
+ if found:
274
+ return found
275
+ candidates = {
276
+ "chrome.exe": [
277
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
278
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
279
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
280
+ ],
281
+ "msedge.exe": [
282
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
283
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
284
+ ],
285
+ "firefox.exe": [
286
+ r"C:\Program Files\Mozilla Firefox\firefox.exe",
287
+ r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe",
288
+ ],
289
+ }.get(exe_name, [])
290
+ for p in candidates:
291
+ if p and os.path.isfile(p):
292
+ return p
293
+ return None
294
+
295
+
296
+ def _copy_to_clipboard(text: str) -> bool:
297
+ plat = sys.platform
298
+ candidates: list[list[str]] = []
299
+ if plat == "darwin":
300
+ candidates = [["pbcopy"]]
301
+ elif plat.startswith("win"):
302
+ candidates = [["clip"]]
303
+ else:
304
+ if shutil.which("xclip"):
305
+ candidates = [["xclip", "-selection", "clipboard"]]
306
+ elif shutil.which("wl-copy"):
307
+ candidates = [["wl-copy"]]
308
+ for cmd in candidates:
309
+ try:
310
+ p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
311
+ p.communicate(text.encode("utf-8"), timeout=3)
312
+ return True
313
+ except Exception:
314
+ continue
315
+ return False
316
+
317
+
318
+ def _write_env_vars(env_path: Path, updates: dict[str, str]) -> None:
319
+ """追加或覆盖 .env 中的指定键,不动其它行。"""
320
+ lines: list[str] = []
321
+ if env_path.exists():
322
+ lines = env_path.read_text(encoding="utf-8").splitlines()
323
+ keys = set(updates.keys())
324
+ new_lines: list[str] = []
325
+ seen: set[str] = set()
326
+ for raw in lines:
327
+ stripped = raw.strip()
328
+ if stripped and not stripped.startswith("#") and "=" in stripped:
329
+ k = stripped.split("=", 1)[0].strip()
330
+ if k in keys:
331
+ new_lines.append(f"{k}={updates[k]}")
332
+ seen.add(k)
333
+ continue
334
+ new_lines.append(raw)
335
+ for k, v in updates.items():
336
+ if k not in seen:
337
+ new_lines.append(f"{k}={v}")
338
+ env_path.write_text("\n".join(new_lines).rstrip() + "\n", encoding="utf-8")
339
+
340
+
341
+ class _CallbackHandler(BaseHTTPRequestHandler):
342
+ result: dict[str, Any] = {}
343
+
344
+ def log_message(self, *_a: Any, **_kw: Any) -> None: # 静音
345
+ return
346
+
347
+ def do_GET(self) -> None: # noqa: N802
348
+ parsed = urllib.parse.urlparse(self.path)
349
+ if parsed.path != "/callback":
350
+ self.send_response(404)
351
+ self.end_headers()
352
+ return
353
+ qs = urllib.parse.parse_qs(parsed.query)
354
+ code = (qs.get("code") or [""])[0]
355
+ state = (qs.get("state") or [""])[0]
356
+ error = (qs.get("error") or [""])[0]
357
+ if error or not code:
358
+ self.send_response(400)
359
+ self.send_header("Content-Type", "text/html; charset=utf-8")
360
+ self.end_headers()
361
+ msg = error or "missing code"
362
+ self.wfile.write(_CALLBACK_HTML_ERR.format(msg=msg).encode("utf-8"))
363
+ _CallbackHandler.result = {"error": msg}
364
+ return
365
+ self.send_response(200)
366
+ self.send_header("Content-Type", "text/html; charset=utf-8")
367
+ self.end_headers()
368
+ self.wfile.write(_CALLBACK_HTML_OK.encode("utf-8"))
369
+ _CallbackHandler.result = {"code": code, "state": state}
370
+
371
+
372
+ def run_auth_flow(project_root: Path | None = None) -> dict[str, str]:
373
+ """一键 OAuth:起本地 server → 开隐身浏览器 → 接 code → 换 key → 写 .env。
374
+
375
+ 返回 {"account_id": ..., "key": ...}。
376
+ """
377
+ _load_env()
378
+ app_key = os.getenv("MD_APP_KEY", APP_KEY_DEFAULT).strip()
379
+ port = int(os.getenv("MD_CALLBACK_PORT", str(CALLBACK_PORT_DEFAULT)))
380
+ register_url = os.getenv("MD_REGISTER_URL", REGISTER_URL_DEFAULT).strip()
381
+ redirect_uri = f"http://localhost:{port}/callback"
382
+
383
+ if not app_key or "REPLACE" in register_url:
384
+ raise RuntimeError(
385
+ "APP_KEY 或 REGISTER_URL 未正确配置。请通过环境变量 "
386
+ "MD_APP_KEY / MD_REGISTER_URL 覆盖,或由维护者在 auth.py 顶部填入。"
387
+ )
388
+
389
+ state = secrets.token_urlsafe(16)
390
+ authorize_url = (
391
+ f"{BASE_API_URL}/oauth2/authorize?"
392
+ + urllib.parse.urlencode(
393
+ {"app_key": app_key, "redirect_uri": redirect_uri, "state": state}
394
+ )
395
+ )
396
+
397
+ try:
398
+ server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
399
+ except OSError as e:
400
+ raise RuntimeError(
401
+ f"端口 {port} 已被占用。用 MD_CALLBACK_PORT=xxxx mdmcp-auth 换端口"
402
+ f"(明道后台的回调地址需要同步更新)。底层错误:{e}"
403
+ ) from e
404
+
405
+ _CallbackHandler.result = {}
406
+ t = Thread(target=server.serve_forever, daemon=True)
407
+ t.start()
408
+
409
+ print(f"→ 监听本地回调 {redirect_uri}")
410
+ method = _open_incognito(authorize_url)
411
+ print(f"→ 已用 {method} 打开明道授权页(复用现有浏览器登录态)")
412
+ if "clipboard" in method:
413
+ print(" ⚠️ 无法自动打开浏览器,请手动访问(URL 已复制到剪贴板):")
414
+ print(f" {authorize_url}")
415
+ print("→ 请在浏览器中登录目标明道账号并同意授权…")
416
+
417
+ # 等回调,最长 5 分钟
418
+ import time as _t
419
+ deadline = _t.time() + 300
420
+ while _t.time() < deadline and not _CallbackHandler.result:
421
+ _t.sleep(0.3)
422
+ server.shutdown()
423
+ server.server_close()
424
+
425
+ res = _CallbackHandler.result
426
+ if not res:
427
+ raise RuntimeError("等待授权超时(5 分钟),请重新运行 mdmcp-auth。")
428
+ if "error" in res:
429
+ raise RuntimeError(f"授权失败:{res['error']}")
430
+ if res.get("state") != state:
431
+ raise RuntimeError("state 不匹配,疑似 CSRF 攻击,已拒绝。")
432
+
433
+ code = res["code"]
434
+ print("→ 已拿到授权码,正在请求服务端换取凭据…")
435
+
436
+ body = json.dumps({"code": code, "redirect_uri": redirect_uri}).encode("utf-8")
437
+ req = urllib.request.Request(
438
+ register_url,
439
+ data=body,
440
+ headers={
441
+ "Content-Type": "application/json",
442
+ "Accept": "application/json",
443
+ "User-Agent": "mdmcp-auth/0.1",
444
+ },
445
+ method="POST",
446
+ )
447
+ with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as resp:
448
+ data = json.loads(resp.read().decode("utf-8"))
449
+
450
+ account_id = data.get("account_id") or ""
451
+ key = data.get("key") or ""
452
+ if not account_id or not key:
453
+ raise RuntimeError(f"Register endpoint 返回异常:{data!r}")
454
+
455
+ root = project_root or Path.cwd()
456
+ env_path = root / ".env"
457
+ _write_env_vars(env_path, {"MD_ACCOUNT_ID": account_id, "MD_KEY": key})
458
+ print(f"→ 已写入 {env_path}")
459
+ print(f" MD_ACCOUNT_ID={account_id}")
460
+ print(f" MD_KEY={'*' * 8}{key[-4:] if len(key) > 4 else ''}")
461
+ return {"account_id": account_id, "key": key}
mdmcp/cli_auth.py ADDED
@@ -0,0 +1,24 @@
1
+ """CLI 入口:`mdmcp-auth` — 浏览器一键 OAuth 授权,自动写 .env。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import auth
9
+
10
+
11
+ def main() -> None:
12
+ try:
13
+ auth.run_auth_flow(project_root=Path.cwd())
14
+ except KeyboardInterrupt:
15
+ print("\n已取消。", file=sys.stderr)
16
+ sys.exit(130)
17
+ except Exception as e:
18
+ print(f"\n❌ {e}", file=sys.stderr)
19
+ sys.exit(1)
20
+ print("\n✅ 完成。现在可以在 .mcp.json 中配置 mdmcp 并重启 Claude Code。")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()