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.
- {ssh_licco-2.7.0/ssh_licco.egg-info → ssh_licco-2.7.2}/PKG-INFO +55 -3
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/README.md +54 -2
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/pyproject.toml +1 -1
- {ssh_licco-2.7.0 → ssh_licco-2.7.2/ssh_licco.egg-info}/PKG-INFO +55 -3
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/__init__.py +2 -2
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/utils.py +18 -3
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/server.py +1 -1
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/session_manager.py +32 -32
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_server.py +46 -1
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_session_manager.py +108 -5
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/LICENSE +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/setup.cfg +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/SOURCES.txt +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/dependency_links.txt +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/entry_points.txt +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/requires.txt +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_licco.egg-info/top_level.txt +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/__main__.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/audit_logger.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/batch_executor.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/cli.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/__init__.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/additional_clients.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/factory.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/interface.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/clients/paramiko_client.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/config_manager.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/connection_config.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/connection_pool.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/exceptions.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/executor.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/__init__.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/connect.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/context.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/docker.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/execute.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/file_transfer.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/host.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/key.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/process.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/schemas.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/handlers/session.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/key_manager.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/logging_config.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/security.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/service.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/tunnel.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/ssh_mcp/watchdog.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_config_manager.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_key_manager.py +0 -0
- {ssh_licco-2.7.0 → ssh_licco-2.7.2}/tests/test_security.py +0 -0
- {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.
|
|
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
|
-
- **[📦 发布指南](
|
|
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
|
-
| **测试用例** |
|
|
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
|
-
- **[📦 发布指南](
|
|
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
|
-
| **测试用例** |
|
|
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
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssh-licco
|
|
3
|
-
Version: 2.7.
|
|
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
|
-
- **[📦 发布指南](
|
|
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
|
-
| **测试用例** |
|
|
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
|
## 📊 版本历史
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
#
|
|
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}
|
|
835
|
-
|
|
836
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|