mcp-config-dedupe 0.1.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.
- mcp_config_dedupe-0.1.0/LICENSE +21 -0
- mcp_config_dedupe-0.1.0/PKG-INFO +97 -0
- mcp_config_dedupe-0.1.0/README.md +73 -0
- mcp_config_dedupe-0.1.0/mcp_config_dedupe.egg-info/PKG-INFO +97 -0
- mcp_config_dedupe-0.1.0/mcp_config_dedupe.egg-info/SOURCES.txt +16 -0
- mcp_config_dedupe-0.1.0/mcp_config_dedupe.egg-info/dependency_links.txt +1 -0
- mcp_config_dedupe-0.1.0/mcp_config_dedupe.egg-info/entry_points.txt +2 -0
- mcp_config_dedupe-0.1.0/mcp_config_dedupe.egg-info/top_level.txt +1 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/__init__.py +4 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/__main__.py +5 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/cli.py +139 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/clients.py +176 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/findings.py +179 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/sync.py +151 -0
- mcp_config_dedupe-0.1.0/mcp_dedupe/toml_util.py +86 -0
- mcp_config_dedupe-0.1.0/pyproject.toml +36 -0
- mcp_config_dedupe-0.1.0/setup.cfg +4 -0
- mcp_config_dedupe-0.1.0/tests/test_dedupe.py +233 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-config-dedupe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect duplicate, drifted, and dead MCP server configs across clients — and dedupe them in one command
|
|
5
|
+
Author-email: hao li <haoli5933@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/mcp-config-dedupe
|
|
8
|
+
Project-URL: Issues, https://github.com/hahahahahahahahah6/mcp-config-dedupe/issues
|
|
9
|
+
Keywords: mcp,model-context-protocol,config,dedupe,claude,cursor,vscode,codex
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# mcp-config-dedupe
|
|
26
|
+
|
|
27
|
+
Detect duplicate, drifted, and dead MCP server configurations across your AI clients — then dedupe them in one command.
|
|
28
|
+
|
|
29
|
+
If you run Claude Desktop, Cursor, VS Code, and Codex side by side, you have probably pasted the same `mcpServers` block into four different config files. Over time they rot: the same server gets configured twice, one copy gets different args than the other (**drift**), and old entries point at commands you uninstalled months ago (**dead links**). `mcp-dedupe` scans every client, shows you the mess, and cleans it up — with backups and a dry-run first.
|
|
30
|
+
|
|
31
|
+
Stdlib-only. No dependencies, no network calls, no telemetry.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mcp-config-dedupe
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.11+.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Scan all supported clients (read-only) and print findings
|
|
45
|
+
mcp-dedupe scan
|
|
46
|
+
|
|
47
|
+
# Machine-readable report (exit code 1 when findings exist — CI friendly)
|
|
48
|
+
mcp-dedupe scan --format json
|
|
49
|
+
|
|
50
|
+
# Preview what dedupe would change (default; writes nothing)
|
|
51
|
+
mcp-dedupe dedupe
|
|
52
|
+
|
|
53
|
+
# Actually dedupe: backups are taken first, confirmation is explicit
|
|
54
|
+
mcp-dedupe dedupe --apply --yes
|
|
55
|
+
|
|
56
|
+
# Also resolve drifted duplicates, preferring Cursor's copy
|
|
57
|
+
mcp-dedupe dedupe --apply --yes --include-drift --prefer cursor
|
|
58
|
+
|
|
59
|
+
# Also remove servers whose command/cwd no longer exists
|
|
60
|
+
mcp-dedupe dedupe --apply --yes --prune-dead
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
What it detects:
|
|
64
|
+
|
|
65
|
+
| Finding | Meaning |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `DUPLICATE` | Same server name with an identical config in 2+ clients — safe to dedupe automatically |
|
|
68
|
+
| `DRIFT` | Same server name but different command/args/env across clients — reported, and only touched with `--include-drift` |
|
|
69
|
+
| `DEAD-LINK` | `command` not on `PATH` / path doesn't exist, or `cwd` is gone — only removed with `--prune-dead` |
|
|
70
|
+
| `REMOTE` | URL-based server; can't be verified offline, listed for visibility |
|
|
71
|
+
| `UNREADABLE` | Config file exists but can't be parsed |
|
|
72
|
+
|
|
73
|
+
Supported clients: **Claude Desktop**, **Cursor**, **VS Code** (`mcp.servers` settings), **Codex CLI** (`~/.codex/config.toml`).
|
|
74
|
+
|
|
75
|
+
Safety model: `scan` and the default `dedupe` (dry-run) never write. `--apply` refuses to run without `--yes`, backs up every touched file to `~/.mcp-dedupe/backups/<timestamp>/` first, and only rewrites configs that parsed cleanly. Drifted duplicates are never auto-merged — you pick the winner with `--prefer`.
|
|
76
|
+
|
|
77
|
+
## Differentiation
|
|
78
|
+
|
|
79
|
+
- **vs `mcp-audit-tool` (config security auditing):** auditing tells you a config looks risky; `mcp-dedupe` takes action — it removes the duplicate and dead entries across clients in one command.
|
|
80
|
+
- **vs editing JSON by hand:** one scan gives you a unified cross-client view with drift highlighted, instead of diffing four files in four different locations yourself.
|
|
81
|
+
- **vs `mcp-config-sync` (merge-everything sync):** syncing unions every server into every client; dedupe does the opposite job — it collapses redundant copies down to one canonical entry and prunes what's dead.
|
|
82
|
+
|
|
83
|
+
## Exit codes
|
|
84
|
+
|
|
85
|
+
- `0` — success (scan clean / apply completed)
|
|
86
|
+
- `1` — findings present (scan or dry-run)
|
|
87
|
+
- `2` — usage error, or `--apply` without `--yes`
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python3 -m unittest discover -s tests -v # 14 smoke tests, stdlib only
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# mcp-config-dedupe
|
|
2
|
+
|
|
3
|
+
Detect duplicate, drifted, and dead MCP server configurations across your AI clients — then dedupe them in one command.
|
|
4
|
+
|
|
5
|
+
If you run Claude Desktop, Cursor, VS Code, and Codex side by side, you have probably pasted the same `mcpServers` block into four different config files. Over time they rot: the same server gets configured twice, one copy gets different args than the other (**drift**), and old entries point at commands you uninstalled months ago (**dead links**). `mcp-dedupe` scans every client, shows you the mess, and cleans it up — with backups and a dry-run first.
|
|
6
|
+
|
|
7
|
+
Stdlib-only. No dependencies, no network calls, no telemetry.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install mcp-config-dedupe
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Python 3.11+.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Scan all supported clients (read-only) and print findings
|
|
21
|
+
mcp-dedupe scan
|
|
22
|
+
|
|
23
|
+
# Machine-readable report (exit code 1 when findings exist — CI friendly)
|
|
24
|
+
mcp-dedupe scan --format json
|
|
25
|
+
|
|
26
|
+
# Preview what dedupe would change (default; writes nothing)
|
|
27
|
+
mcp-dedupe dedupe
|
|
28
|
+
|
|
29
|
+
# Actually dedupe: backups are taken first, confirmation is explicit
|
|
30
|
+
mcp-dedupe dedupe --apply --yes
|
|
31
|
+
|
|
32
|
+
# Also resolve drifted duplicates, preferring Cursor's copy
|
|
33
|
+
mcp-dedupe dedupe --apply --yes --include-drift --prefer cursor
|
|
34
|
+
|
|
35
|
+
# Also remove servers whose command/cwd no longer exists
|
|
36
|
+
mcp-dedupe dedupe --apply --yes --prune-dead
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
What it detects:
|
|
40
|
+
|
|
41
|
+
| Finding | Meaning |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `DUPLICATE` | Same server name with an identical config in 2+ clients — safe to dedupe automatically |
|
|
44
|
+
| `DRIFT` | Same server name but different command/args/env across clients — reported, and only touched with `--include-drift` |
|
|
45
|
+
| `DEAD-LINK` | `command` not on `PATH` / path doesn't exist, or `cwd` is gone — only removed with `--prune-dead` |
|
|
46
|
+
| `REMOTE` | URL-based server; can't be verified offline, listed for visibility |
|
|
47
|
+
| `UNREADABLE` | Config file exists but can't be parsed |
|
|
48
|
+
|
|
49
|
+
Supported clients: **Claude Desktop**, **Cursor**, **VS Code** (`mcp.servers` settings), **Codex CLI** (`~/.codex/config.toml`).
|
|
50
|
+
|
|
51
|
+
Safety model: `scan` and the default `dedupe` (dry-run) never write. `--apply` refuses to run without `--yes`, backs up every touched file to `~/.mcp-dedupe/backups/<timestamp>/` first, and only rewrites configs that parsed cleanly. Drifted duplicates are never auto-merged — you pick the winner with `--prefer`.
|
|
52
|
+
|
|
53
|
+
## Differentiation
|
|
54
|
+
|
|
55
|
+
- **vs `mcp-audit-tool` (config security auditing):** auditing tells you a config looks risky; `mcp-dedupe` takes action — it removes the duplicate and dead entries across clients in one command.
|
|
56
|
+
- **vs editing JSON by hand:** one scan gives you a unified cross-client view with drift highlighted, instead of diffing four files in four different locations yourself.
|
|
57
|
+
- **vs `mcp-config-sync` (merge-everything sync):** syncing unions every server into every client; dedupe does the opposite job — it collapses redundant copies down to one canonical entry and prunes what's dead.
|
|
58
|
+
|
|
59
|
+
## Exit codes
|
|
60
|
+
|
|
61
|
+
- `0` — success (scan clean / apply completed)
|
|
62
|
+
- `1` — findings present (scan or dry-run)
|
|
63
|
+
- `2` — usage error, or `--apply` without `--yes`
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python3 -m unittest discover -s tests -v # 14 smoke tests, stdlib only
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-config-dedupe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect duplicate, drifted, and dead MCP server configs across clients — and dedupe them in one command
|
|
5
|
+
Author-email: hao li <haoli5933@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/mcp-config-dedupe
|
|
8
|
+
Project-URL: Issues, https://github.com/hahahahahahahahah6/mcp-config-dedupe/issues
|
|
9
|
+
Keywords: mcp,model-context-protocol,config,dedupe,claude,cursor,vscode,codex
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# mcp-config-dedupe
|
|
26
|
+
|
|
27
|
+
Detect duplicate, drifted, and dead MCP server configurations across your AI clients — then dedupe them in one command.
|
|
28
|
+
|
|
29
|
+
If you run Claude Desktop, Cursor, VS Code, and Codex side by side, you have probably pasted the same `mcpServers` block into four different config files. Over time they rot: the same server gets configured twice, one copy gets different args than the other (**drift**), and old entries point at commands you uninstalled months ago (**dead links**). `mcp-dedupe` scans every client, shows you the mess, and cleans it up — with backups and a dry-run first.
|
|
30
|
+
|
|
31
|
+
Stdlib-only. No dependencies, no network calls, no telemetry.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mcp-config-dedupe
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.11+.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Scan all supported clients (read-only) and print findings
|
|
45
|
+
mcp-dedupe scan
|
|
46
|
+
|
|
47
|
+
# Machine-readable report (exit code 1 when findings exist — CI friendly)
|
|
48
|
+
mcp-dedupe scan --format json
|
|
49
|
+
|
|
50
|
+
# Preview what dedupe would change (default; writes nothing)
|
|
51
|
+
mcp-dedupe dedupe
|
|
52
|
+
|
|
53
|
+
# Actually dedupe: backups are taken first, confirmation is explicit
|
|
54
|
+
mcp-dedupe dedupe --apply --yes
|
|
55
|
+
|
|
56
|
+
# Also resolve drifted duplicates, preferring Cursor's copy
|
|
57
|
+
mcp-dedupe dedupe --apply --yes --include-drift --prefer cursor
|
|
58
|
+
|
|
59
|
+
# Also remove servers whose command/cwd no longer exists
|
|
60
|
+
mcp-dedupe dedupe --apply --yes --prune-dead
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
What it detects:
|
|
64
|
+
|
|
65
|
+
| Finding | Meaning |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `DUPLICATE` | Same server name with an identical config in 2+ clients — safe to dedupe automatically |
|
|
68
|
+
| `DRIFT` | Same server name but different command/args/env across clients — reported, and only touched with `--include-drift` |
|
|
69
|
+
| `DEAD-LINK` | `command` not on `PATH` / path doesn't exist, or `cwd` is gone — only removed with `--prune-dead` |
|
|
70
|
+
| `REMOTE` | URL-based server; can't be verified offline, listed for visibility |
|
|
71
|
+
| `UNREADABLE` | Config file exists but can't be parsed |
|
|
72
|
+
|
|
73
|
+
Supported clients: **Claude Desktop**, **Cursor**, **VS Code** (`mcp.servers` settings), **Codex CLI** (`~/.codex/config.toml`).
|
|
74
|
+
|
|
75
|
+
Safety model: `scan` and the default `dedupe` (dry-run) never write. `--apply` refuses to run without `--yes`, backs up every touched file to `~/.mcp-dedupe/backups/<timestamp>/` first, and only rewrites configs that parsed cleanly. Drifted duplicates are never auto-merged — you pick the winner with `--prefer`.
|
|
76
|
+
|
|
77
|
+
## Differentiation
|
|
78
|
+
|
|
79
|
+
- **vs `mcp-audit-tool` (config security auditing):** auditing tells you a config looks risky; `mcp-dedupe` takes action — it removes the duplicate and dead entries across clients in one command.
|
|
80
|
+
- **vs editing JSON by hand:** one scan gives you a unified cross-client view with drift highlighted, instead of diffing four files in four different locations yourself.
|
|
81
|
+
- **vs `mcp-config-sync` (merge-everything sync):** syncing unions every server into every client; dedupe does the opposite job — it collapses redundant copies down to one canonical entry and prunes what's dead.
|
|
82
|
+
|
|
83
|
+
## Exit codes
|
|
84
|
+
|
|
85
|
+
- `0` — success (scan clean / apply completed)
|
|
86
|
+
- `1` — findings present (scan or dry-run)
|
|
87
|
+
- `2` — usage error, or `--apply` without `--yes`
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python3 -m unittest discover -s tests -v # 14 smoke tests, stdlib only
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mcp_config_dedupe.egg-info/PKG-INFO
|
|
5
|
+
mcp_config_dedupe.egg-info/SOURCES.txt
|
|
6
|
+
mcp_config_dedupe.egg-info/dependency_links.txt
|
|
7
|
+
mcp_config_dedupe.egg-info/entry_points.txt
|
|
8
|
+
mcp_config_dedupe.egg-info/top_level.txt
|
|
9
|
+
mcp_dedupe/__init__.py
|
|
10
|
+
mcp_dedupe/__main__.py
|
|
11
|
+
mcp_dedupe/cli.py
|
|
12
|
+
mcp_dedupe/clients.py
|
|
13
|
+
mcp_dedupe/findings.py
|
|
14
|
+
mcp_dedupe/sync.py
|
|
15
|
+
mcp_dedupe/toml_util.py
|
|
16
|
+
tests/test_dedupe.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_dedupe
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""CLI for mcp-dedupe: scan and dedupe MCP client configurations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .clients import BY_ID
|
|
12
|
+
from .findings import actionable, scan_all
|
|
13
|
+
from .sync import apply_plan, build_plan, describe_plan
|
|
14
|
+
|
|
15
|
+
EXIT_OK = 0
|
|
16
|
+
EXIT_FINDINGS = 1
|
|
17
|
+
EXIT_ERROR = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _home_arg(args) -> Path:
|
|
21
|
+
return Path(args.home).expanduser() if args.home else Path.home()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_scan(args) -> int:
|
|
25
|
+
result = scan_all(home=_home_arg(args))
|
|
26
|
+
if args.format == "json":
|
|
27
|
+
print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False))
|
|
28
|
+
else:
|
|
29
|
+
present = [s for s in result.scans if s.path and s.path.is_file()]
|
|
30
|
+
print("MCP config scan")
|
|
31
|
+
print("=" * 60)
|
|
32
|
+
for s in result.scans:
|
|
33
|
+
status = (
|
|
34
|
+
f"{len(s.servers)} server(s)" if s.path and s.path.is_file()
|
|
35
|
+
else "no config file"
|
|
36
|
+
)
|
|
37
|
+
extra = f" [ERROR: {s.error}]" if s.error else ""
|
|
38
|
+
print(f"- {s.client.id:15} {status}{extra}")
|
|
39
|
+
print("-" * 60)
|
|
40
|
+
if not result.findings:
|
|
41
|
+
print("Clean: no duplicates, drift, or dead links found.")
|
|
42
|
+
for f in result.findings:
|
|
43
|
+
who = f", ".join(f.clients) if f.clients else "-"
|
|
44
|
+
label = {"duplicate": "DUPLICATE", "drift": "DRIFT",
|
|
45
|
+
"dead-link": "DEAD-LINK", "remote": "REMOTE",
|
|
46
|
+
"unreadable": "UNREADABLE"}.get(f.kind, f.kind.upper())
|
|
47
|
+
print(f"[{label}] {f.server or '(config)'} ({who}): {f.detail}")
|
|
48
|
+
return EXIT_FINDINGS if actionable(result.findings) else EXIT_OK
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_dedupe(args) -> int:
|
|
52
|
+
if args.prefer and args.prefer not in BY_ID:
|
|
53
|
+
print(f"error: unknown client '{args.prefer}'. Known: {', '.join(sorted(BY_ID))}",
|
|
54
|
+
file=sys.stderr)
|
|
55
|
+
return EXIT_ERROR
|
|
56
|
+
home = _home_arg(args)
|
|
57
|
+
result = scan_all(home=home)
|
|
58
|
+
plan = build_plan(result, include_drift=args.include_drift,
|
|
59
|
+
prune_dead=args.prune_dead, prefer=args.prefer)
|
|
60
|
+
|
|
61
|
+
dry = not args.apply
|
|
62
|
+
print("Dedupe plan (dry-run)" if dry else "Dedupe plan")
|
|
63
|
+
print("=" * 60)
|
|
64
|
+
for line in describe_plan(plan):
|
|
65
|
+
print(line)
|
|
66
|
+
|
|
67
|
+
if not plan.actions:
|
|
68
|
+
return EXIT_FINDINGS if actionable(result.findings) else EXIT_OK
|
|
69
|
+
|
|
70
|
+
if dry:
|
|
71
|
+
print("-" * 60)
|
|
72
|
+
print("Dry run: no files were changed. Re-run with --apply --yes to apply.")
|
|
73
|
+
return EXIT_FINDINGS
|
|
74
|
+
|
|
75
|
+
if not args.yes:
|
|
76
|
+
print("Refusing to apply: pass --yes to confirm you want these files rewritten.",
|
|
77
|
+
file=sys.stderr)
|
|
78
|
+
return EXIT_ERROR
|
|
79
|
+
|
|
80
|
+
backup_dir = Path(args.backup_dir).expanduser() if args.backup_dir else \
|
|
81
|
+
Path.home() / ".mcp-dedupe" / "backups"
|
|
82
|
+
removed = apply_plan(plan, result, backup_dir)
|
|
83
|
+
print("-" * 60)
|
|
84
|
+
print(f"Applied. Backups saved under {backup_dir}.")
|
|
85
|
+
for cid in sorted(removed):
|
|
86
|
+
print(f" {cid}: removed {', '.join(sorted(removed[cid]))}")
|
|
87
|
+
return EXIT_OK
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
91
|
+
p = argparse.ArgumentParser(
|
|
92
|
+
prog="mcp-dedupe",
|
|
93
|
+
description="Detect duplicate / drifted / dead MCP server configs across "
|
|
94
|
+
"Claude Desktop, Cursor, VS Code and Codex, and dedupe them.")
|
|
95
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
96
|
+
p.add_argument("--home", default=None,
|
|
97
|
+
help="Override the home directory configs are resolved from "
|
|
98
|
+
"(useful for testing).")
|
|
99
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
100
|
+
|
|
101
|
+
s = sub.add_parser("scan", help="Scan client configs and report findings (read-only).")
|
|
102
|
+
s.add_argument("--format", choices=["text", "json"], default="text")
|
|
103
|
+
s.add_argument("--home", default=None,
|
|
104
|
+
help="Override the home directory configs are resolved from.")
|
|
105
|
+
s.set_defaults(func=cmd_scan)
|
|
106
|
+
|
|
107
|
+
d = sub.add_parser("dedupe",
|
|
108
|
+
help="Plan or apply dedupe of duplicated / dead servers.")
|
|
109
|
+
d.add_argument("--dry-run", action="store_true",
|
|
110
|
+
help="Show the plan without changing files (default).")
|
|
111
|
+
d.add_argument("--apply", action="store_true",
|
|
112
|
+
help="Rewrite config files. Requires --yes.")
|
|
113
|
+
d.add_argument("--yes", action="store_true",
|
|
114
|
+
help="Explicit confirmation for --apply.")
|
|
115
|
+
d.add_argument("--prefer", default=None, metavar="CLIENT",
|
|
116
|
+
help="When resolving duplicates/drift, keep this client's copy "
|
|
117
|
+
f"({', '.join(sorted(BY_ID))}).")
|
|
118
|
+
d.add_argument("--include-drift", action="store_true",
|
|
119
|
+
help="Also resolve drifted duplicates (same name, different config). "
|
|
120
|
+
"Without this flag drift is reported but left untouched.")
|
|
121
|
+
d.add_argument("--prune-dead", action="store_true",
|
|
122
|
+
help="Also remove servers whose command/cwd is a dead link.")
|
|
123
|
+
d.add_argument("--backup-dir", default=None,
|
|
124
|
+
help="Where to store backups (default ~/.mcp-dedupe/backups).")
|
|
125
|
+
d.add_argument("--home", default=None,
|
|
126
|
+
help="Override the home directory configs are resolved from.")
|
|
127
|
+
d.set_defaults(func=cmd_dedupe)
|
|
128
|
+
return p
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv=None) -> int:
|
|
132
|
+
args = build_parser().parse_args(argv)
|
|
133
|
+
try:
|
|
134
|
+
return args.func(args)
|
|
135
|
+
except BrokenPipeError:
|
|
136
|
+
return EXIT_OK
|
|
137
|
+
except Exception as exc: # never crash with a traceback on bad input
|
|
138
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
139
|
+
return EXIT_ERROR
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Client adapters: where each MCP client keeps its server configuration.
|
|
2
|
+
|
|
3
|
+
Every adapter knows how to locate its config file(s) relative to a home
|
|
4
|
+
directory, and how to extract / replace the ``{name: server-config}`` mapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
Servers = Dict[str, Dict[str, Any]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _json_servers(doc: Any, *path: str) -> Servers:
|
|
19
|
+
"""Walk a parsed JSON document to the servers mapping."""
|
|
20
|
+
node = doc
|
|
21
|
+
for key in path:
|
|
22
|
+
if not isinstance(node, dict) or key not in node:
|
|
23
|
+
return {}
|
|
24
|
+
node = node[key]
|
|
25
|
+
if not isinstance(node, dict):
|
|
26
|
+
return {}
|
|
27
|
+
return {k: v for k, v in node.items() if isinstance(v, dict)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_json(text: str) -> Servers:
|
|
31
|
+
return _json_servers(json.loads(text), "mcpServers")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_json(servers: Servers, original_text: Optional[str]) -> str:
|
|
35
|
+
"""Replace the mcpServers block, preserving sibling keys."""
|
|
36
|
+
try:
|
|
37
|
+
doc = json.loads(original_text) if original_text else {}
|
|
38
|
+
except (json.JSONDecodeError, TypeError):
|
|
39
|
+
doc = {}
|
|
40
|
+
if not isinstance(doc, dict):
|
|
41
|
+
doc = {}
|
|
42
|
+
doc["mcpServers"] = servers
|
|
43
|
+
return json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_vscode(text: str) -> Servers:
|
|
47
|
+
doc = json.loads(text)
|
|
48
|
+
# Official VS Code MCP layout: "mcp": {"servers": {...}}.
|
|
49
|
+
servers = _json_servers(doc, "mcp", "servers")
|
|
50
|
+
if servers:
|
|
51
|
+
return servers
|
|
52
|
+
# Legacy/alternate layout with a dotted key.
|
|
53
|
+
return _json_servers(doc, "mcp.servers")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _write_vscode(servers: Servers, original_text: Optional[str]) -> str:
|
|
57
|
+
try:
|
|
58
|
+
doc = json.loads(original_text) if original_text else {}
|
|
59
|
+
except (json.JSONDecodeError, TypeError):
|
|
60
|
+
doc = {}
|
|
61
|
+
if not isinstance(doc, dict):
|
|
62
|
+
doc = {}
|
|
63
|
+
if isinstance(doc.get("mcp"), dict):
|
|
64
|
+
doc["mcp"]["servers"] = servers
|
|
65
|
+
elif "mcp.servers" in doc:
|
|
66
|
+
doc["mcp.servers"] = servers
|
|
67
|
+
else:
|
|
68
|
+
doc.setdefault("mcp", {})["servers"] = servers
|
|
69
|
+
return json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_codex(text: str) -> Servers:
|
|
73
|
+
doc = tomllib.loads(text)
|
|
74
|
+
node = doc.get("mcp_servers", {})
|
|
75
|
+
if not isinstance(node, dict):
|
|
76
|
+
return {}
|
|
77
|
+
return {k: v for k, v in node.items() if isinstance(v, dict)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _write_codex(servers: Servers, original_text: Optional[str]) -> str:
|
|
81
|
+
from . import toml_util
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
doc = tomllib.loads(original_text) if original_text else {}
|
|
85
|
+
except tomllib.TOMLDecodeError:
|
|
86
|
+
doc = {}
|
|
87
|
+
if not isinstance(doc, dict):
|
|
88
|
+
doc = {}
|
|
89
|
+
doc["mcp_servers"] = servers
|
|
90
|
+
return toml_util.dumps(doc)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Client:
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
client_id: str,
|
|
97
|
+
display: str,
|
|
98
|
+
relpaths: Dict[str, List[str]],
|
|
99
|
+
read_servers: Callable[[str], Servers],
|
|
100
|
+
write_servers: Callable[[Servers, Optional[str]], str],
|
|
101
|
+
fmt: str,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.id = client_id
|
|
104
|
+
self.display = display
|
|
105
|
+
self.relpaths = relpaths
|
|
106
|
+
self.read_servers = read_servers
|
|
107
|
+
self.write_servers = write_servers
|
|
108
|
+
self.fmt = fmt
|
|
109
|
+
|
|
110
|
+
def config_path(self, home: Path, platform: Optional[str] = None) -> Optional[Path]:
|
|
111
|
+
plat = platform or sys.platform
|
|
112
|
+
for rel in self.relpaths.get(plat, []):
|
|
113
|
+
p = home / rel
|
|
114
|
+
if p.is_file():
|
|
115
|
+
return p
|
|
116
|
+
# Fall back to the first candidate so callers can report "missing".
|
|
117
|
+
cands = self.relpaths.get(plat, [])
|
|
118
|
+
return home / cands[0] if cands else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
CLIENTS: List[Client] = [
|
|
122
|
+
Client(
|
|
123
|
+
"claude-desktop",
|
|
124
|
+
"Claude Desktop",
|
|
125
|
+
{
|
|
126
|
+
"darwin": ["Library/Application Support/Claude/claude_desktop_config.json"],
|
|
127
|
+
"win32": ["AppData/Roaming/Claude/claude_desktop_config.json"],
|
|
128
|
+
"linux": [".config/Claude/claude_desktop_config.json"],
|
|
129
|
+
},
|
|
130
|
+
_read_json,
|
|
131
|
+
_write_json,
|
|
132
|
+
"json",
|
|
133
|
+
),
|
|
134
|
+
Client(
|
|
135
|
+
"cursor",
|
|
136
|
+
"Cursor",
|
|
137
|
+
{
|
|
138
|
+
"darwin": [".cursor/mcp.json"],
|
|
139
|
+
"win32": [".cursor/mcp.json"],
|
|
140
|
+
"linux": [".cursor/mcp.json"],
|
|
141
|
+
},
|
|
142
|
+
_read_json,
|
|
143
|
+
_write_json,
|
|
144
|
+
"json",
|
|
145
|
+
),
|
|
146
|
+
Client(
|
|
147
|
+
"vscode",
|
|
148
|
+
"VS Code",
|
|
149
|
+
{
|
|
150
|
+
"darwin": ["Library/Application Support/Code/User/settings.json"],
|
|
151
|
+
"win32": ["AppData/Roaming/Code/User/settings.json"],
|
|
152
|
+
"linux": [".config/Code/User/settings.json"],
|
|
153
|
+
},
|
|
154
|
+
_read_vscode,
|
|
155
|
+
_write_vscode,
|
|
156
|
+
"json",
|
|
157
|
+
),
|
|
158
|
+
Client(
|
|
159
|
+
"codex",
|
|
160
|
+
"Codex CLI",
|
|
161
|
+
{
|
|
162
|
+
"darwin": [".codex/config.toml"],
|
|
163
|
+
"win32": [".codex/config.toml"],
|
|
164
|
+
"linux": [".codex/config.toml"],
|
|
165
|
+
},
|
|
166
|
+
_read_codex,
|
|
167
|
+
_write_codex,
|
|
168
|
+
"toml",
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
BY_ID = {c.id: c for c in CLIENTS}
|
|
173
|
+
|
|
174
|
+
# Priority order: the first client in this list that holds a duplicated server
|
|
175
|
+
# keeps the canonical copy unless --prefer overrides it.
|
|
176
|
+
PRIORITY = ["claude-desktop", "cursor", "vscode", "codex"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Finding detection: duplicates, drift, dead links, and unreadable configs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .clients import BY_ID, CLIENTS, Client, Servers
|
|
14
|
+
|
|
15
|
+
SEVERITIES = {"duplicate", "drift", "dead-link", "remote", "unreadable"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Finding:
|
|
20
|
+
kind: str # duplicate | drift | dead-link | remote | unreadable
|
|
21
|
+
server: Optional[str]
|
|
22
|
+
clients: List[str] = field(default_factory=list)
|
|
23
|
+
detail: str = ""
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"kind": self.kind,
|
|
28
|
+
"server": self.server,
|
|
29
|
+
"clients": self.clients,
|
|
30
|
+
"detail": self.detail,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ClientScan:
|
|
36
|
+
client: Client
|
|
37
|
+
path: Optional[Path]
|
|
38
|
+
servers: Servers
|
|
39
|
+
error: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def canonical(server: Dict[str, Any]) -> str:
|
|
43
|
+
"""Stable fingerprint of a server config for equality comparison."""
|
|
44
|
+
return json.dumps(server, sort_keys=True, ensure_ascii=False, default=str)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_client(client: Client, home: Path, platform: Optional[str] = None) -> ClientScan:
|
|
48
|
+
path = client.config_path(home, platform)
|
|
49
|
+
if path is None or not path.is_file():
|
|
50
|
+
return ClientScan(client, path, {})
|
|
51
|
+
try:
|
|
52
|
+
text = path.read_text(encoding="utf-8")
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
return ClientScan(client, path, {}, error=f"cannot read: {exc}")
|
|
55
|
+
try:
|
|
56
|
+
servers = client.read_servers(text)
|
|
57
|
+
except Exception as exc: # malformed JSON/TOML
|
|
58
|
+
return ClientScan(client, path, {}, error=f"cannot parse ({client.fmt}): {exc}")
|
|
59
|
+
return ClientScan(client, path, servers)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _command_dead(server: Dict[str, Any]) -> Optional[str]:
|
|
63
|
+
cmd = server.get("command")
|
|
64
|
+
if not cmd or not isinstance(cmd, str):
|
|
65
|
+
return None
|
|
66
|
+
if os.path.isabs(cmd) or "/" in cmd or "\\" in cmd:
|
|
67
|
+
p = Path(cmd)
|
|
68
|
+
if not p.exists():
|
|
69
|
+
return f"command path does not exist: {cmd}"
|
|
70
|
+
if p.is_dir():
|
|
71
|
+
return f"command path is a directory: {cmd}"
|
|
72
|
+
if not os.access(p, os.X_OK):
|
|
73
|
+
return f"command path is not executable: {cmd}"
|
|
74
|
+
return None
|
|
75
|
+
if shutil.which(cmd) is None:
|
|
76
|
+
return f"command not found on PATH: {cmd}"
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_server_health(name: str, server: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
81
|
+
"""Return (kind, detail) health findings for one server config."""
|
|
82
|
+
out: List[Tuple[str, str]] = []
|
|
83
|
+
if server.get("url"):
|
|
84
|
+
out.append(("remote", f"remote server ({server.get('url')}); not checked offline"))
|
|
85
|
+
return out
|
|
86
|
+
dead = _command_dead(server)
|
|
87
|
+
if dead:
|
|
88
|
+
out.append(("dead-link", dead))
|
|
89
|
+
cwd = server.get("cwd")
|
|
90
|
+
if isinstance(cwd, str) and cwd and not Path(cwd).expanduser().is_dir():
|
|
91
|
+
out.append(("dead-link", f"cwd does not exist: {cwd}"))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ScanResult:
|
|
97
|
+
scans: List[ClientScan]
|
|
98
|
+
findings: List[Finding]
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"clients": [
|
|
103
|
+
{
|
|
104
|
+
"id": s.client.id,
|
|
105
|
+
"display": s.client.display,
|
|
106
|
+
"path": str(s.path) if s.path else None,
|
|
107
|
+
"servers": sorted(s.servers),
|
|
108
|
+
"error": s.error,
|
|
109
|
+
}
|
|
110
|
+
for s in self.scans
|
|
111
|
+
],
|
|
112
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scan_all(home: Optional[Path] = None, platform: Optional[str] = None) -> ScanResult:
|
|
117
|
+
home = home or Path.home()
|
|
118
|
+
scans = [scan_client(c, home, platform) for c in CLIENTS]
|
|
119
|
+
findings: List[Finding] = []
|
|
120
|
+
|
|
121
|
+
for s in scans:
|
|
122
|
+
if s.error:
|
|
123
|
+
findings.append(
|
|
124
|
+
Finding("unreadable", None, [s.client.id], f"{s.path}: {s.error}")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Group servers by name across clients.
|
|
128
|
+
by_name: Dict[str, List[Tuple[ClientScan, Dict[str, Any]]]] = {}
|
|
129
|
+
for s in scans:
|
|
130
|
+
for name, cfg in s.servers.items():
|
|
131
|
+
by_name.setdefault(name, []).append((s, cfg))
|
|
132
|
+
|
|
133
|
+
for name in sorted(by_name):
|
|
134
|
+
holders = by_name[name]
|
|
135
|
+
if len(holders) > 1:
|
|
136
|
+
fingerprints = {canonical(cfg) for _, cfg in holders}
|
|
137
|
+
client_ids = [s.client.id for s, _ in holders]
|
|
138
|
+
if len(fingerprints) == 1:
|
|
139
|
+
findings.append(
|
|
140
|
+
Finding(
|
|
141
|
+
"duplicate",
|
|
142
|
+
name,
|
|
143
|
+
client_ids,
|
|
144
|
+
"identical config in multiple clients; safe to dedupe",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
findings.append(
|
|
149
|
+
Finding(
|
|
150
|
+
"drift",
|
|
151
|
+
name,
|
|
152
|
+
client_ids,
|
|
153
|
+
"same name but different config across clients",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Health checks per (client, server), grouped per server for readable output.
|
|
158
|
+
health_by_server: Dict[str, List[Tuple[str, str, str]]] = {}
|
|
159
|
+
for s in scans:
|
|
160
|
+
for name, cfg in s.servers.items():
|
|
161
|
+
for kind, detail in _check_server_health(name, cfg):
|
|
162
|
+
bucket = health_by_server.setdefault(name, [])
|
|
163
|
+
entry = (kind, detail, s.client.id)
|
|
164
|
+
if entry not in bucket:
|
|
165
|
+
bucket.append(entry)
|
|
166
|
+
for name in sorted(health_by_server):
|
|
167
|
+
for kind, detail, client_id in health_by_server[name]:
|
|
168
|
+
# One finding per (server, kind, detail), listing every client.
|
|
169
|
+
clients = [c for k, d, c in health_by_server[name] if k == kind and d == detail]
|
|
170
|
+
findings.append(Finding(kind, name, clients, detail))
|
|
171
|
+
|
|
172
|
+
# Stable ordering: duplicates, drift, dead-link, remote, unreadable.
|
|
173
|
+
order = {"duplicate": 0, "drift": 1, "dead-link": 2, "remote": 3, "unreadable": 4}
|
|
174
|
+
findings.sort(key=lambda f: (order.get(f.kind, 9), f.server or "", f.detail))
|
|
175
|
+
return ScanResult(scans, findings)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def actionable(findings: List[Finding]) -> List[Finding]:
|
|
179
|
+
return [f for f in findings if f.kind in ("duplicate", "drift", "dead-link", "unreadable")]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Dedupe planning and applying: compute what changes, back up, then write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from . import clients as clients_mod
|
|
12
|
+
from .clients import Client, Servers
|
|
13
|
+
from .findings import Finding, ScanResult, canonical
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Action:
|
|
18
|
+
client: Client
|
|
19
|
+
path: Path
|
|
20
|
+
server: str
|
|
21
|
+
action: str # "remove"
|
|
22
|
+
reason: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Plan:
|
|
27
|
+
actions: List[Action] = field(default_factory=list)
|
|
28
|
+
skipped: List[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def removals_by_client(self) -> Dict[str, List[str]]:
|
|
31
|
+
out: Dict[str, List[str]] = {}
|
|
32
|
+
for a in self.actions:
|
|
33
|
+
out.setdefault(a.client.id, []).append(a.server)
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _keeper_first(client_ids: List[str], prefer: Optional[str]) -> List[str]:
|
|
38
|
+
ordered = list(client_ids)
|
|
39
|
+
if prefer and prefer in ordered:
|
|
40
|
+
ordered.remove(prefer)
|
|
41
|
+
ordered.insert(0, prefer)
|
|
42
|
+
return ordered
|
|
43
|
+
prio = clients_mod.PRIORITY
|
|
44
|
+
return sorted(ordered, key=lambda c: prio.index(c) if c in prio else 99)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_plan(
|
|
48
|
+
result: ScanResult,
|
|
49
|
+
include_drift: bool = False,
|
|
50
|
+
prune_dead: bool = False,
|
|
51
|
+
prefer: Optional[str] = None,
|
|
52
|
+
) -> Plan:
|
|
53
|
+
plan = Plan()
|
|
54
|
+
|
|
55
|
+
# Map (client_id, server) -> (scan, config) for quick lookup.
|
|
56
|
+
configs: Dict[tuple, Dict] = {}
|
|
57
|
+
for s in result.scans:
|
|
58
|
+
for name, cfg in s.servers.items():
|
|
59
|
+
configs[(s.client.id, name)] = cfg
|
|
60
|
+
|
|
61
|
+
for f in result.findings:
|
|
62
|
+
if f.kind == "duplicate":
|
|
63
|
+
keepers = _keeper_first(f.clients, prefer)
|
|
64
|
+
keep = keepers[0]
|
|
65
|
+
for cid in keepers[1:]:
|
|
66
|
+
scan = next(s for s in result.scans if s.client.id == cid)
|
|
67
|
+
plan.actions.append(
|
|
68
|
+
Action(scan.client, scan.path, f.server, "remove",
|
|
69
|
+
f"identical duplicate; keeping copy in {keep}")
|
|
70
|
+
)
|
|
71
|
+
elif f.kind == "drift":
|
|
72
|
+
if include_drift:
|
|
73
|
+
keepers = _keeper_first(f.clients, prefer)
|
|
74
|
+
keep = keepers[0]
|
|
75
|
+
for cid in keepers[1:]:
|
|
76
|
+
scan = next(s for s in result.scans if s.client.id == cid)
|
|
77
|
+
plan.actions.append(
|
|
78
|
+
Action(scan.client, scan.path, f.server, "remove",
|
|
79
|
+
f"drifted duplicate; keeping {keep}'s config")
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
plan.skipped.append(
|
|
83
|
+
f"drift '{f.server}' in {', '.join(f.clients)}: "
|
|
84
|
+
"skipped (re-run with --include-drift to resolve)"
|
|
85
|
+
)
|
|
86
|
+
elif f.kind == "dead-link" and prune_dead:
|
|
87
|
+
for cid in f.clients:
|
|
88
|
+
# Don't double-plan a removal for the same (client, server).
|
|
89
|
+
if any(a.client.id == cid and a.server == f.server for a in plan.actions):
|
|
90
|
+
continue
|
|
91
|
+
scan = next(s for s in result.scans if s.client.id == cid)
|
|
92
|
+
plan.actions.append(
|
|
93
|
+
Action(scan.client, scan.path, f.server, "remove",
|
|
94
|
+
f"dead link: {f.detail}")
|
|
95
|
+
)
|
|
96
|
+
return plan
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def backup_configs(paths: List[Path], backup_dir: Path) -> Path:
|
|
100
|
+
"""Copy each config file into a timestamped backup directory."""
|
|
101
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
102
|
+
dest = backup_dir / stamp
|
|
103
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
for p in paths:
|
|
105
|
+
if p and p.is_file():
|
|
106
|
+
target = dest / (p.name + ".bak")
|
|
107
|
+
shutil.copy2(p, target)
|
|
108
|
+
return dest
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def apply_plan(plan: Plan, result: ScanResult, backup_dir: Path) -> Dict[str, List[str]]:
|
|
112
|
+
"""Apply removals. Returns {client_id: [removed server names]}.
|
|
113
|
+
|
|
114
|
+
Backs up every touched config file first. Only JSON/TOML clients that
|
|
115
|
+
parsed cleanly are rewritten; anything else is left untouched.
|
|
116
|
+
"""
|
|
117
|
+
touched = sorted({a.path for a in plan.actions if a.path})
|
|
118
|
+
backup_configs(touched, backup_dir)
|
|
119
|
+
|
|
120
|
+
# Reload each touched file fresh so we never write stale data.
|
|
121
|
+
removed: Dict[str, List[str]] = {}
|
|
122
|
+
by_path: Dict[Path, List[Action]] = {}
|
|
123
|
+
for a in plan.actions:
|
|
124
|
+
by_path.setdefault(a.path, []).append(a)
|
|
125
|
+
|
|
126
|
+
for path, actions in by_path.items():
|
|
127
|
+
client = actions[0].client
|
|
128
|
+
original = path.read_text(encoding="utf-8")
|
|
129
|
+
servers = client.read_servers(original)
|
|
130
|
+
for a in actions:
|
|
131
|
+
if a.server in servers:
|
|
132
|
+
del servers[a.server]
|
|
133
|
+
removed.setdefault(client.id, []).append(a.server)
|
|
134
|
+
path.write_text(client.write_servers(servers, original), encoding="utf-8")
|
|
135
|
+
return removed
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def describe_plan(plan: Plan) -> List[str]:
|
|
139
|
+
lines: List[str] = []
|
|
140
|
+
if not plan.actions and not plan.skipped:
|
|
141
|
+
lines.append("Nothing to dedupe: no duplicates or dead links found.")
|
|
142
|
+
return lines
|
|
143
|
+
for client_id in sorted(plan.removals_by_client()):
|
|
144
|
+
names = sorted(plan.removals_by_client()[client_id])
|
|
145
|
+
lines.append(f" {client_id}: remove {', '.join(names)}")
|
|
146
|
+
for a in plan.actions:
|
|
147
|
+
if a.client.id == client_id:
|
|
148
|
+
lines.append(f" - {a.server}: {a.reason}")
|
|
149
|
+
for s in plan.skipped:
|
|
150
|
+
lines.append(f" SKIP: {s}")
|
|
151
|
+
return lines
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Minimal TOML emitter (stdlib-only: tomllib can parse but not write).
|
|
2
|
+
|
|
3
|
+
Covers the value shapes that appear in MCP client configs: strings, bools,
|
|
4
|
+
ints, floats, lists of scalars, nested tables, and arrays of tables. Anything
|
|
5
|
+
else raises a clear error so we never silently corrupt a config file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _scalar(value: Any) -> str:
|
|
14
|
+
if isinstance(value, bool):
|
|
15
|
+
return "true" if value else "false"
|
|
16
|
+
if isinstance(value, int):
|
|
17
|
+
return str(value)
|
|
18
|
+
if isinstance(value, float):
|
|
19
|
+
return repr(value)
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
22
|
+
escaped = escaped.replace("\n", "\\n").replace("\t", "\\t")
|
|
23
|
+
return f'"{escaped}"'
|
|
24
|
+
raise ValueError(f"unsupported TOML value: {type(value).__name__} ({value!r})")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_table(value: Any) -> bool:
|
|
28
|
+
return isinstance(value, dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_table_array(value: Any) -> bool:
|
|
32
|
+
return isinstance(value, list) and bool(value) and all(isinstance(v, dict) for v in value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_scalar_list(value: Any) -> bool:
|
|
36
|
+
return isinstance(value, list) and all(
|
|
37
|
+
isinstance(v, (str, bool, int, float)) for v in value
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dumps(doc: Dict[str, Any]) -> str:
|
|
42
|
+
lines: List[str] = []
|
|
43
|
+
|
|
44
|
+
def emit_table(path: List[str], table: Dict[str, Any]) -> None:
|
|
45
|
+
# Scalar / scalar-list keys first, then subtables, then table arrays.
|
|
46
|
+
for key in sorted(table):
|
|
47
|
+
value = table[key]
|
|
48
|
+
if _is_table(value) or _is_table_array(value):
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(value, list) and not _is_scalar_list(value):
|
|
51
|
+
raise ValueError(f"unsupported TOML value for key {key!r}: {value!r}")
|
|
52
|
+
lines.append(f"{key} = {_scalar(value) if not isinstance(value, list) else '[' + ', '.join(_scalar(v) for v in value) + ']'}")
|
|
53
|
+
for key in sorted(table):
|
|
54
|
+
value = table[key]
|
|
55
|
+
if _is_table(value):
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.append(f"[{'.'.join(path + [key])}]")
|
|
58
|
+
emit_table(path + [key], value)
|
|
59
|
+
for key in sorted(table):
|
|
60
|
+
value = table[key]
|
|
61
|
+
if _is_table_array(value):
|
|
62
|
+
for item in value:
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append(f"[[{'.'.join(path + [key])}]]")
|
|
65
|
+
emit_table(path + [key], item)
|
|
66
|
+
|
|
67
|
+
top_scalars = {k: v for k, v in doc.items() if not _is_table(v) and not _is_table_array(v)}
|
|
68
|
+
for key in sorted(top_scalars):
|
|
69
|
+
value = top_scalars[key]
|
|
70
|
+
if isinstance(value, list) and not _is_scalar_list(value):
|
|
71
|
+
raise ValueError(f"unsupported TOML value for key {key!r}")
|
|
72
|
+
lines.append(f"{key} = {_scalar(value) if not isinstance(value, list) else '[' + ', '.join(_scalar(v) for v in value) + ']'}")
|
|
73
|
+
for key in sorted(doc):
|
|
74
|
+
value = doc[key]
|
|
75
|
+
if _is_table(value):
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append(f"[{key}]")
|
|
78
|
+
emit_table([key], value)
|
|
79
|
+
for key in sorted(doc):
|
|
80
|
+
value = doc[key]
|
|
81
|
+
if _is_table_array(value):
|
|
82
|
+
for item in value:
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append(f"[[{key}]]")
|
|
85
|
+
emit_table([key], item)
|
|
86
|
+
return "\n".join(lines).strip() + "\n"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-config-dedupe"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Detect duplicate, drifted, and dead MCP server configs across clients — and dedupe them in one command"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "hao li", email = "haoli5933@gmail.com" }]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "config", "dedupe", "claude", "cursor", "vscode", "codex"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: System :: Systems Administration",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/hahahahahahahahah6/mcp-config-dedupe"
|
|
30
|
+
Issues = "https://github.com/hahahahahahahahah6/mcp-config-dedupe/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
mcp-dedupe = "mcp_dedupe.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["mcp_dedupe*"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Smoke tests for mcp-dedupe. Run with: python3 -m unittest discover -s tests -v"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
13
|
+
|
|
14
|
+
from mcp_dedupe.cli import main as cli_main
|
|
15
|
+
from mcp_dedupe.findings import scan_all
|
|
16
|
+
from mcp_dedupe.sync import apply_plan, build_plan
|
|
17
|
+
|
|
18
|
+
FS_SERVER = {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]}
|
|
19
|
+
BRAVE_A = {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"]}
|
|
20
|
+
BRAVE_B = {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search", "--other"]}
|
|
21
|
+
FETCH_A = {"command": "uvx", "args": ["mcp-server-fetch"]}
|
|
22
|
+
FETCH_B = {"command": "uvx", "args": ["mcp-server-fetch", "--verbose"]}
|
|
23
|
+
DEAD_SERVER = {"command": "definitely-not-a-real-command-xyz-123"}
|
|
24
|
+
REMOTE_SERVER = {"url": "https://example.com/mcp"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write(path: Path, content: str) -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(content, encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def json_config(servers: dict) -> str:
|
|
33
|
+
return json.dumps({"mcpServers": servers}, indent=2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_home(base: Path, files: dict) -> Path:
|
|
37
|
+
home = base / "home"
|
|
38
|
+
for rel, content in files.items():
|
|
39
|
+
write(home / rel, content)
|
|
40
|
+
return home
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScanTest(unittest.TestCase):
|
|
44
|
+
def setUp(self):
|
|
45
|
+
self.tmp = Path(tempfile.mkdtemp())
|
|
46
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
47
|
+
|
|
48
|
+
def test_identical_duplicate_detected(self):
|
|
49
|
+
home = make_home(self.tmp, {
|
|
50
|
+
".config/Claude/claude_desktop_config.json": json_config({"filesystem": FS_SERVER}),
|
|
51
|
+
".cursor/mcp.json": json_config({"filesystem": FS_SERVER}),
|
|
52
|
+
})
|
|
53
|
+
result = scan_all(home=home)
|
|
54
|
+
dups = [f for f in result.findings if f.kind == "duplicate"]
|
|
55
|
+
self.assertEqual(len(dups), 1)
|
|
56
|
+
self.assertEqual(dups[0].server, "filesystem")
|
|
57
|
+
self.assertEqual(sorted(dups[0].clients), ["claude-desktop", "cursor"])
|
|
58
|
+
|
|
59
|
+
def test_drift_detected(self):
|
|
60
|
+
home = make_home(self.tmp, {
|
|
61
|
+
".config/Claude/claude_desktop_config.json": json_config({"brave": BRAVE_A}),
|
|
62
|
+
".cursor/mcp.json": json_config({"brave": BRAVE_B}),
|
|
63
|
+
})
|
|
64
|
+
result = scan_all(home=home)
|
|
65
|
+
drifts = [f for f in result.findings if f.kind == "drift"]
|
|
66
|
+
self.assertEqual(len(drifts), 1)
|
|
67
|
+
self.assertEqual(drifts[0].server, "brave")
|
|
68
|
+
|
|
69
|
+
def test_dead_link_command_not_found(self):
|
|
70
|
+
home = make_home(self.tmp, {
|
|
71
|
+
".cursor/mcp.json": json_config({"oldtool": DEAD_SERVER}),
|
|
72
|
+
})
|
|
73
|
+
result = scan_all(home=home)
|
|
74
|
+
dead = [f for f in result.findings if f.kind == "dead-link"]
|
|
75
|
+
self.assertEqual(len(dead), 1)
|
|
76
|
+
self.assertEqual(dead[0].server, "oldtool")
|
|
77
|
+
self.assertIn("not found", dead[0].detail)
|
|
78
|
+
|
|
79
|
+
def test_dead_link_absolute_path(self):
|
|
80
|
+
home = make_home(self.tmp, {
|
|
81
|
+
".cursor/mcp.json": json_config(
|
|
82
|
+
{"local": {"command": "/nonexistent/bin/server"}}),
|
|
83
|
+
})
|
|
84
|
+
result = scan_all(home=home)
|
|
85
|
+
dead = [f for f in result.findings if f.kind == "dead-link"]
|
|
86
|
+
self.assertEqual(len(dead), 1)
|
|
87
|
+
self.assertIn("does not exist", dead[0].detail)
|
|
88
|
+
|
|
89
|
+
def test_remote_url_not_flagged_dead(self):
|
|
90
|
+
home = make_home(self.tmp, {
|
|
91
|
+
".cursor/mcp.json": json_config({"web": REMOTE_SERVER}),
|
|
92
|
+
})
|
|
93
|
+
result = scan_all(home=home)
|
|
94
|
+
dead = [f for f in result.findings if f.kind == "dead-link"]
|
|
95
|
+
self.assertEqual(dead, [])
|
|
96
|
+
remote = [f for f in result.findings if f.kind == "remote"]
|
|
97
|
+
self.assertEqual(len(remote), 1)
|
|
98
|
+
|
|
99
|
+
def test_malformed_config_reported(self):
|
|
100
|
+
home = make_home(self.tmp, {
|
|
101
|
+
".cursor/mcp.json": "{not valid json",
|
|
102
|
+
})
|
|
103
|
+
result = scan_all(home=home)
|
|
104
|
+
bad = [f for f in result.findings if f.kind == "unreadable"]
|
|
105
|
+
self.assertEqual(len(bad), 1)
|
|
106
|
+
self.assertEqual(bad[0].clients, ["cursor"])
|
|
107
|
+
|
|
108
|
+
def test_vscode_nested_layout_parsed(self):
|
|
109
|
+
home = make_home(self.tmp, {
|
|
110
|
+
".config/Code/User/settings.json": json.dumps(
|
|
111
|
+
{"editor.fontSize": 14, "mcp": {"servers": {"filesystem": FS_SERVER}}}),
|
|
112
|
+
})
|
|
113
|
+
result = scan_all(home=home)
|
|
114
|
+
scan = next(s for s in result.scans if s.client.id == "vscode")
|
|
115
|
+
self.assertEqual(list(scan.servers), ["filesystem"])
|
|
116
|
+
|
|
117
|
+
def test_codex_toml_parsed(self):
|
|
118
|
+
home = make_home(self.tmp, {
|
|
119
|
+
".codex/config.toml": (
|
|
120
|
+
'model = "gpt-5"\n\n[mcp_servers.filesystem]\n'
|
|
121
|
+
'command = "npx"\nargs = ["-y", "srv"]\n'
|
|
122
|
+
),
|
|
123
|
+
})
|
|
124
|
+
result = scan_all(home=home)
|
|
125
|
+
scan = next(s for s in result.scans if s.client.id == "codex")
|
|
126
|
+
self.assertEqual(list(scan.servers), ["filesystem"])
|
|
127
|
+
self.assertEqual(scan.servers["filesystem"]["command"], "npx")
|
|
128
|
+
|
|
129
|
+
def test_scan_json_output(self):
|
|
130
|
+
home = make_home(self.tmp, {
|
|
131
|
+
".cursor/mcp.json": json_config({"oldtool": DEAD_SERVER}),
|
|
132
|
+
})
|
|
133
|
+
proc = subprocess.run(
|
|
134
|
+
[sys.executable, "-m", "mcp_dedupe", "scan", "--format", "json",
|
|
135
|
+
"--home", str(home)],
|
|
136
|
+
capture_output=True, text=True, cwd=str(Path(__file__).resolve().parent.parent),
|
|
137
|
+
)
|
|
138
|
+
self.assertEqual(proc.returncode, 1) # findings present
|
|
139
|
+
data = json.loads(proc.stdout)
|
|
140
|
+
kinds = {f["kind"] for f in data["findings"]}
|
|
141
|
+
self.assertIn("dead-link", kinds)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DedupeTest(unittest.TestCase):
|
|
145
|
+
def setUp(self):
|
|
146
|
+
self.tmp = Path(tempfile.mkdtemp())
|
|
147
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
148
|
+
self.backup = self.tmp / "backups"
|
|
149
|
+
|
|
150
|
+
def _home(self):
|
|
151
|
+
return make_home(self.tmp, {
|
|
152
|
+
# "filesystem": identical in 3 clients -> duplicate
|
|
153
|
+
".config/Claude/claude_desktop_config.json":
|
|
154
|
+
json_config({"filesystem": FS_SERVER, "brave": BRAVE_A}),
|
|
155
|
+
".cursor/mcp.json":
|
|
156
|
+
json_config({"filesystem": FS_SERVER, "oldtool": DEAD_SERVER}),
|
|
157
|
+
# "fetch": same name, different config -> drift
|
|
158
|
+
".codex/config.toml":
|
|
159
|
+
'[mcp_servers.filesystem]\ncommand = "npx"\n'
|
|
160
|
+
'args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]\n'
|
|
161
|
+
'\n[mcp_servers.fetch]\ncommand = "uvx"\nargs = ["mcp-server-fetch"]\n',
|
|
162
|
+
".config/Code/User/settings.json": json.dumps(
|
|
163
|
+
{"mcp": {"servers": {"fetch": FETCH_B}}}),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
def test_dry_run_writes_nothing(self):
|
|
167
|
+
home = self._home()
|
|
168
|
+
before = {str(p): p.read_bytes() for p in home.rglob("*") if p.is_file()}
|
|
169
|
+
result = scan_all(home=home)
|
|
170
|
+
plan = build_plan(result)
|
|
171
|
+
self.assertTrue(plan.actions) # there is work to do...
|
|
172
|
+
# ...but a dry run must not touch anything. Just don't call apply_plan.
|
|
173
|
+
after = {str(p): p.read_bytes() for p in home.rglob("*") if p.is_file()}
|
|
174
|
+
self.assertEqual(before, after)
|
|
175
|
+
|
|
176
|
+
def test_apply_removes_duplicates_and_backs_up(self):
|
|
177
|
+
home = self._home()
|
|
178
|
+
result = scan_all(home=home)
|
|
179
|
+
plan = build_plan(result) # drift + dead-link excluded by default
|
|
180
|
+
removed = apply_plan(plan, result, self.backup)
|
|
181
|
+
# identical duplicate: claude-desktop keeps (priority), cursor+codex lose it
|
|
182
|
+
self.assertEqual(removed.get("cursor"), ["filesystem"])
|
|
183
|
+
self.assertEqual(removed.get("codex"), ["filesystem"])
|
|
184
|
+
self.assertNotIn("claude-desktop", removed)
|
|
185
|
+
cursor_cfg = json.loads((home / ".cursor/mcp.json").read_text())
|
|
186
|
+
self.assertNotIn("filesystem", cursor_cfg["mcpServers"])
|
|
187
|
+
self.assertIn("oldtool", cursor_cfg["mcpServers"]) # dead kept: no --prune-dead
|
|
188
|
+
# backup exists
|
|
189
|
+
backups = list(self.backup.rglob("*.bak"))
|
|
190
|
+
self.assertTrue(backups)
|
|
191
|
+
# drift untouched by default
|
|
192
|
+
codex_text = (home / ".codex/config.toml").read_text()
|
|
193
|
+
self.assertIn("mcp-server-fetch", codex_text)
|
|
194
|
+
|
|
195
|
+
def test_apply_requires_explicit_yes(self):
|
|
196
|
+
home = self._home()
|
|
197
|
+
proc = subprocess.run(
|
|
198
|
+
[sys.executable, "-m", "mcp_dedupe", "dedupe", "--apply",
|
|
199
|
+
"--home", str(home)],
|
|
200
|
+
capture_output=True, text=True, cwd=str(Path(__file__).resolve().parent.parent),
|
|
201
|
+
)
|
|
202
|
+
self.assertEqual(proc.returncode, 2)
|
|
203
|
+
self.assertIn("--yes", proc.stderr + proc.stdout)
|
|
204
|
+
# nothing changed
|
|
205
|
+
cursor_cfg = json.loads((home / ".cursor/mcp.json").read_text())
|
|
206
|
+
self.assertIn("filesystem", cursor_cfg["mcpServers"])
|
|
207
|
+
|
|
208
|
+
def test_drift_resolved_with_include_drift_and_prefer(self):
|
|
209
|
+
home = self._home()
|
|
210
|
+
result = scan_all(home=home)
|
|
211
|
+
plain = build_plan(result)
|
|
212
|
+
self.assertTrue(any("drift" in s for s in plain.skipped))
|
|
213
|
+
plan = build_plan(result, include_drift=True, prefer="vscode")
|
|
214
|
+
removed = apply_plan(plan, result, self.backup)
|
|
215
|
+
# vscode preferred: its "fetch" survives, codex's copy removed
|
|
216
|
+
self.assertIn("fetch", removed.get("codex", []))
|
|
217
|
+
vscode_cfg = json.loads((home / ".config/Code/User/settings.json").read_text())
|
|
218
|
+
self.assertIn("fetch", vscode_cfg["mcp"]["servers"])
|
|
219
|
+
codex_text = (home / ".codex/config.toml").read_text()
|
|
220
|
+
self.assertNotIn("[mcp_servers.fetch]", codex_text)
|
|
221
|
+
|
|
222
|
+
def test_prune_dead_removes_dead_servers(self):
|
|
223
|
+
home = self._home()
|
|
224
|
+
result = scan_all(home=home)
|
|
225
|
+
plan = build_plan(result, prune_dead=True)
|
|
226
|
+
removed = apply_plan(plan, result, self.backup)
|
|
227
|
+
self.assertIn("oldtool", removed.get("cursor", []))
|
|
228
|
+
cursor_cfg = json.loads((home / ".cursor/mcp.json").read_text())
|
|
229
|
+
self.assertNotIn("oldtool", cursor_cfg["mcpServers"])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
unittest.main()
|