ssh-licco 2.7.0__tar.gz → 2.7.2__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 (52) hide show
  1. {ssh_licco-2.7.0/ssh_licco.egg-info → ssh_licco-2.7.2}/PKG-INFO +55 -3
  2. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/README.md +54 -2
  3. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/pyproject.toml +1 -1
  4. {ssh_licco-2.7.0 → ssh_licco-2.7.2/ssh_licco.egg-info}/PKG-INFO +55 -3
  5. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/__init__.py +2 -2
  6. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/utils.py +18 -3
  7. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/server.py +1 -1
  8. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/session_manager.py +32 -32
  9. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_server.py +46 -1
  10. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_session_manager.py +108 -5
  11. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/LICENSE +0 -0
  12. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/setup.cfg +0 -0
  13. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/SOURCES.txt +0 -0
  14. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/dependency_links.txt +0 -0
  15. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/entry_points.txt +0 -0
  16. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/requires.txt +0 -0
  17. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/top_level.txt +0 -0
  18. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/__main__.py +0 -0
  19. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/audit_logger.py +0 -0
  20. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/batch_executor.py +0 -0
  21. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/cli.py +0 -0
  22. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/__init__.py +0 -0
  23. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/additional_clients.py +0 -0
  24. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/factory.py +0 -0
  25. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/interface.py +0 -0
  26. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/paramiko_client.py +0 -0
  27. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/config_manager.py +0 -0
  28. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/connection_config.py +0 -0
  29. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/connection_pool.py +0 -0
  30. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/exceptions.py +0 -0
  31. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/executor.py +0 -0
  32. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/__init__.py +0 -0
  33. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/connect.py +0 -0
  34. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/context.py +0 -0
  35. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/docker.py +0 -0
  36. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/execute.py +0 -0
  37. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/file_transfer.py +0 -0
  38. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/host.py +0 -0
  39. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/key.py +0 -0
  40. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/process.py +0 -0
  41. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/schemas.py +0 -0
  42. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/session.py +0 -0
  43. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/key_manager.py +0 -0
  44. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/logging_config.py +0 -0
  45. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/security.py +0 -0
  46. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/service.py +0 -0
  47. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/tunnel.py +0 -0
  48. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/watchdog.py +0 -0
  49. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_config_manager.py +0 -0
  50. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_key_manager.py +0 -0
  51. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_security.py +0 -0
  52. {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_watchdog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-licco
3
- Version: 2.7.0
3
+ Version: 2.7.2
4
4
  Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
5
5
  Author-email: SSH LICCO Team <contact@ssh-licco.dev>
6
6
  License: MIT
@@ -63,7 +63,7 @@ Dynamic: license-file
63
63
 
64
64
  ### 开发资源
65
65
  - **[🎓 Skills 文档](docs/skills/)** - 开发、运维、安装指南
66
- - **[📦 发布指南](docs/skills/RELEASE_SKILL.md)** - 版本发布流程
66
+ - **[📦 发布指南](#-发布指南一体化命令)** - 一体化版本发布命令
67
67
  - **[🐛 GitHub Issues](https://github.com/Echoqili/ssh-licco/issues)** - 问题反馈
68
68
 
69
69
  ---
@@ -622,7 +622,7 @@ ssh-licco 的核心依赖是:
622
622
 
623
623
  | 指标 | 状态 |
624
624
  |------|------|
625
- | **测试用例** | 424 passed, 0 skipped |
625
+ | **测试用例** | 244 passed, 0 skipped |
626
626
  | **覆盖率** | 16 个源模块全覆盖 |
627
627
  | **测试框架** | pytest + pytest-asyncio |
628
628
 
@@ -663,4 +663,56 @@ pytest --cov=ssh_mcp --cov-report=term-missing
663
663
 
664
664
  ---
665
665
 
666
+ ## 📦 发布指南(一体化命令)
667
+
668
+ 项目提供 `sync_version.py` 作为**唯一**版本发布入口,一条命令完成所有版本源同步 + 文档更新 + 一致性自检 + git commit/tag/push,杜绝漏改 `VERSION` / `package.json` / `SKILL.md` 等文件。
669
+
670
+ ### 一键发布
671
+
672
+ ```bash
673
+ # 升 patch(z):2.7.1 → 2.7.2
674
+ python sync_version.py 2.7.2
675
+
676
+ # 升 minor(y):2.7.1 → 2.8.0
677
+ python sync_version.py 2.8.0
678
+
679
+ # 升 major(x):2.7.1 → 3.0.0
680
+ python sync_version.py 3.0.0
681
+ ```
682
+
683
+ 默认行为:改版本源 → 同步文档版本 → 一致性自检 → `commit` → `push` → 打 `tag` → `push tag`。
684
+
685
+ ### 常用选项
686
+
687
+ ```bash
688
+ # 预览会发生的变更,不写文件、不 commit
689
+ python sync_version.py 2.7.2 --dry-run
690
+
691
+ # 只改文件,不提交、不打 tag
692
+ python sync_version.py 2.7.2 --no-commit --no-tag
693
+
694
+ # 只做一致性自检(CI 中使用)
695
+ python sync_version.py --check
696
+ ```
697
+
698
+ ### 覆盖的版本源
699
+
700
+ | 文件 | 字段 | 说明 |
701
+ |------|------|------|
702
+ | `ssh_mcp/__init__.py` | `__version__` | **唯一真源**,其他文件都与其对齐 |
703
+ | `pyproject.toml` | `version` | Python 包构建版本 |
704
+ | `VERSION` | 纯文本 | `.github/workflows/pypi.yml` 实际读取的版本 |
705
+ | `package.json` | `version` | npm 包版本 |
706
+ | `package-lock.json` | `version` × 2 | npm lock 根版本 + `packages[""].version` |
707
+ | `.trae/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
708
+ | `docs/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
709
+
710
+ ### CI 预检
711
+
712
+ `.github/workflows/pypi.yml` 在构建前会执行 `python sync_version.py --check`,任何版本源不一致都会**直接中断发布流程**,防止打错版本号。
713
+
714
+ > 完整发布流程(含 PyPI 上传、MCP Registry 发布)见 [docs/skills/RELEASE_SKILL.md](docs/skills/RELEASE_SKILL.md)。
715
+
716
+ ---
717
+
666
718
  ## 📊 版本历史
@@ -30,7 +30,7 @@
30
30
 
31
31
  ### 开发资源
32
32
  - **[🎓 Skills 文档](docs/skills/)** - 开发、运维、安装指南
33
- - **[📦 发布指南](docs/skills/RELEASE_SKILL.md)** - 版本发布流程
33
+ - **[📦 发布指南](#-发布指南一体化命令)** - 一体化版本发布命令
34
34
  - **[🐛 GitHub Issues](https://github.com/Echoqili/ssh-licco/issues)** - 问题反馈
35
35
 
36
36
  ---
@@ -589,7 +589,7 @@ ssh-licco 的核心依赖是:
589
589
 
590
590
  | 指标 | 状态 |
591
591
  |------|------|
592
- | **测试用例** | 424 passed, 0 skipped |
592
+ | **测试用例** | 244 passed, 0 skipped |
593
593
  | **覆盖率** | 16 个源模块全覆盖 |
594
594
  | **测试框架** | pytest + pytest-asyncio |
595
595
 
@@ -630,4 +630,56 @@ pytest --cov=ssh_mcp --cov-report=term-missing
630
630
 
631
631
  ---
632
632
 
633
+ ## 📦 发布指南(一体化命令)
634
+
635
+ 项目提供 `sync_version.py` 作为**唯一**版本发布入口,一条命令完成所有版本源同步 + 文档更新 + 一致性自检 + git commit/tag/push,杜绝漏改 `VERSION` / `package.json` / `SKILL.md` 等文件。
636
+
637
+ ### 一键发布
638
+
639
+ ```bash
640
+ # 升 patch(z):2.7.1 → 2.7.2
641
+ python sync_version.py 2.7.2
642
+
643
+ # 升 minor(y):2.7.1 → 2.8.0
644
+ python sync_version.py 2.8.0
645
+
646
+ # 升 major(x):2.7.1 → 3.0.0
647
+ python sync_version.py 3.0.0
648
+ ```
649
+
650
+ 默认行为:改版本源 → 同步文档版本 → 一致性自检 → `commit` → `push` → 打 `tag` → `push tag`。
651
+
652
+ ### 常用选项
653
+
654
+ ```bash
655
+ # 预览会发生的变更,不写文件、不 commit
656
+ python sync_version.py 2.7.2 --dry-run
657
+
658
+ # 只改文件,不提交、不打 tag
659
+ python sync_version.py 2.7.2 --no-commit --no-tag
660
+
661
+ # 只做一致性自检(CI 中使用)
662
+ python sync_version.py --check
663
+ ```
664
+
665
+ ### 覆盖的版本源
666
+
667
+ | 文件 | 字段 | 说明 |
668
+ |------|------|------|
669
+ | `ssh_mcp/__init__.py` | `__version__` | **唯一真源**,其他文件都与其对齐 |
670
+ | `pyproject.toml` | `version` | Python 包构建版本 |
671
+ | `VERSION` | 纯文本 | `.github/workflows/pypi.yml` 实际读取的版本 |
672
+ | `package.json` | `version` | npm 包版本 |
673
+ | `package-lock.json` | `version` × 2 | npm lock 根版本 + `packages[""].version` |
674
+ | `.trae/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
675
+ | `docs/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
676
+
677
+ ### CI 预检
678
+
679
+ `.github/workflows/pypi.yml` 在构建前会执行 `python sync_version.py --check`,任何版本源不一致都会**直接中断发布流程**,防止打错版本号。
680
+
681
+ > 完整发布流程(含 PyPI 上传、MCP Registry 发布)见 [docs/skills/RELEASE_SKILL.md](docs/skills/RELEASE_SKILL.md)。
682
+
683
+ ---
684
+
633
685
  ## 📊 版本历史
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssh-licco"
3
- version = "2.7.0"
3
+ version = "2.7.2"
4
4
  description = "SSH Model Context Protocol Server - Enable SSH functionality for AI models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-licco
3
- Version: 2.7.0
3
+ Version: 2.7.2
4
4
  Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
5
5
  Author-email: SSH LICCO Team <contact@ssh-licco.dev>
6
6
  License: MIT
@@ -63,7 +63,7 @@ Dynamic: license-file
63
63
 
64
64
  ### 开发资源
65
65
  - **[🎓 Skills 文档](docs/skills/)** - 开发、运维、安装指南
66
- - **[📦 发布指南](docs/skills/RELEASE_SKILL.md)** - 版本发布流程
66
+ - **[📦 发布指南](#-发布指南一体化命令)** - 一体化版本发布命令
67
67
  - **[🐛 GitHub Issues](https://github.com/Echoqili/ssh-licco/issues)** - 问题反馈
68
68
 
69
69
  ---
@@ -622,7 +622,7 @@ ssh-licco 的核心依赖是:
622
622
 
623
623
  | 指标 | 状态 |
624
624
  |------|------|
625
- | **测试用例** | 424 passed, 0 skipped |
625
+ | **测试用例** | 244 passed, 0 skipped |
626
626
  | **覆盖率** | 16 个源模块全覆盖 |
627
627
  | **测试框架** | pytest + pytest-asyncio |
628
628
 
@@ -663,4 +663,56 @@ pytest --cov=ssh_mcp --cov-report=term-missing
663
663
 
664
664
  ---
665
665
 
666
+ ## 📦 发布指南(一体化命令)
667
+
668
+ 项目提供 `sync_version.py` 作为**唯一**版本发布入口,一条命令完成所有版本源同步 + 文档更新 + 一致性自检 + git commit/tag/push,杜绝漏改 `VERSION` / `package.json` / `SKILL.md` 等文件。
669
+
670
+ ### 一键发布
671
+
672
+ ```bash
673
+ # 升 patch(z):2.7.1 → 2.7.2
674
+ python sync_version.py 2.7.2
675
+
676
+ # 升 minor(y):2.7.1 → 2.8.0
677
+ python sync_version.py 2.8.0
678
+
679
+ # 升 major(x):2.7.1 → 3.0.0
680
+ python sync_version.py 3.0.0
681
+ ```
682
+
683
+ 默认行为:改版本源 → 同步文档版本 → 一致性自检 → `commit` → `push` → 打 `tag` → `push tag`。
684
+
685
+ ### 常用选项
686
+
687
+ ```bash
688
+ # 预览会发生的变更,不写文件、不 commit
689
+ python sync_version.py 2.7.2 --dry-run
690
+
691
+ # 只改文件,不提交、不打 tag
692
+ python sync_version.py 2.7.2 --no-commit --no-tag
693
+
694
+ # 只做一致性自检(CI 中使用)
695
+ python sync_version.py --check
696
+ ```
697
+
698
+ ### 覆盖的版本源
699
+
700
+ | 文件 | 字段 | 说明 |
701
+ |------|------|------|
702
+ | `ssh_mcp/__init__.py` | `__version__` | **唯一真源**,其他文件都与其对齐 |
703
+ | `pyproject.toml` | `version` | Python 包构建版本 |
704
+ | `VERSION` | 纯文本 | `.github/workflows/pypi.yml` 实际读取的版本 |
705
+ | `package.json` | `version` | npm 包版本 |
706
+ | `package-lock.json` | `version` × 2 | npm lock 根版本 + `packages[""].version` |
707
+ | `.trae/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
708
+ | `docs/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
709
+
710
+ ### CI 预检
711
+
712
+ `.github/workflows/pypi.yml` 在构建前会执行 `python sync_version.py --check`,任何版本源不一致都会**直接中断发布流程**,防止打错版本号。
713
+
714
+ > 完整发布流程(含 PyPI 上传、MCP Registry 发布)见 [docs/skills/RELEASE_SKILL.md](docs/skills/RELEASE_SKILL.md)。
715
+
716
+ ---
717
+
666
718
  ## 📊 版本历史
@@ -1,8 +1,8 @@
1
1
  # SSH-LICCO 版本信息
2
2
  # 所有代码中的版本信息都从这里读取
3
- # 版本号:2.7.0
3
+ # 版本号:2.7.2
4
4
 
5
- __version__ = "2.7.0"
5
+ __version__ = "2.7.2"
6
6
  __author__ = "Li Qi"
7
7
  __email__ = "1985694657@qq.com"
8
8
  __license__ = "MIT"
@@ -18,16 +18,30 @@ async def ensure_session(
18
18
  """Ensure an active session exists.
19
19
 
20
20
  Priority:
21
- 1. session_id if provided
21
+ 1. session_id if provided (validated; stale ids fall through to rebuild)
22
22
  2. name/host_name from hosts.json
23
23
  3. host directly (with optional port/username/password)
24
24
  4. env-config fallback (auto-connect)
25
25
 
26
26
  Returns session_id on success, None on failure.
27
27
  """
28
+ stale_session_id = None
28
29
  session_id = args.get("session_id")
29
30
  if session_id:
30
- return session_id
31
+ # 校验 session_id 存活;get_session 内部会对死会话做透明重建(复用同 id)
32
+ try:
33
+ session = await ctx.session_manager.get_session(session_id)
34
+ except Exception:
35
+ session = None
36
+ if session:
37
+ return session_id
38
+ # session_id 已失效(MCP 进程重启 / 重建失败 / entry 被回收):
39
+ # 不直接采用必失败的 id,而是走 name/host/env 回退链透明重建,
40
+ # 避免 "Session not found" 中断调用方
41
+ stale_session_id = session_id
42
+ ctx.logger.warning(
43
+ f"session_id {session_id} 已失效,尝试通过 name/host/env 配置重建会话"
44
+ )
31
45
 
32
46
  # Use named host from hosts.json (name or host_name)
33
47
  host_name = args.get("name") or args.get("host_name")
@@ -65,7 +79,8 @@ async def ensure_session(
65
79
  return line.split("Session ID:")[1].strip()
66
80
  return None
67
81
 
68
- return None
82
+ # 回退链也无法建连:返回原 session_id,让上层报准确的 "Session not found: {id}"
83
+ return stale_session_id
69
84
 
70
85
 
71
86
  def diagnose_exit_code(exit_code: int, log_tail: str, stderr: str) -> str:
@@ -141,7 +141,7 @@ class Tunnel:
141
141
 
142
142
  class SSHMCPServer:
143
143
  def __init__(self):
144
- self.server = Server("ssh-licco", __version__)
144
+ self.server = Server("ssh-licco", version=__version__)
145
145
  self.session_manager = SessionManager()
146
146
  self.key_manager = KeyManager()
147
147
  self.config_manager = ConfigManager()
@@ -57,8 +57,7 @@ class SSHSession:
57
57
  self._connect_lock = asyncio.Lock()
58
58
  self._shutdown_event = asyncio.Event()
59
59
  self._background_channels: list = [] # 保持 background channel 引用,防止 GC 关闭 channel
60
- self._reconnect_count = 0 # 重连次数统计
61
- self._max_reconnects = 3 # 最大重连次数
60
+ self._reconnect_count = 0 # 连续重建失败计数(成功后清零,仅用于日志观察)
62
61
  self._logger = get_logger("SSHSession")
63
62
 
64
63
  @property
@@ -734,10 +733,12 @@ class SessionManager:
734
733
  timeout_sessions = []
735
734
 
736
735
  for session_id, session in self._sessions.items():
737
- if session.is_connected:
738
- idle_time = now - session._last_activity
739
- if idle_time > timedelta(seconds=session.config.session_timeout):
740
- timeout_sessions.append(session_id)
736
+ # idle 超时对存活与失联会话一视同仁:
737
+ # - 存活会话超时 → 正常回收(原逻辑)
738
+ # - dead entry 超时 → 兜底回收,防止重建一直失败的 entry 永久滞留
739
+ idle_time = now - session._last_activity
740
+ if idle_time > timedelta(seconds=session.config.session_timeout):
741
+ timeout_sessions.append(session_id)
741
742
 
742
743
  for session_id in timeout_sessions:
743
744
  try:
@@ -763,13 +764,16 @@ class SessionManager:
763
764
  return session._get_session_info()
764
765
 
765
766
  # 🔒 安全检查:会话并发数限制
766
- if len(self._sessions) >= self.MAX_SESSIONS:
767
+ # 只统计存活会话:dead entry 不占用并发额度(重建失败时 entry 会被保留供重试),
768
+ # 避免 5 个死会话占满 MAX_SESSIONS_PER_HOST 导致新连接全部被拒
769
+ live_sessions = [s for s in self._sessions.values() if s.is_connected]
770
+ if len(live_sessions) >= self.MAX_SESSIONS:
767
771
  raise ConnectionException(
768
772
  f"达到最大会话数限制 ({self.MAX_SESSIONS}),请先关闭一些会话"
769
773
  )
770
774
 
771
775
  # 🔒 安全检查:每个主机会话数限制
772
- host_sessions = sum(1 for s in self._sessions.values() if s.config.host == config.host)
776
+ host_sessions = sum(1 for s in live_sessions if s.config.host == config.host)
773
777
  if host_sessions >= self.MAX_SESSIONS_PER_HOST:
774
778
  raise ConnectionException(
775
779
  f"主机 {config.host} 已达到最大会话数限制 ({self.MAX_SESSIONS_PER_HOST})"
@@ -785,7 +789,7 @@ class SessionManager:
785
789
  async with self._lock:
786
790
  session = self._sessions.get(session_id)
787
791
 
788
- # 🔄 自动重连机制:如果 session 存在但已断开,尝试重连
792
+ # 🔄 自动重连机制:如果 session 存在但已断开,尝试透明重建
789
793
  if session and not session.is_connected and auto_reconnect:
790
794
  # 非真实 SSHSession 实例(如测试 Mock)不尝试重连,直接清理
791
795
  if not isinstance(session, SSHSession):
@@ -799,44 +803,35 @@ class SessionManager:
799
803
  del self._sessions[session_id]
800
804
  return None
801
805
 
802
- # 检查重连次数限制
803
- if session._reconnect_count >= session._max_reconnects:
804
- self._logger.error(
805
- f"Session {session_id} 重连次数已达上限 ({session._max_reconnects}),不再重连"
806
- )
807
- await session.disconnect()
808
- if session_id in self._sessions:
809
- del self._sessions[session_id]
810
- return None
811
-
812
806
  self._logger.warning(
813
- f"Session {session_id} 已断开,尝试自动重连(重连次数:{session._reconnect_count}/{session._max_reconnects})"
807
+ f"Session {session_id} 已断开,尝试透明重建(连续失败次数:{session._reconnect_count})"
814
808
  )
815
809
  try:
816
- # 保存配置信息用于重连
810
+ # 保存配置信息用于重建
817
811
  config = session.config
818
812
  session._reconnect_count += 1
819
-
820
- # 清理旧 session
821
813
  await session.disconnect()
822
- if session_id in self._sessions:
823
- del self._sessions[session_id]
824
814
 
825
- # 创建新 session(使用相同的 session_id)
815
+ # 🔄 透明重建:用相同配置创建新 session 并复用原 session_id,
816
+ # 调用方持有的 session_id 始终有效,无需感知底层重建。
817
+ # 旧逻辑的"重连次数上限/重建失败即删除 entry"会导致 session_id
818
+ # 永久失效(表现即 "Session not found"),已移除;
819
+ # 重建失败时保留 dead entry(含连接配置),下次调用继续重试,
820
+ # 长期失联的 dead entry 由 _cleanup_timeout_sessions 按 idle 超时兜底回收
826
821
  new_session = SSHSession(config)
827
822
  new_session.session_id = session_id # 复用原来的 session_id
828
- new_session._reconnect_count = session._reconnect_count # 保持重连次数
829
823
  await new_session.connect()
824
+ new_session._reconnect_count = 0 # 重建成功,清零连续失败计数
830
825
  self._sessions[session_id] = new_session
831
- self._logger.info(f"Session {session_id} 自动重连成功")
826
+ self._logger.info(f"Session {session_id} 透明重建成功")
832
827
  return new_session
833
828
  except Exception as e:
834
- self._logger.error(f"Session {session_id} 自动重连失败: {e}")
835
- if session_id in self._sessions:
836
- del self._sessions[session_id]
829
+ self._logger.error(f"Session {session_id} 透明重建失败: {e}")
830
+ # 不删除 entry:保留 dead session 及其配置,供后续调用重试
831
+ self._sessions[session_id] = session
837
832
  return None
838
833
 
839
- # 🧹 清理完全失效的 session
834
+ # 🧹 清理完全失效的 session(仅 auto_reconnect=False 的显式清理路径)
840
835
  if session and not session.is_connected:
841
836
  self._logger.warning(
842
837
  f"Session {session_id} 已断开,清理(host={session.config.host})"
@@ -846,6 +841,11 @@ class SessionManager:
846
841
  del self._sessions[session_id]
847
842
  return None
848
843
 
844
+ # 存活会话:会话被正常使用即视为健康,重置连续失败计数,
845
+ # 避免历史上的瞬时网络抖动累计导致会话被判死
846
+ if session and session.is_connected:
847
+ session._reconnect_count = 0
848
+
849
849
  return session
850
850
 
851
851
  async def close_session(self, session_id: str) -> None:
@@ -11,7 +11,7 @@ from ssh_mcp.handlers.execute import handle_execute
11
11
  from ssh_mcp.handlers.file_transfer import handle_file_transfer
12
12
  from ssh_mcp.handlers.host import handle_host
13
13
  from ssh_mcp.handlers.key import handle_generate_key
14
- from ssh_mcp.handlers.utils import should_run_background
14
+ from ssh_mcp.handlers.utils import ensure_session, should_run_background
15
15
  from ssh_mcp.server import SSHMCPServer
16
16
 
17
17
 
@@ -714,3 +714,48 @@ class TestHandleTaskStatus:
714
714
  },
715
715
  )
716
716
  assert len(result) == 1
717
+
718
+
719
+ class TestEnsureSessionStaleId:
720
+ """session_id 失效时的透明重建:不再直接返回必失败的 id"""
721
+
722
+ @staticmethod
723
+ def _ctx(get_session_return=None):
724
+ ctx = MagicMock()
725
+ ctx.session_manager = MagicMock()
726
+ ctx.session_manager.get_session = AsyncMock(return_value=get_session_return)
727
+ ctx.logger = MagicMock()
728
+ ctx.env_config = None
729
+ return ctx
730
+
731
+ @pytest.mark.asyncio
732
+ async def test_live_session_id_returned_directly(self):
733
+ ctx = self._ctx(get_session_return=MagicMock())
734
+ sid = await ensure_session(ctx, {"session_id": "s1"}, AsyncMock())
735
+ assert sid == "s1"
736
+ ctx.session_manager.get_session.assert_awaited_once_with("s1")
737
+
738
+ @pytest.mark.asyncio
739
+ async def test_stale_id_rebuilds_via_host(self):
740
+ """失效 id + 同请求携带 host 参数 → 走回退链透明重建"""
741
+ from mcp.types import TextContent
742
+
743
+ ctx = self._ctx(get_session_return=None)
744
+
745
+ async def fake_connect(c, a):
746
+ return [TextContent(type="text", text="Connected to 1.2.3.4\nSession ID: fresh-id")]
747
+
748
+ sid = await ensure_session(ctx, {"session_id": "stale", "host": "1.2.3.4"}, fake_connect)
749
+ assert sid == "fresh-id"
750
+ ctx.logger.warning.assert_called_once() # 失效告警已记录
751
+
752
+ @pytest.mark.asyncio
753
+ async def test_stale_id_without_fallback_returns_original(self):
754
+ """失效 id 且无任何回退信息 → 返回原 id,由上层报准确的 Session not found"""
755
+
756
+ async def unexpected_connect(c, a):
757
+ raise AssertionError("no name/host/env configured, connect should not be called")
758
+
759
+ ctx = self._ctx(get_session_return=None)
760
+ sid = await ensure_session(ctx, {"session_id": "stale"}, unexpected_connect)
761
+ assert sid == "stale"
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- from datetime import datetime
4
+ from datetime import datetime, timedelta
5
5
  from unittest.mock import AsyncMock, MagicMock, patch
6
6
 
7
7
  import pytest
@@ -147,14 +147,12 @@ class TestSessionManager:
147
147
 
148
148
  @pytest.mark.asyncio
149
149
  async def test_get_session_disconnected_auto_clean(self):
150
- """get_session 遇到已断开的 session 时,应自动清理并返回 None(不抛 AttributeError)"""
150
+ """get_session 遇到非真实 SSHSession 对象(如测试 Mock)时,应清理并返回 None"""
151
151
  manager = SessionManager()
152
152
  mock_session = MagicMock()
153
153
  mock_session.is_connected = False
154
154
  mock_session.config.host = "1.2.3.4"
155
- # 让重连计数已达上限,走"清理并返回 None"分支(避免 MagicMock 比较 '>=' 报错)
156
- mock_session._reconnect_count = 99
157
- mock_session._max_reconnects = 1
155
+ # MagicMock 不是 SSHSession 实例,走"跳过重连并清理"分支
158
156
  # disconnect 是协程,需用 AsyncMock
159
157
  mock_session.disconnect = AsyncMock(return_value=None)
160
158
  manager._sessions["dead-id"] = mock_session
@@ -168,6 +166,111 @@ class TestSessionManager:
168
166
  result = await manager.get_session("nonexistent")
169
167
  assert result is None
170
168
 
169
+ @pytest.mark.asyncio
170
+ async def test_get_session_dead_transparent_rebuild(self, conn_config):
171
+ """死会话透明重建:复用原 session_id、成功后连续失败计数清零(不再删除 entry)"""
172
+
173
+ class FakeSession(SSHSession):
174
+ async def connect(self):
175
+ self._state = SessionState.CONNECTED
176
+ self.client = MagicMock()
177
+ return self._get_session_info()
178
+
179
+ manager = SessionManager()
180
+ # dead 必须是 FakeSession 实例(不调用 connect 即保持断开态),
181
+ # 否则补丁后 isinstance 检查会走清理分支
182
+ dead = FakeSession(conn_config)
183
+ dead._reconnect_count = 99 # 即使远超旧的重连上限也应重建
184
+ manager._sessions["sid"] = dead
185
+
186
+ with patch("ssh_mcp.session_manager.SSHSession", FakeSession):
187
+ result = await manager.get_session("sid")
188
+
189
+ assert isinstance(result, FakeSession)
190
+ assert result is not dead # 底层对象已替换
191
+ assert result.session_id == "sid" # 调用方持有的 session_id 保持有效
192
+ assert manager._sessions["sid"] is result
193
+ assert result._reconnect_count == 0
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_get_session_rebuild_failure_keeps_entry(self, conn_config):
197
+ """重建失败不删除 entry:session_id 保留,主机恢复后下次调用重建成功"""
198
+
199
+ class FlakySession(SSHSession):
200
+ fail_connect = True
201
+
202
+ async def connect(self):
203
+ if type(self).fail_connect:
204
+ raise ConnectionError("host unreachable")
205
+ self._state = SessionState.CONNECTED
206
+ self.client = MagicMock()
207
+ return self._get_session_info()
208
+
209
+ manager = SessionManager()
210
+ dead = FlakySession(conn_config) # 未连接 → 死会话
211
+ manager._sessions["sid"] = dead
212
+
213
+ with patch("ssh_mcp.session_manager.SSHSession", FlakySession):
214
+ # 阶段一:主机不可达,重建失败但 entry 保留
215
+ result = await manager.get_session("sid")
216
+ assert result is None
217
+ assert manager._sessions["sid"] is dead # entry 保留供后续重试
218
+ assert dead._reconnect_count == 1 # 连续失败计数累加(日志观察用)
219
+
220
+ # 阶段二:主机恢复,同一 session_id 透明重建成功
221
+ FlakySession.fail_connect = False
222
+ result = await manager.get_session("sid")
223
+ assert result is not None
224
+ assert result.session_id == "sid"
225
+ assert manager._sessions["sid"] is result
226
+
227
+ @pytest.mark.asyncio
228
+ async def test_get_session_live_resets_reconnect_count(self, conn_config):
229
+ """存活会话被正常获取时重置连续失败计数,历史抖动不累计"""
230
+ manager = SessionManager()
231
+ live = SSHSession(conn_config)
232
+ live._state = SessionState.CONNECTED
233
+ live.client = MagicMock()
234
+ live._reconnect_count = 2
235
+ manager._sessions["sid"] = live
236
+
237
+ result = await manager.get_session("sid")
238
+ assert result is live
239
+ assert live._reconnect_count == 0
240
+
241
+ @pytest.mark.asyncio
242
+ async def test_create_session_dead_entries_do_not_count_toward_limit(self, conn_config):
243
+ """dead entry 不占用并发额度:同主机多个死会话不应阻止新建"""
244
+
245
+ class FakeSession(SSHSession):
246
+ async def connect(self):
247
+ self._state = SessionState.CONNECTED
248
+ self.client = MagicMock()
249
+ return self._get_session_info()
250
+
251
+ manager = SessionManager()
252
+ for i in range(manager.MAX_SESSIONS_PER_HOST):
253
+ dead = SSHSession(conn_config) # 从未连接 → 死会话
254
+ manager._sessions[f"dead-{i}"] = dead
255
+
256
+ with patch("ssh_mcp.session_manager.SSHSession", FakeSession):
257
+ info = await manager.create_session(conn_config) # 不应抛 ConnectionException
258
+
259
+ assert info.session_id in manager._sessions
260
+
261
+ @pytest.mark.asyncio
262
+ async def test_cleanup_timeout_sessions_reaps_dead_entries(self, conn_config):
263
+ """dead entry 超过 idle 超时后由清理循环兜底回收,不会永久滞留"""
264
+ manager = SessionManager()
265
+ dead = SSHSession(conn_config)
266
+ dead._last_activity = datetime.now() - timedelta(
267
+ seconds=conn_config.session_timeout + 100
268
+ )
269
+ manager._sessions["sid"] = dead
270
+
271
+ await manager._cleanup_timeout_sessions()
272
+ assert "sid" not in manager._sessions
273
+
171
274
  @pytest.mark.asyncio
172
275
  async def test_close_session(self):
173
276
  manager = SessionManager()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes