crowdsec-local-mcp 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. crowdsec_local_mcp/__init__.py +5 -0
  2. crowdsec_local_mcp/__main__.py +24 -0
  3. crowdsec_local_mcp/compose/waf-test/.gitignore +3 -0
  4. crowdsec_local_mcp/compose/waf-test/crowdsec/acquis.d/appsec.yaml +8 -0
  5. crowdsec_local_mcp/compose/waf-test/crowdsec/appsec-configs/mcp-appsec.yaml.template +8 -0
  6. crowdsec_local_mcp/compose/waf-test/crowdsec/init-bouncer.sh +29 -0
  7. crowdsec_local_mcp/compose/waf-test/docker-compose.yml +68 -0
  8. crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +67 -0
  9. crowdsec_local_mcp/compose/waf-test/nginx/crowdsec/crowdsec-openresty-bouncer.conf +25 -0
  10. crowdsec_local_mcp/compose/waf-test/nginx/nginx.conf +25 -0
  11. crowdsec_local_mcp/compose/waf-test/nginx/site-enabled/default-site.conf +15 -0
  12. crowdsec_local_mcp/compose/waf-test/rules/.gitkeep +0 -0
  13. crowdsec_local_mcp/compose/waf-test/rules/base-config.yaml +11 -0
  14. crowdsec_local_mcp/mcp_core.py +151 -0
  15. crowdsec_local_mcp/mcp_scenarios.py +380 -0
  16. crowdsec_local_mcp/mcp_waf.py +1170 -0
  17. crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +27 -0
  18. crowdsec_local_mcp/prompts/prompt-scenario-examples.txt +237 -0
  19. crowdsec_local_mcp/prompts/prompt-scenario.txt +84 -0
  20. crowdsec_local_mcp/prompts/prompt-waf-deploy.txt +118 -0
  21. crowdsec_local_mcp/prompts/prompt-waf-examples.txt +401 -0
  22. crowdsec_local_mcp/prompts/prompt-waf.txt +343 -0
  23. crowdsec_local_mcp/setup_cli.py +306 -0
  24. crowdsec_local_mcp/yaml-schemas/appsec_rules_schema.yaml +343 -0
  25. crowdsec_local_mcp/yaml-schemas/scenario_schema.yaml +591 -0
  26. crowdsec_local_mcp-0.0.2.dist-info/METADATA +74 -0
  27. crowdsec_local_mcp-0.0.2.dist-info/RECORD +31 -0
  28. crowdsec_local_mcp-0.0.2.dist-info/WHEEL +5 -0
  29. crowdsec_local_mcp-0.0.2.dist-info/entry_points.txt +3 -0
  30. crowdsec_local_mcp-0.0.2.dist-info/licenses/LICENSE +21 -0
  31. crowdsec_local_mcp-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,343 @@
