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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ mcp-dedupe = mcp_dedupe.cli:main
@@ -0,0 +1,4 @@
1
+ """mcp_dedupe: detect drift/duplicates/dead links across MCP client configs."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m mcp_dedupe`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()