ssh-licco 2.7.1__tar.gz → 2.7.3__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.1/ssh_licco.egg-info → ssh_licco-2.7.3}/PKG-INFO +56 -3
  2. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/README.md +55 -2
  3. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/pyproject.toml +1 -1
  4. {ssh_licco-2.7.1 → ssh_licco-2.7.3/ssh_licco.egg-info}/PKG-INFO +56 -3
  5. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_licco.egg-info/SOURCES.txt +0 -1
  6. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/__init__.py +2 -2
  7. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/utils.py +18 -3
  8. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/server.py +45 -17
  9. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/session_manager.py +32 -32
  10. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/tests/test_server.py +46 -1
  11. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/tests/test_session_manager.py +108 -5
  12. ssh_licco-2.7.1/ssh_mcp/cli.py +0 -135
  13. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/LICENSE +0 -0
  14. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/setup.cfg +0 -0
  15. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_licco.egg-info/dependency_links.txt +0 -0
  16. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_licco.egg-info/entry_points.txt +0 -0
  17. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_licco.egg-info/requires.txt +0 -0
  18. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_licco.egg-info/top_level.txt +0 -0
  19. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/__main__.py +0 -0
  20. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/audit_logger.py +0 -0
  21. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/batch_executor.py +0 -0
  22. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/clients/__init__.py +0 -0
  23. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/clients/additional_clients.py +0 -0
  24. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/clients/factory.py +0 -0
  25. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/clients/interface.py +0 -0
  26. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/clients/paramiko_client.py +0 -0
  27. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/config_manager.py +0 -0
  28. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/connection_config.py +0 -0
  29. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/connection_pool.py +0 -0
  30. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/exceptions.py +0 -0
  31. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/executor.py +0 -0
  32. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/__init__.py +0 -0
  33. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/connect.py +0 -0
  34. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/context.py +0 -0
  35. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/docker.py +0 -0
  36. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/execute.py +0 -0
  37. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/file_transfer.py +0 -0
  38. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/host.py +0 -0
  39. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/key.py +0 -0
  40. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/process.py +0 -0
  41. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/schemas.py +0 -0
  42. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/handlers/session.py +0 -0
  43. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/key_manager.py +0 -0
  44. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/logging_config.py +0 -0
  45. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/security.py +0 -0
  46. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/service.py +0 -0
  47. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/tunnel.py +0 -0
  48. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/ssh_mcp/watchdog.py +0 -0
  49. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/tests/test_config_manager.py +0 -0
  50. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/tests/test_key_manager.py +0 -0
  51. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/tests/test_security.py +0 -0
  52. {ssh_licco-2.7.1 → ssh_licco-2.7.3}/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.1
3
+ Version: 2.7.3
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
@@ -47,6 +47,7 @@ Dynamic: license-file
47
47
  ## 📚 文档导航
48
48
 
49
49
  ### 快速开始
