mcp-server-core 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (30) hide show
  1. {mcp_server_core-0.2.2/mcp_server_core.egg-info → mcp_server_core-0.3.0}/PKG-INFO +4 -1
  2. mcp_server_core-0.3.0/mcp_framework/launcher.py +1 -0
  3. mcp_server_core-0.3.0/mcp_framework/response.py +1 -0
  4. mcp_server_core-0.3.0/mcp_framework/tray.py +1 -0
  5. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/__init__.py +7 -0
  6. mcp_server_core-0.3.0/mcp_server_core/launcher.py +103 -0
  7. mcp_server_core-0.3.0/mcp_server_core/response.py +59 -0
  8. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/server.py +10 -5
  9. mcp_server_core-0.3.0/mcp_server_core/tray.py +534 -0
  10. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0/mcp_server_core.egg-info}/PKG-INFO +4 -1
  11. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core.egg-info/SOURCES.txt +6 -0
  12. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core.egg-info/requires.txt +4 -0
  13. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/pyproject.toml +5 -1
  14. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/LICENSE +0 -0
  15. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/README.md +0 -0
  16. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/__init__.py +0 -0
  17. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/env.py +0 -0
  18. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/http_server.py +0 -0
  19. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/oauth.py +0 -0
  20. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/process.py +0 -0
  21. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/server.py +0 -0
  22. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_framework/tunnel.py +0 -0
  23. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/env.py +0 -0
  24. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/http_server.py +0 -0
  25. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/oauth.py +0 -0
  26. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/process.py +0 -0
  27. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core/tunnel.py +0 -0
  28. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core.egg-info/dependency_links.txt +0 -0
  29. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/mcp_server_core.egg-info/top_level.txt +0 -0
  30. {mcp_server_core-0.2.2 → mcp_server_core-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-server-core
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Universal MCP Server Framework: Streamable HTTP ASGI, Cloudflare Tunnel, OAuth 2.1 & Process Supervisor
5
5
  Author: yimmr
6
6
  License-Expression: MIT
@@ -19,6 +19,9 @@ Requires-Dist: uvicorn>=0.27.0
19
19
  Requires-Dist: pywin32>=306; platform_system == "Windows"
20
20
  Provides-Extra: test
21
21
  Requires-Dist: httpx>=0.27.0; extra == "test"
22
+ Provides-Extra: tray
23
+ Requires-Dist: pystray>=0.19.5; extra == "tray"
24
+ Requires-Dist: pillow>=10.0.0; extra == "tray"
22
25
  Dynamic: license-file
23
26
 
24
27
  # MCP Server Core 🚀
@@ -0,0 +1 @@
1
+ from mcp_server_core.launcher import * # noqa: F401, F403
@@ -0,0 +1 @@
1
+ from mcp_server_core.response import * # noqa: F401, F403
@@ -0,0 +1 @@
1
+ from mcp_server_core.tray import * # noqa: F401, F403
@@ -25,6 +25,8 @@ from .process import (
25
25
  kill_stale_server_process,
26
26
  stop_mcp_service,
27
27
  )
28
+ from .launcher import launch_service
29
+ from .response import ToolResultListDict
28
30
  from .server import (
29
31
  build_mcp_server_app,
30
32
  record_pid,
@@ -33,6 +35,7 @@ from .server import (
33
35
  serve_http,
34
36
  serve_stdio,
35
37
  )
38
+ from .tray import BaseTrayApp, ServerProcessSupervisor
36
39
  from .tunnel import (
37
40
  CloudflareTunnel,
38
41
  TunnelHeartbeatMonitor,
@@ -64,6 +67,10 @@ __all__ = [
64
67
  "get_client_ip",
65
68
  "get_server_public_url",
66
69
  "verify_pkce",
70
+ "launch_service",
71
+ "ToolResultListDict",
72
+ "BaseTrayApp",
73
+ "ServerProcessSupervisor",
67
74
  "build_mcp_server_app",
68
75
  "record_pid",
69
76
  "cleanup_pid",
@@ -0,0 +1,103 @@
1
+ """Unified MCP Service Launcher and Process Lifecycle CLI."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from .env import DeploymentEnvConfig, get_runtime_dir
10
+ from .process import check_service_status, stop_mcp_service
11
+ from .tunnel import ensure_tunnel_running, start_tunnel_heartbeat_monitor
12
+
13
+
14
+ def launch_service(
15
+ service_name: str,
16
+ server_runner: Callable[[], Any],
17
+ default_port: int = 8788,
18
+ root_dir: Path | str | None = None,
19
+ mutex_name: str | None = None,
20
+ ) -> None:
21
+ """Launch an MCP service in console mode with tunnel supervisor and single-instance guards.
22
+
23
+ Handles:
24
+ - sys.platform Windows console UTF-8 safety
25
+ - `--stop`: Terminate running server on port
26
+ - `--test`: Pre-flight check and exit
27
+ - Single-instance detection with helpful console alerts
28
+ - Background shared Cloudflare Tunnel check & heartbeat monitor
29
+ - Seamless delegation to server_runner with `--no-tunnel`
30
+ """
31
+ if sys.platform == "win32":
32
+ try:
33
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
34
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
35
+ except Exception:
36
+ pass
37
+
38
+ cwd = Path(root_dir).resolve() if root_dir else Path.cwd().resolve()
39
+ os.chdir(cwd)
40
+
41
+ try:
42
+ env_cfg = DeploymentEnvConfig.load(cwd)
43
+ port = env_cfg.port or default_port
44
+ key_configured = bool(env_cfg.api_key)
45
+ secret_key = env_cfg.api_key if "--show-key" in sys.argv else ("已配置(不显示密钥)" if key_configured else "未配置")
46
+ public_endpoint = env_cfg.upstream_url or f"http://127.0.0.1:{port}/mcp"
47
+ except Exception:
48
+ port = default_port
49
+ api_key_env = os.getenv("MCP_API_KEY", "")
50
+ key_configured = bool(api_key_env)
51
+ secret_key = api_key_env if "--show-key" in sys.argv else ("已配置(不显示密钥)" if key_configured else "未配置")
52
+ public_endpoint = os.getenv("TUNNEL_PUBLIC_URL", f"http://127.0.0.1:{port}/mcp")
53
+
54
+ local_endpoint = f"http://127.0.0.1:{port}/mcp"
55
+ active_mutex = mutex_name or f"Local\\{service_name.replace(' ', '_')}_{port}"
56
+
57
+ if "--stop" in sys.argv:
58
+ print(f"[*] 正在停止 {service_name} (端口 {port})...")
59
+ res = stop_mcp_service(port, cwd)
60
+ if res.get("killed_pids"):
61
+ print(f"[SUCCESS] {service_name} 服务已成功停止 (已终结进程: {res['killed_pids']})!")
62
+ else:
63
+ print(f"[INFO] 未检测到端口 {port} 上正在运行的 {service_name} 服务。")
64
+ sys.exit(0)
65
+
66
+ is_running, mode_desc, running_pid = check_service_status(port, cwd, active_mutex)
67
+ if is_running:
68
+ print("\n" + "=" * 70)
69
+ print(f" 【提示】{service_name} 服务已在运行中(或正在启动),请勿重复启动!")
70
+ print("=" * 70)
71
+ print(f" * 运行模式: {mode_desc}")
72
+ if running_pid:
73
+ print(f" * 进程 PID: {running_pid}")
74
+ print(f" * 专属公网端点: {public_endpoint}")
75
+ print(f" * 安全访问密钥: {secret_key}")
76
+ print(f" * 本地监听地址: {local_endpoint}")
77
+ print("-" * 70)
78
+ print(" 如需停止服务,请直接运行 stop.bat 或右键右下角托盘图标退出")
79
+ print("=" * 70 + "\n")
80
+ try:
81
+ input("按 Enter 键退出...")
82
+ except Exception:
83
+ pass
84
+ sys.exit(0)
85
+
86
+ # 1. Ensure shared tunnel is active & start self-healing heartbeat monitor
87
+ print(f"[*] 检查并确保共享穿透隧道在线...")
88
+ try:
89
+ ensure_tunnel_running()
90
+ if public_endpoint:
91
+ start_tunnel_heartbeat_monitor(public_url=public_endpoint, check_interval=30.0)
92
+ except Exception as e:
93
+ print(f"[!] 隧道自检提示: {e}")
94
+
95
+ if "--test" in sys.argv:
96
+ print(f"[OK] {service_name} launcher pre-flight check passed!")
97
+ sys.exit(0)
98
+
99
+ # 2. Run child server runner
100
+ print(f"[*] 正在启动 {service_name} (端口 {port})...\n")
101
+ if "--no-tunnel" not in sys.argv:
102
+ sys.argv = [sys.argv[0], "--no-tunnel"] + [arg for arg in sys.argv[1:] if arg != "--no-tunnel"]
103
+ server_runner()
@@ -0,0 +1,59 @@
1
+ """Structured response containers and protocol helpers for MCP tools."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ class ToolResultListDict(dict):
8
+ """Structured container that ensures atomic, single-block MCP response serialization while preserving list semantics.
9
+
10
+ FastMCP converts any returned Python list into multiple separate TextContent blocks
11
+ in the MCP protocol response. When intermediate MCP gateways or LLM clients parse
12
+ the response, many gateways only inspect the last TextContent block, resulting in
13
+ truncation (e.g. only returning the last project, file, or item).
14
+
15
+ ToolResultListDict inherits from dict so FastMCP serializes the ENTIRE structure
16
+ into a single TextContent JSON payload:
17
+ {"status": "success", "count": N, "total": N, "<key>": items, "entries": items}
18
+
19
+ Simultaneously, for Python unit tests and backward compatibility, it implements
20
+ list-like iteration (__iter__), indexing (__getitem__ for int), length (__len__),
21
+ and membership (__contains__).
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ items: list[Any],
27
+ key: str = "items",
28
+ status: str = "success",
29
+ **extra: Any,
30
+ ) -> None:
31
+ super().__init__()
32
+ self["status"] = status
33
+ self["total"] = len(items)
34
+ self["count"] = len(items)
35
+ self[key] = items
36
+ if key != "entries":
37
+ self["entries"] = items
38
+ for k, v in extra.items():
39
+ self[k] = v
40
+ self._items = items
41
+
42
+ def __iter__(self):
43
+ return iter(self._items)
44
+
45
+ def __len__(self) -> int:
46
+ return len(self._items)
47
+
48
+ def __getitem__(self, item: Any) -> Any:
49
+ if isinstance(item, int):
50
+ return self._items[item]
51
+ return super().__getitem__(item)
52
+
53
+ def __contains__(self, item: Any) -> bool:
54
+ try:
55
+ if super().__contains__(item):
56
+ return True
57
+ except TypeError:
58
+ pass
59
+ return item in self._items
@@ -154,8 +154,10 @@ async def serve_http(
154
154
  print(f" * 官方 MCP 门户: {portal_url} (ChatGPT 统一端点)")
155
155
  if upstream_mcp_url:
156
156
  print(f" * 内部隧道回源: {upstream_mcp_url}")
157
+ show_key = "--show-key" in sys.argv
157
158
  if env_cfg.require_auth:
158
- print(f" * 安全访问密钥: {env_cfg.api_key}")
159
+ key_display = env_cfg.api_key if show_key else "已配置 (运行传 --show-key 查看明文)"
160
+ print(f" * 安全访问密钥: {key_display}")
159
161
  print("-" * 76)
160
162
 
161
163
  print(" 【远程 Agent 配置指引 (Remote Agent Connection Guide)】")
@@ -164,19 +166,22 @@ async def serve_http(
164
166
  print(f" [ChatGPT Developer Mode / Custom Actions 连接指南]:")
165
167
  print(f" * 服务器 URL: {primary_endpoint}")
166
168
  print(f" * 身份验证类型: Bearer Token 或 OAuth")
167
- print(f" * 访问密钥: {env_cfg.api_key}")
169
+ key_hint = env_cfg.api_key if show_key else "请从本机安全配置获取 (日志不显示明文)"
170
+ print(f" * 访问密钥: {key_hint}")
168
171
  print(f" * 备用直连回源地址: {upstream_mcp_url}\n")
169
172
 
170
173
  if env_cfg.require_auth:
174
+ token_display = env_cfg.api_key if show_key else "<MCP_API_KEY>"
171
175
  print(f" [通用远程 Agent (网页端/Cursor/Claude Desktop/API)]: ")
172
- print(f" * 单 URL 快捷接入: {primary_endpoint}?token={env_cfg.api_key}")
173
- print(f" * Header 接入: Authorization: Bearer {env_cfg.api_key}")
176
+ print(f" * 单 URL 快捷接入: {primary_endpoint}?token={token_display}")
177
+ print(f" * Header 接入: Authorization: Bearer {token_display}")
174
178
  else:
175
179
  print(f" URL: {primary_endpoint}")
176
180
  else:
177
181
  if env_cfg.require_auth:
182
+ token_display = env_cfg.api_key if show_key else "<MCP_API_KEY>"
178
183
  print(f" URL: {local_mcp_url}")
179
- print(f" Header: Authorization: Bearer {env_cfg.api_key}")
184
+ print(f" Header: Authorization: Bearer {token_display}")
180
185
  else:
181
186
  print(f" URL: {local_mcp_url}")
182
187
 
@@ -0,0 +1,534 @@
1
+ """Reusable Windows System Tray Application Base for MCP Services."""
2
+ from __future__ import annotations
3
+
4
+ import atexit
5
+ import os
6
+ import secrets
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ import urllib.request
12
+ import webbrowser
13
+ from pathlib import Path
14
+ from typing import Any, Callable
15
+
16
+ if sys.platform == "win32":
17
+ try:
18
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
19
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
20
+ except Exception:
21
+ pass
22
+ try:
23
+ import ctypes
24
+ try:
25
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
26
+ except Exception:
27
+ ctypes.windll.user32.SetProcessDPIAware()
28
+ except Exception:
29
+ pass
30
+
31
+ try:
32
+ from PIL import Image, ImageDraw, ImageFont
33
+ import pystray
34
+ from pystray import MenuItem as item
35
+ except ImportError as err:
36
+ pystray = None
37
+ item = None
38
+
39
+ if sys.platform == "win32":
40
+ import win32api
41
+ import win32clipboard
42
+ import win32con
43
+ import win32event
44
+ import win32job
45
+ import winerror
46
+ else:
47
+ win32api = win32clipboard = win32con = win32event = win32job = winerror = None
48
+
49
+ from .env import DeploymentEnvConfig, get_runtime_dir, update_env_variable
50
+ from .process import is_port_in_use, kill_stale_server_process
51
+ from .tunnel import ensure_tunnel_running, stop_tunnel_if_no_mcp
52
+
53
+
54
+ def get_server_python_exe() -> str:
55
+ """Ensure child server runs with python.exe (with CREATE_NO_WINDOW) rather than pythonw.exe."""
56
+ py_exe = sys.executable
57
+ if py_exe.lower().endswith("pythonw.exe"):
58
+ cand = py_exe[:-5] + ".exe"
59
+ if os.path.exists(cand):
60
+ return cand
61
+ return py_exe
62
+
63
+
64
+ class ServerProcessSupervisor:
65
+ """Supervises child MCP server subprocess lifecycle bound to a Windows Job Object."""
66
+
67
+ def __init__(self, port: int, cwd: Path, server_cmd: list[str], service_name: str):
68
+ self.port = port
69
+ self.cwd = cwd
70
+ self.server_cmd = server_cmd
71
+ self.service_name = service_name
72
+ self.runtime_dir = get_runtime_dir(cwd)
73
+ self.runtime_dir.mkdir(parents=True, exist_ok=True)
74
+ self.proc: subprocess.Popen | None = None
75
+ self.job = None
76
+ self.status_text = f"● 【运行中】127.0.0.1:{self.port}"
77
+ self._restarting = False
78
+ self._init_job_object()
79
+
80
+ def _init_job_object(self) -> None:
81
+ if sys.platform != "win32":
82
+ return
83
+ try:
84
+ job = win32job.CreateJobObject(None, "")
85
+ info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation)
86
+ info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
87
+ win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, info)
88
+ self.job = job
89
+ except Exception:
90
+ self.job = None
91
+
92
+ def kill_stale_server_only(self) -> None:
93
+ kill_stale_server_process(self.port, self.cwd)
94
+ time.sleep(0.3)
95
+
96
+ def start(self) -> None:
97
+ self.kill_stale_server_only()
98
+ ensure_tunnel_running()
99
+
100
+ creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
101
+ env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
102
+
103
+ self.runtime_dir.mkdir(parents=True, exist_ok=True)
104
+ log_file = open(self.runtime_dir / "server_error.log", "w", encoding="utf-8")
105
+ self.proc = subprocess.Popen(
106
+ self.server_cmd,
107
+ cwd=str(self.cwd),
108
+ creationflags=creationflags,
109
+ env=env,
110
+ stdout=log_file,
111
+ stderr=log_file,
112
+ )
113
+
114
+ if self.job and self.proc and getattr(self.proc, "_handle", None):
115
+ try:
116
+ win32job.AssignProcessToJobObject(self.job, self.proc._handle)
117
+ except Exception:
118
+ pass
119
+
120
+ def stop(self) -> None:
121
+ if self.proc:
122
+ try:
123
+ self.proc.terminate()
124
+ self.proc.wait(timeout=2)
125
+ except Exception:
126
+ try:
127
+ self.proc.kill()
128
+ except Exception:
129
+ pass
130
+ self.proc = None
131
+ self.kill_stale_server_only()
132
+ stop_tunnel_if_no_mcp()
133
+
134
+ def restart_async(self, icon: Any | None = None) -> None:
135
+ if self._restarting:
136
+ return
137
+ threading.Thread(target=self._do_restart, args=(icon,), daemon=True).start()
138
+
139
+ def _do_restart(self, icon: Any | None = None) -> None:
140
+ self._restarting = True
141
+ self.status_text = f"● 【重启中...】127.0.0.1:{self.port}"
142
+ if icon:
143
+ try:
144
+ icon.update_menu()
145
+ icon.notify("正在重启 MCP 服务,请稍候...", self.service_name)
146
+ except Exception:
147
+ pass
148
+
149
+ try:
150
+ if self.proc:
151
+ try:
152
+ self.proc.terminate()
153
+ self.proc.wait(timeout=2)
154
+ except Exception:
155
+ try:
156
+ self.proc.kill()
157
+ except Exception:
158
+ pass
159
+ self.proc = None
160
+
161
+ self.kill_stale_server_only()
162
+ ensure_tunnel_running()
163
+
164
+ creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
165
+ env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
166
+
167
+ self.runtime_dir.mkdir(parents=True, exist_ok=True)
168
+ log_file = open(self.runtime_dir / "server_error.log", "w", encoding="utf-8")
169
+ self.proc = subprocess.Popen(
170
+ self.server_cmd,
171
+ cwd=str(self.cwd),
172
+ creationflags=creationflags,
173
+ env=env,
174
+ stdout=log_file,
175
+ stderr=log_file,
176
+ )
177
+
178
+ if self.job and self.proc and getattr(self.proc, "_handle", None):
179
+ try:
180
+ win32job.AssignProcessToJobObject(self.job, self.proc._handle)
181
+ except Exception:
182
+ pass
183
+
184
+ healthy = False
185
+ for _ in range(20):
186
+ time.sleep(0.5)
187
+ try:
188
+ req = urllib.request.Request(f"http://127.0.0.1:{self.port}/health")
189
+ with urllib.request.urlopen(req, timeout=1) as resp:
190
+ if resp.status == 200:
191
+ healthy = True
192
+ break
193
+ except Exception:
194
+ pass
195
+
196
+ if healthy:
197
+ self.status_text = f"● 【运行中】127.0.0.1:{self.port}"
198
+ if icon:
199
+ try:
200
+ icon.update_menu()
201
+ icon.notify(f"服务已重启完成并就绪 (端口 {self.port})", self.service_name)
202
+ except Exception:
203
+ pass
204
+ else:
205
+ self.status_text = f"● 【异常】端口 {self.port} 未响应"
206
+ if icon:
207
+ try:
208
+ icon.update_menu()
209
+ icon.notify("警告:服务重启超时,健康检查未通过!", self.service_name)
210
+ except Exception:
211
+ pass
212
+ finally:
213
+ self._restarting = False
214
+
215
+
216
+ class BaseTrayApp:
217
+ """Base class for Windows System Tray applications powering local MCP servers."""
218
+
219
+ def __init__(
220
+ self,
221
+ service_name: str,
222
+ base_dir: Path | str | None = None,
223
+ default_port: int = 8788,
224
+ icon_char: str = "M",
225
+ icon_color: str = "#00b894",
226
+ server_module: str | None = "app",
227
+ server_cmd: list[str] | None = None,
228
+ custom_menu_builder: Callable[[BaseTrayApp], list[Any]] | None = None,
229
+ ):
230
+ if pystray is None:
231
+ raise RuntimeError(
232
+ "pystray and pillow are required for BaseTrayApp. Please install them: pip install pystray pillow pywin32"
233
+ )
234
+
235
+ self.service_name = service_name
236
+ self.base_dir = Path(base_dir).resolve() if base_dir else Path.cwd().resolve()
237
+ self.runtime_dir = get_runtime_dir(self.base_dir)
238
+ self.runtime_dir.mkdir(parents=True, exist_ok=True)
239
+ self.default_port = default_port
240
+ self.icon_char = icon_char
241
+ self.icon_color = icon_color
242
+ self.custom_menu_builder = custom_menu_builder
243
+
244
+ self.port = self.get_port()
245
+ self.mutex_name = f"Local\\{self.service_name.replace(' ', '_')}_{self.port}"
246
+ self.tray_pid_file = self.runtime_dir / ".tray.pid"
247
+
248
+ if server_cmd is not None:
249
+ self.server_cmd = server_cmd
250
+ else:
251
+ py_exe = get_server_python_exe()
252
+ mod = server_module or "app"
253
+ self.server_cmd = [py_exe, "-m", mod, "--no-tunnel"]
254
+
255
+ self.supervisor = ServerProcessSupervisor(
256
+ port=self.port,
257
+ cwd=self.base_dir,
258
+ server_cmd=self.server_cmd,
259
+ service_name=self.service_name,
260
+ )
261
+ self.icon: pystray.Icon | None = None
262
+
263
+ def get_current_env(self) -> DeploymentEnvConfig | None:
264
+ try:
265
+ return DeploymentEnvConfig.load(self.base_dir)
266
+ except Exception:
267
+ return None
268
+
269
+ def get_port(self) -> int:
270
+ cfg = self.get_current_env()
271
+ if cfg and cfg.port:
272
+ return cfg.port
273
+ return self.default_port
274
+
275
+ def get_api_key(self) -> str:
276
+ cfg = self.get_current_env()
277
+ if cfg and cfg.api_key:
278
+ return cfg.api_key
279
+ return os.getenv("MCP_API_KEY", "")
280
+
281
+ def get_public_url(self) -> str:
282
+ cfg = self.get_current_env()
283
+ if cfg and cfg.upstream_url:
284
+ return cfg.upstream_url
285
+ return os.getenv("TUNNEL_PUBLIC_URL", f"http://127.0.0.1:{self.port}/mcp")
286
+
287
+ def record_tray_pid(self) -> None:
288
+ try:
289
+ self.runtime_dir.mkdir(parents=True, exist_ok=True)
290
+ self.tray_pid_file.write_text(str(os.getpid()), encoding="utf-8")
291
+ except Exception:
292
+ pass
293
+
294
+ def cleanup_tray_pid(self) -> None:
295
+ for t_file in [self.tray_pid_file, self.base_dir / ".tray.pid"]:
296
+ try:
297
+ if t_file.exists():
298
+ t_file.unlink()
299
+ except Exception:
300
+ pass
301
+
302
+ def ensure_single_instance(self) -> Any:
303
+ if sys.platform != "win32":
304
+ return None
305
+ mutex = win32event.CreateMutex(None, False, self.mutex_name)
306
+ if win32api.GetLastError() == winerror.ERROR_ALREADY_EXISTS:
307
+ try:
308
+ win32api.MessageBox(
309
+ 0,
310
+ f"【提示】{self.service_name} 已在系统托盘运行中!\n\n请在任务栏右下角通知区域查看图标。\n如需停止,可右键托盘图标退出或运行 stop.bat。",
311
+ self.service_name,
312
+ win32con.MB_ICONINFORMATION | win32con.MB_OK | win32con.MB_SYSTEMMODAL,
313
+ )
314
+ except Exception:
315
+ pass
316
+ sys.exit(0)
317
+ return mutex
318
+
319
+ def copy_to_clipboard(self, text: str) -> None:
320
+ if sys.platform != "win32":
321
+ return
322
+ try:
323
+ win32clipboard.OpenClipboard()
324
+ win32clipboard.EmptyClipboard()
325
+ win32clipboard.SetClipboardText(text, win32con.CF_UNICODETEXT)
326
+ win32clipboard.CloseClipboard()
327
+ except Exception as e:
328
+ print("Clipboard error:", e)
329
+
330
+ def message_box(
331
+ self,
332
+ text: str,
333
+ title: str | None = None,
334
+ flags: int = win32con.MB_ICONINFORMATION | win32con.MB_OK | win32con.MB_SYSTEMMODAL,
335
+ ) -> int:
336
+ if sys.platform != "win32":
337
+ return 0
338
+ return win32api.MessageBox(0, text, title or self.service_name, flags)
339
+
340
+ def choose_folder(self, title: str = "选择文件夹") -> str | None:
341
+ """Open native high-DPI directory chooser dialog with Tkinter or PowerShell fallback."""
342
+ try:
343
+ import tkinter as tk
344
+ from tkinter import filedialog
345
+ root = tk.Tk()
346
+ root.withdraw()
347
+ root.attributes("-topmost", True)
348
+
349
+ ico_path = self.runtime_dir / "app.ico"
350
+ if not ico_path.exists():
351
+ try:
352
+ self.create_tray_icon().save(ico_path, format="ICO")
353
+ except Exception:
354
+ pass
355
+ if ico_path.exists():
356
+ try:
357
+ root.iconbitmap(str(ico_path))
358
+ except Exception:
359
+ pass
360
+
361
+ folder = filedialog.askdirectory(title=title, parent=root)
362
+ root.destroy()
363
+ if folder:
364
+ return folder
365
+ except Exception:
366
+ pass
367
+
368
+ try:
369
+ ps_cmd = (
370
+ 'Add-Type -AssemblyName System.Windows.Forms; '
371
+ '$fb = New-Object System.Windows.Forms.FolderBrowserDialog; '
372
+ f'$fb.Description = "{title}"; '
373
+ '$fb.ShowNewFolderButton = $true; '
374
+ 'if ($fb.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { '
375
+ ' Write-Output $fb.SelectedPath '
376
+ '}'
377
+ )
378
+ creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
379
+ res = subprocess.run(
380
+ ["powershell", "-NoProfile", "-Command", ps_cmd],
381
+ capture_output=True,
382
+ text=True,
383
+ creationflags=creationflags,
384
+ )
385
+ out = res.stdout.strip()
386
+ return out if out else None
387
+ except Exception:
388
+ return None
389
+
390
+ def create_tray_icon(self) -> Image.Image:
391
+ """Create round dynamic tray icon with customizable letter and background color."""
392
+ img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
393
+ draw = ImageDraw.Draw(img)
394
+ draw.ellipse((4, 4, 60, 60), fill=self.icon_color)
395
+ try:
396
+ font = ImageFont.truetype("arial.ttf", 36)
397
+ draw.text((18, 12), self.icon_char, fill="white", font=font)
398
+ except Exception:
399
+ draw.text((22, 16), self.icon_char, fill="white")
400
+ return img
401
+
402
+ def build_custom_menu(self) -> list[Any]:
403
+ """Override this method or pass custom_menu_builder to inject business-specific menu items."""
404
+ if self.custom_menu_builder:
405
+ return self.custom_menu_builder(self)
406
+ return []
407
+
408
+ # Standard actions
409
+ def on_copy_url(self, icon: Any, _menu_item: Any) -> None:
410
+ url = self.get_public_url()
411
+ self.copy_to_clipboard(url)
412
+ try:
413
+ icon.notify("已复制 MCP 服务器网址:\n" + url, self.service_name)
414
+ except Exception:
415
+ pass
416
+
417
+ def on_copy_key(self, icon: Any, _menu_item: Any) -> None:
418
+ key = self.get_api_key()
419
+ self.copy_to_clipboard(key)
420
+ try:
421
+ icon.notify("已复制访问密匙:\n" + key, self.service_name)
422
+ except Exception:
423
+ pass
424
+
425
+ def on_rotate_key(self, icon: Any, _menu_item: Any) -> None:
426
+ new_key = f"sec_{secrets.token_urlsafe(16)}"
427
+ env_file = self.base_dir / ".env"
428
+ update_env_variable("MCP_API_KEY", new_key, env_file)
429
+ os.environ["MCP_API_KEY"] = new_key
430
+ self.copy_to_clipboard(new_key)
431
+ try:
432
+ icon.notify(
433
+ f"已生成新访问密匙并复制到剪贴板!\n{new_key}\n正在平滑重启服务以生效...",
434
+ self.service_name,
435
+ )
436
+ except Exception:
437
+ pass
438
+ self.supervisor.restart_async(icon)
439
+
440
+ def on_open_health(self, icon: Any, _menu_item: Any) -> None:
441
+ webbrowser.open(f"http://127.0.0.1:{self.port}/health")
442
+
443
+ def on_restart(self, icon: Any, _menu_item: Any) -> None:
444
+ self.supervisor.restart_async(icon)
445
+
446
+ def on_exit(self, icon: Any, _menu_item: Any) -> None:
447
+ def do_exit():
448
+ try:
449
+ icon.title = f"{self.service_name} (正在退出...)"
450
+ icon.notify(f"{self.service_name} 正在停止服务,请稍候...", self.service_name)
451
+ except Exception:
452
+ pass
453
+ try:
454
+ self.supervisor.stop()
455
+ except Exception:
456
+ pass
457
+ self.cleanup_tray_pid()
458
+ try:
459
+ icon.visible = False
460
+ icon.stop()
461
+ except Exception:
462
+ pass
463
+ os._exit(0)
464
+
465
+ threading.Thread(target=do_exit, daemon=True).start()
466
+
467
+ def build_menu(self) -> pystray.Menu:
468
+ items = []
469
+
470
+ # 1. Status header
471
+ items.append(item(lambda _: self.supervisor.status_text, lambda: None, enabled=False))
472
+ items.append(pystray.Menu.SEPARATOR)
473
+
474
+ # 2. Custom business menu items slot
475
+ custom_items = self.build_custom_menu()
476
+ if custom_items:
477
+ items.extend(custom_items)
478
+ items.append(pystray.Menu.SEPARATOR)
479
+
480
+ # 3. Standard platform tools
481
+ items.append(item("复制 MCP 服务器网址", self.on_copy_url))
482
+ items.append(item("复制访问密匙", self.on_copy_key))
483
+ items.append(item("重设访问密匙", self.on_rotate_key))
484
+ items.append(item("健康检查", self.on_open_health))
485
+ items.append(pystray.Menu.SEPARATOR)
486
+
487
+ # 4. Lifecycle controls
488
+ items.append(item("重启", self.on_restart))
489
+ items.append(item("退出", self.on_exit))
490
+
491
+ return pystray.Menu(*items)
492
+
493
+ def run(self) -> None:
494
+ mutex = self.ensure_single_instance()
495
+
496
+ if is_port_in_use(self.port):
497
+ try:
498
+ self.message_box(
499
+ f"{self.service_name} 已在运行中 (端口 {self.port} 已被占用)。\n\n如需切换为无黑框托盘常驻模式,请先运行 stop.bat 停止现有服务后再启动。",
500
+ flags=win32con.MB_ICONINFORMATION | win32con.MB_OK | win32con.MB_SYSTEMMODAL,
501
+ )
502
+ except Exception:
503
+ pass
504
+ sys.exit(0)
505
+
506
+ self.record_tray_pid()
507
+ atexit.register(self.cleanup_tray_pid)
508
+
509
+ self.supervisor.start()
510
+
511
+ self.icon = pystray.Icon(
512
+ name=f"{self.service_name.lower().replace(' ', '_')}_tray",
513
+ icon=self.create_tray_icon(),
514
+ title=f"{self.service_name} (端口 {self.port})",
515
+ menu=self.build_menu(),
516
+ )
517
+
518
+ try:
519
+ self.icon.run()
520
+ except Exception as e:
521
+ import traceback
522
+ try:
523
+ (self.runtime_dir / "tray_debug.log").write_text(
524
+ f"Exception: {e}\n{traceback.format_exc()}", encoding="utf-8"
525
+ )
526
+ except Exception:
527
+ pass
528
+ finally:
529
+ try:
530
+ self.supervisor.stop()
531
+ except Exception:
532
+ pass
533
+ self.cleanup_tray_pid()
534
+ os._exit(0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-server-core
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Universal MCP Server Framework: Streamable HTTP ASGI, Cloudflare Tunnel, OAuth 2.1 & Process Supervisor
5
5
  Author: yimmr
6
6
  License-Expression: MIT
@@ -19,6 +19,9 @@ Requires-Dist: uvicorn>=0.27.0
19
19
  Requires-Dist: pywin32>=306; platform_system == "Windows"
20
20
  Provides-Extra: test
21
21
  Requires-Dist: httpx>=0.27.0; extra == "test"
22
+ Provides-Extra: tray
23
+ Requires-Dist: pystray>=0.19.5; extra == "tray"
24
+ Requires-Dist: pillow>=10.0.0; extra == "tray"
22
25
  Dynamic: license-file
23
26
 
24
27
  # MCP Server Core 🚀
@@ -4,16 +4,22 @@ pyproject.toml
4
4
  mcp_framework/__init__.py
5
5
  mcp_framework/env.py
6
6
  mcp_framework/http_server.py
7
+ mcp_framework/launcher.py
7
8
  mcp_framework/oauth.py
8
9
  mcp_framework/process.py
10
+ mcp_framework/response.py
9
11
  mcp_framework/server.py
12
+ mcp_framework/tray.py
10
13
  mcp_framework/tunnel.py
11
14
  mcp_server_core/__init__.py
12
15
  mcp_server_core/env.py
13
16
  mcp_server_core/http_server.py
17
+ mcp_server_core/launcher.py
14
18
  mcp_server_core/oauth.py
15
19
  mcp_server_core/process.py
20
+ mcp_server_core/response.py
16
21
  mcp_server_core/server.py
22
+ mcp_server_core/tray.py
17
23
  mcp_server_core/tunnel.py
18
24
  mcp_server_core.egg-info/PKG-INFO
19
25
  mcp_server_core.egg-info/SOURCES.txt
@@ -7,3 +7,7 @@ pywin32>=306
7
7
 
8
8
  [test]
9
9
  httpx>=0.27.0
10
+
11
+ [tray]
12
+ pystray>=0.19.5
13
+ pillow>=10.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mcp-server-core"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Universal MCP Server Framework: Streamable HTTP ASGI, Cloudflare Tunnel, OAuth 2.1 & Process Supervisor"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -31,6 +31,10 @@ dependencies = [
31
31
  test = [
32
32
  "httpx>=0.27.0",
33
33
  ]
34
+ tray = [
35
+ "pystray>=0.19.5",
36
+ "pillow>=10.0.0",
37
+ ]
34
38
 
35
39
  [tool.setuptools.packages.find]
36
40
  where = ["."]
File without changes