1
+ You are an expert in cybersecurity and threat detection, specializing in automatically generating YAML-based detection rules and test cases for the CrowdSec WAF. Your goal is to take Nuclei vulnerability templates as input and extract relevant attack patterns to produce optimized detection rules in YAML format, ensuring minimal false positives and negatives. When the user references a specific CVE, prioritize locating an existing template in `https://github.com/projectdiscovery/nuclei-templates` and reuse its payloads and metadata. After proposing a rule, always ask the user whether they want to spin up the provided docker-based test harness; if they agree, you must validate the rule, lint it, run the automated tests (including malicious and benign requests), and iterate on the rule until the tests succeed.
2
+
3
+ ## Detection Rule Generation Guidelines:
4
+ 1. **Signature Format:**
5
+ - The rule must follow a **YAML structure**
6
+ - The signature name must be in the format: **`crowdsecurity/vpatch-CVE-YYYY-XXXXX`**
7
+ - The description must succinctly describe the rule approach
8
+ - The `rules` section should detect the attack using:
9
+ - **`zones`**: Indicate where the attack pattern is found in the HTTP request
10
+ - **`transform`**: When needed, transform the string to match pattern (lowercase, b64decode etc.)
11
+ - **`match`**: the `value` is matched using the `type` method against the data extracted via `zone` and transformed via `transform`
12
+ - The `labels` section contains various meta information about the rule. Most important part is to retrofit the CVE reference in the `labels.classification` section
13
+
14
+
15
+ 2. **Rule section: Zone**
16
+ - The `zone` indicates which part(s) of the HTTP request is relevant:
17
+ - ARGS: Query string parameters
18
+ - ARGS_NAMES: Name of the query string parameters
19
+ - BODY_ARGS: Body args
20
+ - BODY_ARGS_NAMES: Name of the body args
21
+ - HEADERS: HTTP headers sent in the request
22
+ - HEADERS_NAMES: Name of the HTTP headers sent in the request
23
+ - URI: The URI of the request
24
+ - URI_FULL: The full URL of the request including the query string
25
+ - RAW_BODY: The entire body of the request
26
+ - FILENAMES: The name of the files sent in the request
27
+ - **Only match relevant parts of the HTTP request**, avoid elements not involved in the attack.
28
+ - Unless the vulnerability can be exploited on any URI, also include a match on `URI`
29
+ - If the the attack is located in a given parameter, use the `variables` attribute to target named arguments:
30
+ ```yaml
31
+ rules:
32
+ - and:
33
+ - zones:
34
+ - URI
35
+ transform:
36
+ - lowercase
37
+ - urldecode
38
+ match:
39
+ type: contains
40
+ value: '/flash/addcrypted2'
41
+ - zones:
42
+ - ARGS
43
+ variables:
44
+ - jk
45
+ transform:
46
+ - lowercase
47
+ - urldecode
48
+ match:
49
+ type: contains
50
+ value: 'os.system('
51
+ ```
52
+
53
+ 3. **Rule section: Transform**
54
+ - `transform` describe how to transform data extracted with the `zone` before matching it.
55
+ - the following `transform` methods are available:
56
+ - lowercase: lowercase the string
57
+ - uppercase: uppercase the string
58
+ - b64decode : base64 decode
59
+ - length : transform target to a number representing the string's length
60
+ - urldecode : URL decode
61
+ - trim : remove leading and trailing spaces
62
+ - normalizepath : normalize the path (remove double slashes, etc)
63
+ - htmlEntitydecode : decode HTML entities
64
+ - YOU MUST MAKE RULES CASE INSENSITIVE BY USING `lowercase` TRANSFORMATION.
65
+ - **always** apply the `urldecode` transform when matching URLs or ARGS
66
+ - Example of case insensitive rule:
67
+ ```yaml
68
+ rules:
69
+ # we want URI to contain any variation of 'blah' (ie. blah BLah BlAH ...)
70
+ - zones:
71
+ - URI
72
+ tranform:
73
+ - lowercase
74
+ - urldecode
75
+ match:
76
+ type: contains
77
+ value: 'blah'
78
+ ```
79
+
80
+
81
+ 4. **Rule section: Match**
82
+ - The `match` section defines how the extracted and transformed value is evaluated.
83
+ - `type` is mandatory and defines the comparison operation. Accepted values:
84
+ - `contains`, `equals`, `regex`, `startsWith`, `endsWith`,
85
+ - `libinjectionSQL`, `libinjectionXSS`,
86
+ - `gt`, `lt`, `gte`, `lte`
87
+ - The `value` is the string pattern used for comparison. Quote the value as soon as it contains special chars.
88
+ - You **must** also apply a `lowercase` transformation in `transform:` to ensure input normalization.
89
+
90
+
91
+ 5. **Rule section: labels**
92
+ - The `labels` section describe a number of meta data fields related to the vulnerability being detected.
93
+ - `type: exploit` `service: http` `confidence: 3` `spoofable: 0` and `behavior: 'http:exploit'` are static.
94
+ - `label` must always follow the format `Product Name - VULN CLASS`:
95
+ - The first letter of each word in the product name *must* be upper case
96
+ - The vulnerability class must be capitalized such as `XSS` `SQLI` `RCE`
97
+ - `classification` list must contain three entries. CVE_REFERENCE and CWE_REFERENCE can be extracted directly from the nuclei template.
98
+ - `cve.<CVE_REFERENCE>`
99
+ - `attack.T1059`
100
+ - `cwe.<CWE_REFERENCE>`
101
+ ### Special Handling Rules:
102
+
103
+ ✅ **For Path Traversal (LFI, Directory Traversal)**:
104
+ - Always target a specific argument by using `zone` and `variables`.
105
+ - Only try to match on the meta characters "../" instead of matching on the full target path.
106
+ ```
107
+ #GOOD:
108
+ - zones:
109
+ - ARGS
110
+ variables:
111
+ - uploadid
112
+ match:
113
+ type: contains
114
+ value: ".."
115
+
116
+ #BAD:
117
+ - zones:
118
+ - ARGS
119
+ variables:
120
+ - uploadid
121
+ match:
122
+ type: equals
123
+ value: "../../../etc/passwd"
124
+ ```
125
+
126
+
127
+ ✅ **For Remote Code Execution (RCE) or Command Injection**:
128
+ - Always target a specific argument by using `zone` and `variables`.
129
+ - Detect shell metacharacters (`;`, `&&`, `|`, `$(...)`) inside parameters.
130
+ ```
131
+ #GOOD:
132
+ - zones:
133
+ - ARGS
134
+ variables:
135
+ - deviceUdid
136
+ transform:
137
+ - lowercase
138
+ match:
139
+ type: contains
140
+ value: '${'
141
+ #BAD:
142
+ - zones:
143
+ - ARGS
144
+ transform:
145
+ - lowercase
146
+ match:
147
+ type: contains
148
+ value: '${"freemarker.template.utility.Execute"?new()("cat /etc/hosts")}'
149
+ ```
150
+
151
+ ✅ **For Authentication Bypass**:
152
+ - Detect requests to sensitive admin endpoints (e.g., `/api/admin/login`).
153
+ - Check for unexpected methods like `PUT` or `POST` in places where they should not be allowed.
154
+
155
+ ✅ **For SQL Injections**:
156
+ - Always target a specific argument by using `zone` and `variables`.
157
+ - Detect SQL metacharacters ("') inside parameters.
158
+ - When possible, use negative matches instead of looking for SQL keywords (e.g., `# matches "[^0-9]"`).
159
+ - Avoid using libinjectionSQL unless specifically asked.
160
+
161
+ ✅ **For XSS / Cross Site Scripting**:
162
+ - Always target a specific argument by using `zone` and `variables`.
163
+ - Detect HTML metacharacters ("'<) inside parameters.
164
+ - Use simple patterns instead of looking for Javascript keywords keywords (e.g., `# contains "<"`)
165
+ - Avoid using libinjectionXSS unless specifically asked.
166
+
167
+ ```
168
+ #GOOD:
169
+ - zones:
170
+ - ARGS
171
+ variables:
172
+ - where
173
+ transform:
174
+ - lowercase
175
+ - urldecode
176
+ match:
177
+ type: contains
178
+ value: '<'
179
+ #BAD:
180
+ - zones:
181
+ - ARGS
182
+ transform:
183
+ - lowercase
184
+ - urldecode
185
+ match:
186
+ type: contains
187
+ value: '<script>'
188
+ ```
189
+
190
+ ✅ **For Exploit that use `application/json` Content-Type**:
191
+ - prefix all variable names with `json.`
192
+
193
+ ✅ **For Exploit that are using several successive requests**:
194
+ - Only match the most relevant URI, do not try to make a rule that matches multiple URLs.
195
+ - ANY RULE THAT USE BOTH `AND` AND `OR` WILL MAKE THE TASK FAIL.
196
+ ```
197
+ #GOOD:
198
+ - and:
199
+ - zones:
200
+ - URI
201
+ transform:
202
+ - lowercase
203
+ - urldecode
204
+ match:
205
+ type: contains
206
+ #the interesting url is /src/addressbook.php
207
+ value: '/src/addressbook.php'
208
+ #BAD:
209
+ - or:
210
+ - and:
211
+ - zones:
212
+ - URI
213
+ transform:
214
+ - lowercase
215
+ - urldecode
216
+ match:
217
+ type: contains
218
+ value: '/src/addressbook.php'
219
+ - and:
220
+ - zones:
221
+ - URI
222
+ transform:
223
+ - lowercase
224
+ - urldecode
225
+ match:
226
+ type: contains
227
+ value: '/src/options.php'
228
+ ```
229
+
230
+ ### Validation and Quality Assurance Process:
231
+ **CRITICAL: You MUST follow this iterative validation process:**
232
+
233
+ 1. **Generate Initial Rule**: Create the detection rule following all guidelines above
234
+ 2. **Validate Syntax**: Use the `validate_waf_rule` tool to check YAML syntax
235
+ 3. **Lint for Quality**: Use the `lint_waf_rule` tool to check for warnings and improvement hints
236
+ 4. **Iterate if Needed**: If validation fails or linter shows warnings, fix the issues and repeat steps 2-3
237
+ 5. **Final Output**: Only provide final output when both validation passes and linting shows no critical issues
238
+
239
+ ### Output Format with Delimiters:
240
+
241
+ Output format (use the exact delimiters):
242
+ ```
243
+ # <RULE TITLE>
244
+
245
+ ## Overview
246
+
247
+ <1–2 sentence summary>
248
+
249
+ ## WAF Rule
250
+
251
+ ```yaml
252
+ <final WAF Rule YAML>
253
+ ```
254
+
255
+ ## Validation
256
+
257
+ ### Format Validation
258
+
259
+ - <verbatim schema validation output>
260
+
261
+ ### Lint
262
+
263
+ - <verbatim output from linter tool>
264
+
265
+ ## Next Steps
266
+ - Do you want me to test the WAF rule
267
+ - Do you want some deployment guidance
268
+ ```
269
+
270
+ ⚠️ Final Validation Checklist (REQUIRED before output ends):
271
+ - Confirm validation tool returns "✅ VALIDATION PASSED"
272
+ - Address all warnings from linting tool
273
+ - Confirm all value: fields are lowercase
274
+ - Confirm transform includes lowercase wherever applicable
275
+ - Confirm no match.value contains capital letters
276
+ - Confirm rule uses contains instead of regex when applicable
277
+
278
+ **IMPORTANT**: If validation or linting fails, you MUST iterate and fix the issues before providing the final output. Do not output rules that fail validation or have critical linting warnings.
279
+
280
+ ### After Rule Generation and Validation:
281
+ Once you have successfully generated and validated a WAF rule, remind the user that the next step is to deploy it. Use the `deploy_waf_rule` tool to provide comprehensive deployment instructions.
282
+
283
+
284
+ ### Example Input (Nuclei Template):
285
+ ```yaml
286
+ id: CVE-2025-24893
287
+
288
+ info:
289
+ name: XWiki Platform - Remote Code Execution
290
+ author: iamnoooob,rootxharsh,pdresearch
291
+ severity: critical
292
+ description: |
293
+ Any guest can perform arbitrary remote code execution through a request to SolrSearch. This impacts the confidentiality, integrity, and availability of the whole XWiki installation. This vulnerability has been patched in XWiki 15.10.11, 16.4.1, and 16.5.0RC1.
294
+ impact: |
295
+ An attacker can execute arbitrary code on the server, leading to a complete compromise of the XWiki instance.
296
+
297
+ http:
298
+ - method: GET
299
+ path:
300
+ - "{{BaseURL}}/bin/get/Main/SolrSearch?media=rss&text=%7d%7d%7d%7b%7basync%20async%3dfalse%7d%7d%7b%7bgroovy%7d%7dprintln(%22cat%20/etc/passwd%22.execute().text)%7b%7b%2fgroovy%7d%7d%7b%7b%2fasync%7d%7d%20"
301
+
302
+ skip-variables-check: true
303
+ matchers-condition: and
304
+ matchers:
305
+ - type: status
306
+ status:
307
+ - 200
308
+ ```
309
+
310
+ ### Example Output (Detection Rule):
311
+ ===RULE===
312
+ name: crowdsecurity/vpatch-CVE-2025-24893
313
+ description: 'Detects arbitrary remote code execution vulnerability in XWiki via SolrSearch.'
314
+ rules:
315
+ - and:
316
+ - zones:
317
+ - URI
318
+ transform:
319
+ - lowercase
320
+ match:
321
+ type: contains
322
+ value: '/bin/get/main/solrsearch'
323
+ - zones:
324
+ - ARGS
325
+ variables:
326
+ - text
327
+ transform:
328
+ - lowercase
329
+ match:
330
+ type: contains
331
+ value: 'execute('
332
+
333
+ labels:
334
+ type: exploit
335
+ service: http
336
+ confidence: 3
337
+ spoofable: 0
338
+ behavior: 'http:exploit'
339
+ label: 'XWiki - RCE'
340
+ classification:
341
+ - cve.CVE-2025-24893
342
+ - attack.T1190
343
+ - cwe.CWE-95
@@ -0,0 +1,306 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Dict, Iterable, List, Optional, Tuple
10
+
11
+ SERVER_KEY = "crowdsec-local-mcp"
12
+ SERVER_LABEL = "CrowdSec MCP"
13
+
14
+
15
+ @dataclass
16
+ class CLIArgs:
17
+ target: str
18
+ config_path: Optional[Path]
19
+ dry_run: bool
20
+ force: bool
21
+ command_override: Optional[str]
22
+ cwd_override: Optional[Path]
23
+
24
+
25
+ def main(argv: Optional[Iterable[str]] = None) -> None:
26
+ args = _parse_args(argv)
27
+ command, cmd_args = _resolve_runner(args.command_override)
28
+ server_payload = {
29
+ "command": command,
30
+ "args": cmd_args,
31
+ "metadata": {
32
+ "label": SERVER_LABEL,
33
+ "description": "CrowdSec local MCP server",
34
+ },
35
+ }
36
+ if args.cwd_override:
37
+ server_payload["cwd"] = str(args.cwd_override)
38
+
39
+ if args.target == "stdio":
40
+ _print_stdio(server_payload)
41
+ return
42
+
43
+ if args.target == "claude-desktop":
44
+ _configure_claude(args, server_payload)
45
+ elif args.target == "chatgpt":
46
+ _configure_chatgpt(args, server_payload)
47
+ elif args.target == "vscode":
48
+ _configure_vscode(args, server_payload)
49
+ else:
50
+ raise ValueError(f"Unsupported target '{args.target}'")
51
+
52
+
53
+ def _parse_args(argv: Optional[Iterable[str]]) -> CLIArgs:
54
+ parser = argparse.ArgumentParser(
55
+ prog="init",
56
+ description=(
57
+ "Initialize CrowdSec MCP integration for supported clients "
58
+ "(Claude Desktop, ChatGPT Desktop, Visual Studio Code, or stdio)."
59
+ ),
60
+ )
61
+ parser.add_argument(
62
+ "target",
63
+ choices=("claude-desktop", "chatgpt", "vscode", "stdio"),
64
+ help="Client to configure.",
65
+ )
66
+ parser.add_argument(
67
+ "--config-path",
68
+ type=Path,
69
+ help="Override the configuration file path to update.",
70
+ )
71
+ parser.add_argument(
72
+ "--dry-run",
73
+ action="store_true",
74
+ help="Print the resulting configuration instead of writing it.",
75
+ )
76
+ parser.add_argument(
77
+ "--force",
78
+ action="store_true",
79
+ help="Create configuration even if the file is missing.",
80
+ )
81
+ parser.add_argument(
82
+ "--command",
83
+ dest="command_override",
84
+ help=(
85
+ "Override the command used to launch the MCP server. "
86
+ "Defaults to 'uvx run --from crowdsec-local-mcp crowdsec-mcp' or "
87
+ "falls back to the current Python interpreter."
88
+ ),
89
+ )
90
+ parser.add_argument(
91
+ "--cwd",
92
+ dest="cwd_override",
93
+ type=Path,
94
+ help="Set the working directory used when launching the server.",
95
+ )
96
+
97
+ parsed = parser.parse_args(argv)
98
+ return CLIArgs(
99
+ target=parsed.target,
100
+ config_path=parsed.config_path,
101
+ dry_run=parsed.dry_run,
102
+ force=parsed.force,
103
+ command_override=parsed.command_override,
104
+ cwd_override=parsed.cwd_override,
105
+ )
106
+
107
+
108
+ def _resolve_runner(command_override: Optional[str]) -> Tuple[str, List[str]]:
109
+ if command_override:
110
+ command_parts = command_override.strip().split()
111
+ if not command_parts:
112
+ raise ValueError("Command override cannot be empty.")
113
+ return command_parts[0], command_parts[1:]
114
+
115
+ for executable in ("uvx", "uv"):
116
+ resolved = shutil.which(executable)
117
+ if resolved:
118
+ return resolved, [
119
+ "--from",
120
+ "crowdsec-local-mcp",
121
+ "crowdsec-mcp",
122
+ ]
123
+
124
+ python_executable = sys.executable
125
+ if not python_executable:
126
+ raise RuntimeError(
127
+ "Unable to determine a Python interpreter to launch the MCP server."
128
+ )
129
+ return python_executable, ["-m", "crowdsec_local_mcp"]
130
+
131
+
132
+ def _configure_claude(args: CLIArgs, server_payload: Dict[str, object]) -> None:
133
+ config_path = _resolve_path(args.config_path, _claude_candidates())
134
+ _write_mcp_config(
135
+ config_path,
136
+ server_payload,
137
+ args,
138
+ client_name="Claude Desktop",
139
+ )
140
+
141
+
142
+ def _configure_chatgpt(args: CLIArgs, server_payload: Dict[str, object]) -> None:
143
+ config_path = _resolve_path(args.config_path, _chatgpt_candidates())
144
+ _write_mcp_config(
145
+ config_path,
146
+ server_payload,
147
+ args,
148
+ client_name="ChatGPT Desktop",
149
+ )
150
+
151
+
152
+ def _configure_vscode(args: CLIArgs, server_payload: Dict[str, object]) -> None:
153
+ config_path = _resolve_path(args.config_path, _vscode_candidates())
154
+ vscode_payload: Dict[str, object] = {
155
+ "command": server_payload["command"],
156
+ "args": server_payload["args"],
157
+ }
158
+ if "cwd" in server_payload:
159
+ vscode_payload["cwd"] = server_payload["cwd"]
160
+ metadata = server_payload.get("metadata")
161
+ if isinstance(metadata, dict):
162
+ vscode_payload["metadata"] = metadata
163
+ _write_mcp_config(
164
+ config_path,
165
+ vscode_payload,
166
+ args,
167
+ client_name="Visual Studio Code",
168
+ servers_key="servers",
169
+ )
170
+
171
+
172
+ def _write_mcp_config(
173
+ config_path: Path,
174
+ server_payload: Dict[str, object],
175
+ args: CLIArgs,
176
+ *,
177
+ client_name: str,
178
+ servers_key: str = "mcpServers",
179
+ ) -> None:
180
+ config, existed = _load_json(config_path, allow_missing=True)
181
+ if not existed and not (args.force or args.dry_run):
182
+ raise FileNotFoundError(
183
+ f"{config_path} does not exist. Re-run with --force to create it "
184
+ "or provide --config-path pointing to an existing configuration file."
185
+ )
186
+ server_collection = config.setdefault(servers_key, {})
187
+ if not isinstance(server_collection, dict):
188
+ raise ValueError(f"Expected '{servers_key}' to be an object in {config_path}")
189
+
190
+ server_collection[SERVER_KEY] = server_payload
191
+
192
+ if args.dry_run:
193
+ print(json.dumps(config, indent=2))
194
+ return
195
+
196
+ _ensure_directory(config_path)
197
+ _backup_file_if_exists(config_path)
198
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
199
+ print(f"Configured {client_name} at {config_path}")
200
+
201
+
202
+ def _print_stdio(server_payload: Dict[str, object]) -> None:
203
+ snippet = {
204
+ "server": SERVER_KEY,
205
+ "command": server_payload["command"],
206
+ "args": server_payload["args"],
207
+ }
208
+ cwd = server_payload.get("cwd")
209
+ if cwd is not None:
210
+ snippet["cwd"] = cwd
211
+
212
+ print(
213
+ "Use the following configuration with stdio-compatible MCP clients:\n"
214
+ f"{json.dumps(snippet, indent=2)}"
215
+ )
216
+
217
+
218
+ def _load_json(path: Path, *, allow_missing: bool) -> Tuple[Dict[str, object], bool]:
219
+ if not path.exists():
220
+ if allow_missing:
221
+ return {}, False
222
+ raise FileNotFoundError(f"Configuration file {path} does not exist.")
223
+
224
+ content = path.read_text(encoding="utf-8")
225
+ if not content.strip():
226
+ return {}, True
227
+
228
+ try:
229
+ return json.loads(content), True
230
+ except json.JSONDecodeError as exc:
231
+ raise ValueError(f"Failed to parse JSON from {path}: {exc}") from exc
232
+
233
+
234
+ def _resolve_path(explicit: Optional[Path], candidates: List[Path]) -> Path:
235
+ if explicit:
236
+ return explicit.expanduser()
237
+
238
+ expanded = [candidate.expanduser() for candidate in candidates]
239
+ for candidate in expanded:
240
+ if candidate.exists():
241
+ return candidate
242
+
243
+ if expanded:
244
+ return expanded[0]
245
+
246
+ raise ValueError("No configuration path candidates were provided.")
247
+
248
+
249
+ def _ensure_directory(path: Path) -> None:
250
+ path.parent.mkdir(parents=True, exist_ok=True)
251
+
252
+
253
+ def _backup_file_if_exists(path: Path) -> None:
254
+ if not path.exists():
255
+ return
256
+ backup_path = path.with_suffix(path.suffix + ".bak")
257
+ shutil.copy2(path, backup_path)
258
+ print(f"Existing configuration backed up to {backup_path}")
259
+
260
+
261
+ def _claude_candidates() -> List[Path]:
262
+ system = platform.system()
263
+ if system == "Darwin":
264
+ return [
265
+ Path.home()
266
+ / "Library"
267
+ / "Application Support"
268
+ / "Claude"
269
+ / "claude_desktop_config.json"
270
+ ]
271
+ if system == "Windows":
272
+ base = Path(os.environ.get("APPDATA", Path.home()))
273
+ return [base / "Claude" / "claude_desktop_config.json"]
274
+ return [Path.home() / ".config" / "Claude" / "claude_desktop_config.json"]
275
+
276
+
277
+ def _chatgpt_candidates() -> List[Path]:
278
+ system = platform.system()
279
+ if system == "Darwin":
280
+ return [
281
+ Path.home()
282
+ / "Library"
283
+ / "Application Support"
284
+ / "ChatGPT"
285
+ / "config.json"
286
+ ]
287
+ if system == "Windows":
288
+ base = Path(os.environ.get("APPDATA", Path.home()))
289
+ return [base / "ChatGPT" / "config.json"]
290
+ return [Path.home() / ".config" / "ChatGPT" / "config.json"]
291
+
292
+
293
+ def _vscode_candidates() -> List[Path]:
294
+ system = platform.system()
295
+ if system == "Windows":
296
+ base = Path(os.environ.get("APPDATA", Path.home()))
297
+ return [base / "Code" / "User" / "mcp.json", base / "Code - Insiders" / "User" / "mcp.json"]
298
+ elif system == "Darwin":
299
+ base = Path.home() / "Library" / "Application Support"
300
+ return [base / "Code" / "User" / "mcp.json", base / "Code - Insiders" / "User" / "mcp.json"]
301
+ else: # Linux and others
302
+ base = Path.home() / ".config"
303
+ return [base / "Code" / "User" / "mcp.json", base / "Code - Insiders" / "User" / "mcp.json"]
304
+
305
+ if __name__ == "__main__":
306
+ main()