50
+ - **[🐣 新手指南](docs/BEGINNER_GUIDE.md)** - 零基础上手(推荐从这开始)
50
51
  - **[⬇️ 安装指南](#-快速安装)** - 2 种安装方式
51
52
  - **[🚀 快速开始](#-快速开始)** - 5 分钟上手
52
53
  - **[📋 配置模板](#-完整配置示例)** - 开箱即用的配置
@@ -63,7 +64,7 @@ Dynamic: license-file
63
64
 
64
65
  ### 开发资源
65
66
  - **[🎓 Skills 文档](docs/skills/)** - 开发、运维、安装指南
66
- - **[📦 发布指南](docs/skills/RELEASE_SKILL.md)** - 版本发布流程
67
+ - **[📦 发布指南](#-发布指南一体化命令)** - 一体化版本发布命令
67
68
  - **[🐛 GitHub Issues](https://github.com/Echoqili/ssh-licco/issues)** - 问题反馈
68
69
 
69
70
  ---
@@ -622,7 +623,7 @@ ssh-licco 的核心依赖是:
622
623
 
623
624
  | 指标 | 状态 |
624
625
  |------|------|
625
- | **测试用例** | 424 passed, 0 skipped |
626
+ | **测试用例** | 244 passed, 0 skipped |
626
627
  | **覆盖率** | 16 个源模块全覆盖 |
627
628
  | **测试框架** | pytest + pytest-asyncio |
628
629
 
@@ -663,4 +664,56 @@ pytest --cov=ssh_mcp --cov-report=term-missing
663
664
 
664
665
  ---
665
666
 
667
+ ## 📦 发布指南(一体化命令)
668
+
669
+ 项目提供 `sync_version.py` 作为**唯一**版本发布入口,一条命令完成所有版本源同步 + 文档更新 + 一致性自检 + git commit/tag/push,杜绝漏改 `VERSION` / `package.json` / `SKILL.md` 等文件。
670
+
671
+ ### 一键发布
672
+
673
+ ```bash
674
+ # 升 patch(z):2.7.1 → 2.7.2
675
+ python sync_version.py 2.7.2
676
+
677
+ # 升 minor(y):2.7.1 → 2.8.0
678
+ python sync_version.py 2.8.0
679
+
680
+ # 升 major(x):2.7.1 → 3.0.0
681
+ python sync_version.py 3.0.0
682
+ ```
683
+
684
+ 默认行为:改版本源 → 同步文档版本 → 一致性自检 → `commit` → `push` → 打 `tag` → `push tag`。
685
+
686
+ ### 常用选项
687
+
688
+ ```bash
689
+ # 预览会发生的变更,不写文件、不 commit
690
+ python sync_version.py 2.7.2 --dry-run
691
+
692
+ # 只改文件,不提交、不打 tag
693
+ python sync_version.py 2.7.2 --no-commit --no-tag
694
+
695
+ # 只做一致性自检(CI 中使用)
696
+ python sync_version.py --check
697
+ ```
698
+
699
+ ### 覆盖的版本源
700
+
701
+ | 文件 | 字段 | 说明 |
702
+ |------|------|------|
703
+ | `ssh_mcp/__init__.py` | `__version__` | **唯一真源**,其他文件都与其对齐 |
704
+ | `pyproject.toml` | `version` | Python 包构建版本 |
705
+ | `VERSION` | 纯文本 | `.github/workflows/pypi.yml` 实际读取的版本 |
706
+ | `package.json` | `version` | npm 包版本 |
707
+ | `package-lock.json` | `version` × 2 | npm lock 根版本 + `packages[""].version` |
708
+ | `.trae/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
709
+ | `docs/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
710
+
711
+ ### CI 预检
712
+
713
+ `.github/workflows/pypi.yml` 在构建前会执行 `python sync_version.py --check`,任何版本源不一致都会**直接中断发布流程**,防止打错版本号。
714
+
715
+ > 完整发布流程(含 PyPI 上传、MCP Registry 发布)见 [docs/skills/RELEASE_SKILL.md](docs/skills/RELEASE_SKILL.md)。
716
+
717
+ ---
718
+
666
719
  ## 📊 版本历史
@@ -14,6 +14,7 @@
14
14
  ## 📚 文档导航
15
15
 
16
16
  ### 快速开始
17
+ - **[🐣 新手指南](docs/BEGINNER_GUIDE.md)** - 零基础上手(推荐从这开始)
17
18
  - **[⬇️ 安装指南](#-快速安装)** - 2 种安装方式
18
19
  - **[🚀 快速开始](#-快速开始)** - 5 分钟上手
19
20
  - **[📋 配置模板](#-完整配置示例)** - 开箱即用的配置
@@ -30,7 +31,7 @@
30
31
 
31
32
  ### 开发资源
32
33
  - **[🎓 Skills 文档](docs/skills/)** - 开发、运维、安装指南
33
- - **[📦 发布指南](docs/skills/RELEASE_SKILL.md)** - 版本发布流程
34
+ - **[📦 发布指南](#-发布指南一体化命令)** - 一体化版本发布命令
34
35
  - **[🐛 GitHub Issues](https://github.com/Echoqili/ssh-licco/issues)** - 问题反馈
35
36
 
36
37
  ---
@@ -589,7 +590,7 @@ ssh-licco 的核心依赖是:
589
590
 
590
591
  | 指标 | 状态 |
591
592
  |------|------|
592
- | **测试用例** | 424 passed, 0 skipped |
593
+ | **测试用例** | 244 passed, 0 skipped |
593
594
  | **覆盖率** | 16 个源模块全覆盖 |
594
595
  | **测试框架** | pytest + pytest-asyncio |
595
596
 
@@ -630,4 +631,56 @@ pytest --cov=ssh_mcp --cov-report=term-missing
630
631
 
631
632
  ---
632
633
 
634
+ ## 📦 发布指南(一体化命令)
635
+
636
+ 项目提供 `sync_version.py` 作为**唯一**版本发布入口,一条命令完成所有版本源同步 + 文档更新 + 一致性自检 + git commit/tag/push,杜绝漏改 `VERSION` / `package.json` / `SKILL.md` 等文件。
637
+
638
+ ### 一键发布
639
+
640
+ ```bash
641
+ # 升 patch(z):2.7.1 → 2.7.2
642
+ python sync_version.py 2.7.2
643
+
644
+ # 升 minor(y):2.7.1 → 2.8.0
645
+ python sync_version.py 2.8.0
646
+
647
+ # 升 major(x):2.7.1 → 3.0.0
648
+ python sync_version.py 3.0.0
649
+ ```
650
+
651
+ 默认行为:改版本源 → 同步文档版本 → 一致性自检 → `commit` → `push` → 打 `tag` → `push tag`。
652
+
653
+ ### 常用选项
654
+
655
+ ```bash
656
+ # 预览会发生的变更,不写文件、不 commit
657
+ python sync_version.py 2.7.2 --dry-run
658
+
659
+ # 只改文件,不提交、不打 tag
660
+ python sync_version.py 2.7.2 --no-commit --no-tag
661
+
662
+ # 只做一致性自检(CI 中使用)
663
+ python sync_version.py --check
664
+ ```
665
+
666
+ ### 覆盖的版本源
667
+
668
+ | 文件 | 字段 | 说明 |
669
+ |------|------|------|
670
+ | `ssh_mcp/__init__.py` | `__version__` | **唯一真源**,其他文件都与其对齐 |
671
+ | `pyproject.toml` | `version` | Python 包构建版本 |
672
+ | `VERSION` | 纯文本 | `.github/workflows/pypi.yml` 实际读取的版本 |
673
+ | `package.json` | `version` | npm 包版本 |
674
+ | `package-lock.json` | `version` × 2 | npm lock 根版本 + `packages[""].version` |
675
+ | `.trae/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
676
+ | `docs/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
677
+
678
+ ### CI 预检
679
+
680
+ `.github/workflows/pypi.yml` 在构建前会执行 `python sync_version.py --check`,任何版本源不一致都会**直接中断发布流程**,防止打错版本号。
681
+
682
+ > 完整发布流程(含 PyPI 上传、MCP Registry 发布)见 [docs/skills/RELEASE_SKILL.md](docs/skills/RELEASE_SKILL.md)。
683
+
684
+ ---
685
+
633
686
  ## 📊 版本历史
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssh-licco"
3
- version = "2.7.1"
3
+ version = "2.7.3"
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.1
3
+ Version: 2.7.3
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
@@ -47,6 +47,7 @@ Dynamic: license-file
47
47
  ## 📚 文档导航
48
48
 
49
49
  ### 快速开始
50
+ - **[🐣 新手指南](docs/BEGINNER_GUIDE.md)** - 零基础上手(推荐从这开始)
50
51
  - **[⬇️ 安装指南](#-快速安装)** - 2 种安装方式
51
52
  - **[🚀 快速开始](#-快速开始)** - 5 分钟上手
52
53
  - **[📋 配置模板](#-完整配置示例)** - 开箱即用的配置
@@ -63,7 +64,7 @@ Dynamic: license-file
63
64
 
64
65
  ### 开发资源
65
66
  - **[🎓 Skills 文档](docs/skills/)** - 开发、运维、安装指南
66
- - **[📦 发布指南](docs/skills/RELEASE_SKILL.md)** - 版本发布流程
67
+ - **[📦 发布指南](#-发布指南一体化命令)** - 一体化版本发布命令
67
68
  - **[🐛 GitHub Issues](https://github.com/Echoqili/ssh-licco/issues)** - 问题反馈
68
69
 
69
70
  ---
@@ -622,7 +623,7 @@ ssh-licco 的核心依赖是:
622
623
 
623
624
  | 指标 | 状态 |
624
625
  |------|------|
625
- | **测试用例** | 424 passed, 0 skipped |
626
+ | **测试用例** | 244 passed, 0 skipped |
626
627
  | **覆盖率** | 16 个源模块全覆盖 |
627
628
  | **测试框架** | pytest + pytest-asyncio |
628
629
 
@@ -663,4 +664,56 @@ pytest --cov=ssh_mcp --cov-report=term-missing
663
664
 
664
665
  ---
665
666
 
667
+ ## 📦 发布指南(一体化命令)
668
+
669
+ 项目提供 `sync_version.py` 作为**唯一**版本发布入口,一条命令完成所有版本源同步 + 文档更新 + 一致性自检 + git commit/tag/push,杜绝漏改 `VERSION` / `package.json` / `SKILL.md` 等文件。
670
+
671
+ ### 一键发布
672
+
673
+ ```bash
674
+ # 升 patch(z):2.7.1 → 2.7.2
675
+ python sync_version.py 2.7.2
676
+
677
+ # 升 minor(y):2.7.1 → 2.8.0
678
+ python sync_version.py 2.8.0
679
+
680
+ # 升 major(x):2.7.1 → 3.0.0
681
+ python sync_version.py 3.0.0
682
+ ```
683
+
684
+ 默认行为:改版本源 → 同步文档版本 → 一致性自检 → `commit` → `push` → 打 `tag` → `push tag`。
685
+
686
+ ### 常用选项
687
+
688
+ ```bash
689
+ # 预览会发生的变更,不写文件、不 commit
690
+ python sync_version.py 2.7.2 --dry-run
691
+
692
+ # 只改文件,不提交、不打 tag
693
+ python sync_version.py 2.7.2 --no-commit --no-tag
694
+
695
+ # 只做一致性自检(CI 中使用)
696
+ python sync_version.py --check
697
+ ```
698
+
699
+ ### 覆盖的版本源
700
+
701
+ | 文件 | 字段 | 说明 |
702
+ |------|------|------|
703
+ | `ssh_mcp/__init__.py` | `__version__` | **唯一真源**,其他文件都与其对齐 |
704
+ | `pyproject.toml` | `version` | Python 包构建版本 |
705
+ | `VERSION` | 纯文本 | `.github/workflows/pypi.yml` 实际读取的版本 |
706
+ | `package.json` | `version` | npm 包版本 |
707
+ | `package-lock.json` | `version` × 2 | npm lock 根版本 + `packages[""].version` |
708
+ | `.trae/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
709
+ | `docs/skills/*/SKILL.md` | `Current Version` | 文档中的版本标注 |
710
+
711
+ ### CI 预检
712
+
713
+ `.github/workflows/pypi.yml` 在构建前会执行 `python sync_version.py --check`,任何版本源不一致都会**直接中断发布流程**,防止打错版本号。
714
+
715
+ > 完整发布流程(含 PyPI 上传、MCP Registry 发布)见 [docs/skills/RELEASE_SKILL.md](docs/skills/RELEASE_SKILL.md)。
716
+
717
+ ---
718
+
666
719
  ## 📊 版本历史
@@ -11,7 +11,6 @@ ssh_mcp/__init__.py
11
11
  ssh_mcp/__main__.py
12
12
  ssh_mcp/audit_logger.py
13
13
  ssh_mcp/batch_executor.py
14
- ssh_mcp/cli.py
15
14
  ssh_mcp/config_manager.py
16
15
  ssh_mcp/connection_config.py
17
16
  ssh_mcp/connection_pool.py
@@ -1,8 +1,8 @@
1
1
  # SSH-LICCO 版本信息
2
2
  # 所有代码中的版本信息都从这里读取
3
- # 版本号:2.7.1
3
+ # 版本号:2.7.3
4
4
 
5
- __version__ = "2.7.1"
5
+ __version__ = "2.7.3"
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:
@@ -11,7 +11,14 @@ from typing import Any
11
11
 
12
12
  from mcp.server import Server
13
13
  from mcp.server.stdio import stdio_server
14
- from mcp.types import TextContent, Tool
14
+ from mcp.types import (
15
+ CallToolRequestParams,
16
+ CallToolResult,
17
+ ListToolsResult,
18
+ PaginatedRequestParams,
19
+ TextContent,
20
+ Tool,
21
+ )
15
22
 
16
23
  from .audit_logger import get_audit_logger
17
24
  from .config_manager import ConfigManager, SSHConfig, SSHHost
@@ -141,7 +148,7 @@ class Tunnel:
141
148
 
142
149
  class SSHMCPServer:
143
150
  def __init__(self):
144
- self.server = Server("ssh-licco", __version__)
151
+ self.server = Server("ssh-licco", version=__version__)
145
152
  self.session_manager = SessionManager()
146
153
  self.key_manager = KeyManager()
147
154
  self.config_manager = ConfigManager()
@@ -212,24 +219,45 @@ class SSHMCPServer:
212
219
  return True, ""
213
220
 
214
221
  def _setup_handlers(self):
215
- @self.server.list_tools()
216
- async def list_tools() -> list[Tool]:
217
- return list(schemas.TOOLS.values())
222
+ self.server.add_request_handler(
223
+ "tools/list", PaginatedRequestParams, self._handle_list_tools
224
+ )
225
+ self.server.add_request_handler(
226
+ "tools/call", CallToolRequestParams, self._handle_call_tool
227
+ )
218
228
 
219
- @self.server.call_tool()
220
- async def call_tool(name: str, arguments: Any) -> list[TextContent]:
221
- allowed, msg = self._check_rate_limit()
222
- if not allowed:
223
- return [TextContent(type="text", text=msg)]
229
+ async def _handle_list_tools(
230
+ self, ctx: Any, params: PaginatedRequestParams
231
+ ) -> ListToolsResult:
232
+ return ListToolsResult(tools=list(schemas.TOOLS.values()))
233
+
234
+ async def _handle_call_tool(
235
+ self, ctx: Any, params: CallToolRequestParams
236
+ ) -> CallToolResult:
237
+ allowed, msg = self._check_rate_limit()
238
+ if not allowed:
239
+ return CallToolResult(
240
+ content=[TextContent(type="text", text=msg)], is_error=False
241
+ )
224
242
 
225
- handler = HANDLERS.get(name)
226
- if handler is None:
227
- return [TextContent(type="text", text=f"Unknown tool: {name}")]
243
+ handler = HANDLERS.get(params.name)
244
+ if handler is None:
245
+ return CallToolResult(
246
+ content=[
247
+ TextContent(type="text", text=f"Unknown tool: {params.name}")
248
+ ],
249
+ is_error=True,
250
+ )
228
251
 
229
- try:
230
- return await handler(self._ctx, arguments)
231
- except Exception as e:
232
- return [TextContent(type="text", text=f"Error: {str(e)}")]
252
+ try:
253
+ result = await handler(self._ctx, params.arguments or {})
254
+ content = result if isinstance(result, list) else [result]
255
+ return CallToolResult(content=content, is_error=False)
256
+ except Exception as e:
257
+ return CallToolResult(
258
+ content=[TextContent(type="text", text=f"Error: {str(e)}")],
259
+ is_error=True,
260
+ )
233
261
 
234
262
  async def _handle_connect(self, args: dict) -> list[TextContent]:
235
263
  """合并 ssh_config + ssh_login + ssh_connect"""
@@ -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()
@@ -1,135 +0,0 @@
1
- """SSH-LICCO command line interface."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import os
6
- import sys
7
- from pathlib import Path
8
-
9
- from ssh_mcp import __version__
10
-
11
-
12
- def _build_parser() -> argparse.ArgumentParser:
13
- """Build the CLI argument parser."""
14
- parser = argparse.ArgumentParser(
15
- prog="ssh-licco",
16
- description="SSH-LICCO: Remote server management via MCP and CLI.",
17
- )
18
- parser.add_argument(
19
- "--version",
20
- action="version",
21
- version=f"ssh-licco {__version__}",
22
- )
23
-
24
- subparsers = parser.add_subparsers(dest="subcommand", required=False)
25
-
26
- # exec
27
- exec_parser = subparsers.add_parser("exec", help="Execute a remote command")
28
- exec_parser.add_argument("cmd", help="Command to execute")
29
- exec_parser.add_argument("--host", default=None, help="Remote host")
30
- exec_parser.add_argument("-u", "--username", default=None, help="SSH username")
31
- exec_parser.add_argument("--password", default=None, help="SSH password")
32
- exec_parser.add_argument(
33
- "--connect-timeout", type=int, default=None, help="Connection timeout in seconds"
34
- )
35
-
36
- # upload
37
- upload_parser = subparsers.add_parser("upload", help="Upload a file")
38
- upload_parser.add_argument("local", help="Local file path")
39
- upload_parser.add_argument("remote", help="Remote file path")
40
- upload_parser.add_argument("--host", default=None, help="Remote host")
41
- upload_parser.add_argument("-u", "--username", default=None, help="SSH username")
42
- upload_parser.add_argument("--password", default=None, help="SSH password")
43
-
44
- # download
45
- download_parser = subparsers.add_parser("download", help="Download a file")
46
- download_parser.add_argument("remote", help="Remote file path")
47
- download_parser.add_argument("local", help="Local file path")
48
- download_parser.add_argument("--host", default=None, help="Remote host")
49
- download_parser.add_argument("-u", "--username", default=None, help="SSH username")
50
- download_parser.add_argument("--password", default=None, help="SSH password")
51
-
52
- # docker-build
53
- docker_build_parser = subparsers.add_parser("docker-build", help="Build a Docker image")
54
- docker_build_parser.add_argument("image", help="Image tag")
55
- docker_build_parser.add_argument("-c", "--context", default=".", help="Build context")
56
- docker_build_parser.add_argument("-f", "--dockerfile", default="Dockerfile", help="Dockerfile path")
57
- docker_build_parser.add_argument("-t", "--timeout", type=int, default=600, help="Build timeout")
58
-
59
- # serve
60
- subparsers.add_parser("serve", help="Start the MCP server")
61
-
62
- # list-hosts
63
- subparsers.add_parser("list-hosts", help="List configured hosts")
64
-
65
- return parser
66
-
67
-
68
- def _load_connection_config(args: argparse.Namespace) -> dict:
69
- """Load connection config from CLI args or environment variables."""
70
- host = args.host or os.getenv("SSH_HOST")
71
- username = args.username or os.getenv("SSH_USER")
72
- password = args.password or os.getenv("SSH_PASSWORD")
73
- port = args.port if getattr(args, "port", None) else 22
74
- timeout = args.connect_timeout if getattr(args, "connect_timeout", None) else 30
75
-
76
- return {
77
- "host": host,
78
- "port": port,
79
- "username": username,
80
- "password": password,
81
- "timeout": timeout,
82
- }
83
-
84
-
85
- def main(argv: list[str] | None = None) -> int:
86
- """CLI entry point."""
87
- parser = _build_parser()
88
- args = parser.parse_args(argv)
89
-
90
- if args.subcommand is None:
91
- parser.print_help()
92
- return 0
93
-
94
- if args.subcommand == "serve":
95
- from ssh_mcp.server import run_server
96
-
97
- run_server()
98
- return 0
99
-
100
- if args.subcommand == "list-hosts":
101
- from ssh_mcp.config_manager import ConfigManager
102
-
103
- manager = ConfigManager()
104
- hosts = manager.list_hosts()
105
- if not hosts:
106
- print("No hosts configured.")
107
- return 0
108
- for name, config in hosts.items():
109
- print(f"{name}: {config.host}:{config.port} ({config.username})")
110
- return 0
111
-
112
- if args.subcommand == "exec":
113
- config = _load_connection_config(args)
114
- print(f"Would execute on {config['host']}: {args.cmd}")
115
- return 0
116
-
117
- if args.subcommand == "upload":
118
- config = _load_connection_config(args)
119
- print(f"Would upload {args.local} -> {config['host']}:{args.remote}")
120
- return 0
121
-
122
- if args.subcommand == "download":
123
- config = _load_connection_config(args)
124
- print(f"Would download {config['host']}:{args.remote} -> {args.local}")
125
- return 0
126
-
127
- if args.subcommand == "docker-build":
128
- print(f"Would build Docker image {args.image} from {args.context}/{args.dockerfile}")
129
- return 0
130
-
131
- return 0
132
-
133
-
134
- if __name__ == "__main__":
135
- sys.exit(main())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes