mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,463 @@
1
+ """Utility functions for py-mcp-installer-service.
2
+
3
+ This module provides common utility functions for file operations, command
4
+ resolution, credential masking, and safe parsing of configuration files.
5
+
6
+ Design Philosophy:
7
+ - Atomic operations for file writes (temp file + rename)
8
+ - Safe parsing with error recovery
9
+ - Credential masking for logs
10
+ - Cross-platform compatibility
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import tempfile
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from .exceptions import AtomicWriteError, BackupError, ConfigurationError
22
+
23
+ # ============================================================================
24
+ # File Operations (Atomic & Safe)
25
+ # ============================================================================
26
+
27
+
28
+ def atomic_write(path: Path, content: str) -> None:
29
+ """Write file atomically using temp file + rename pattern.
30
+
31
+ This ensures the file is never in a partially-written state, which is
32
+ critical for configuration files that may be read by other processes.
33
+
34
+ Strategy:
35
+ 1. Write to temporary file in same directory
36
+ 2. Sync to disk (fsync)
37
+ 3. Atomic rename to target path
38
+
39
+ Args:
40
+ path: Target file path
41
+ content: Content to write
42
+
43
+ Raises:
44
+ AtomicWriteError: If write operation fails
45
+
46
+ Example:
47
+ >>> atomic_write(Path("/tmp/config.json"), '{"key": "value"}')
48
+ """
49
+ # Ensure parent directory exists
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+
52
+ # Create temp file in same directory (required for atomic rename)
53
+ try:
54
+ fd, temp_path = tempfile.mkstemp(
55
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
56
+ )
57
+
58
+ try:
59
+ # Write content
60
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
61
+ f.write(content)
62
+ f.flush()
63
+ os.fsync(f.fileno()) # Force write to disk
64
+
65
+ # Atomic rename (overwrites existing file atomically)
66
+ os.replace(temp_path, path)
67
+
68
+ except Exception as e:
69
+ # Clean up temp file on error
70
+ if os.path.exists(temp_path):
71
+ os.unlink(temp_path)
72
+ raise AtomicWriteError(f"Failed to write {path}: {e}", str(path)) from e
73
+
74
+ except Exception as e:
75
+ raise AtomicWriteError(f"Failed to create temp file: {e}", str(path)) from e
76
+
77
+
78
+ def backup_file(path: Path) -> Path:
79
+ """Create timestamped backup of file.
80
+
81
+ Backups are stored in .mcp-installer-backups/ directory next to the
82
+ original file, with timestamp in filename.
83
+
84
+ Args:
85
+ path: File to backup
86
+
87
+ Returns:
88
+ Path to created backup file
89
+
90
+ Raises:
91
+ BackupError: If backup creation fails
92
+
93
+ Example:
94
+ >>> backup_path = backup_file(Path("/tmp/config.json"))
95
+ >>> print(backup_path)
96
+ /tmp/.mcp-installer-backups/config.json.20250105_143022.backup
97
+ """
98
+ if not path.exists():
99
+ raise BackupError(f"Cannot backup non-existent file: {path}")
100
+
101
+ # Create backup directory
102
+ backup_dir = path.parent / ".mcp-installer-backups"
103
+ try:
104
+ backup_dir.mkdir(exist_ok=True)
105
+ except Exception as e:
106
+ raise BackupError(f"Failed to create backup directory: {e}") from e
107
+
108
+ # Generate timestamped backup filename
109
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
110
+ backup_name = f"{path.name}.{timestamp}.backup"
111
+ backup_path = backup_dir / backup_name
112
+
113
+ # Copy file to backup
114
+ try:
115
+ shutil.copy2(path, backup_path)
116
+ except Exception as e:
117
+ raise BackupError(f"Failed to copy file to backup: {e}") from e
118
+
119
+ return backup_path
120
+
121
+
122
+ def restore_backup(backup_path: Path, original_path: Path) -> None:
123
+ """Restore file from backup.
124
+
125
+ Args:
126
+ backup_path: Path to backup file
127
+ original_path: Path to restore to
128
+
129
+ Raises:
130
+ BackupError: If restore fails
131
+
132
+ Example:
133
+ >>> restore_backup(
134
+ ... Path("/tmp/.mcp-installer-backups/config.json.20250105_143022.backup"),
135
+ ... Path("/tmp/config.json")
136
+ ... )
137
+ """
138
+ if not backup_path.exists():
139
+ raise BackupError(f"Backup file not found: {backup_path}")
140
+
141
+ try:
142
+ shutil.copy2(backup_path, original_path)
143
+ except Exception as e:
144
+ raise BackupError(f"Failed to restore from backup: {e}") from e
145
+
146
+
147
+ # ============================================================================
148
+ # Safe Parsing (JSON/TOML with Error Recovery)
149
+ # ============================================================================
150
+
151
+
152
+ def parse_json_safe(path: Path) -> dict[str, Any]:
153
+ """Parse JSON file with graceful error handling.
154
+
155
+ Returns empty dict if file doesn't exist or is empty.
156
+ Raises ConfigurationError if file is invalid JSON.
157
+
158
+ Args:
159
+ path: Path to JSON file
160
+
161
+ Returns:
162
+ Parsed JSON as dictionary (empty dict if file doesn't exist)
163
+
164
+ Raises:
165
+ ConfigurationError: If file exists but is invalid JSON
166
+
167
+ Example:
168
+ >>> config = parse_json_safe(Path("/tmp/config.json"))
169
+ >>> print(config.get("mcpServers", {}))
170
+ """
171
+ if not path.exists():
172
+ return {}
173
+
174
+ try:
175
+ with path.open("r", encoding="utf-8") as f:
176
+ content = f.read().strip()
177
+
178
+ # Empty file is valid (return empty dict)
179
+ if not content:
180
+ return {}
181
+
182
+ result: dict[str, Any] = json.loads(content)
183
+ return result
184
+
185
+ except json.JSONDecodeError as e:
186
+ raise ConfigurationError(
187
+ f"Invalid JSON in {path}: {e}", config_path=str(path)
188
+ ) from e
189
+ except Exception as e:
190
+ raise ConfigurationError(
191
+ f"Failed to read {path}: {e}", config_path=str(path)
192
+ ) from e
193
+
194
+
195
+ def parse_toml_safe(path: Path) -> dict[str, Any]:
196
+ """Parse TOML file with graceful error handling.
197
+
198
+ Returns empty dict if file doesn't exist or is empty.
199
+ Raises ConfigurationError if file is invalid TOML.
200
+
201
+ Args:
202
+ path: Path to TOML file
203
+
204
+ Returns:
205
+ Parsed TOML as dictionary (empty dict if file doesn't exist)
206
+
207
+ Raises:
208
+ ConfigurationError: If file exists but is invalid TOML
209
+
210
+ Example:
211
+ >>> config = parse_toml_safe(Path("/tmp/config.toml"))
212
+ >>> print(config.get("mcp_servers", {}))
213
+ """
214
+ if not path.exists():
215
+ return {}
216
+
217
+ try:
218
+ # Python 3.11+ has tomllib in stdlib
219
+ try:
220
+ import tomllib # type: ignore[import-untyped]
221
+ except ImportError:
222
+ import tomli as tomllib # type: ignore[import-untyped,unused-ignore]
223
+
224
+ with path.open("rb") as f:
225
+ content = f.read()
226
+
227
+ # Empty file is valid (return empty dict)
228
+ if not content:
229
+ return {}
230
+
231
+ result: dict[str, Any] = tomllib.loads(content.decode("utf-8"))
232
+ return result
233
+
234
+ except Exception as e:
235
+ raise ConfigurationError(
236
+ f"Invalid TOML in {path}: {e}", config_path=str(path)
237
+ ) from e
238
+
239
+
240
+ # ============================================================================
241
+ # Credential Masking (Security)
242
+ # ============================================================================
243
+
244
+
245
+ def mask_credentials(data: dict[str, Any]) -> dict[str, Any]:
246
+ """Recursively mask sensitive values in dictionary for logging.
247
+
248
+ Masks any keys containing: API_KEY, TOKEN, SECRET, PASSWORD, CREDENTIALS, AUTH
249
+
250
+ Args:
251
+ data: Dictionary potentially containing sensitive data
252
+
253
+ Returns:
254
+ New dictionary with sensitive values masked as "***"
255
+
256
+ Example:
257
+ >>> masked = mask_credentials({
258
+ ... "API_KEY": "secret123",
259
+ ... "DEBUG": "true"
260
+ ... })
261
+ >>> print(masked)
262
+ {'API_KEY': '***', 'DEBUG': 'true'}
263
+ """
264
+ sensitive_keywords = {
265
+ "API_KEY",
266
+ "TOKEN",
267
+ "SECRET",
268
+ "PASSWORD",
269
+ "CREDENTIALS",
270
+ "AUTH",
271
+ "KEY",
272
+ }
273
+
274
+ def is_sensitive(key: str) -> bool:
275
+ """Check if key name suggests sensitive data."""
276
+ key_upper = key.upper()
277
+ return any(keyword in key_upper for keyword in sensitive_keywords)
278
+
279
+ def mask_value(key: str, value: Any) -> Any:
280
+ """Recursively mask sensitive values."""
281
+ if isinstance(value, dict):
282
+ return {k: mask_value(k, v) for k, v in value.items()}
283
+ elif isinstance(value, list):
284
+ return [mask_value(key, item) for item in value]
285
+ elif is_sensitive(key):
286
+ return "***"
287
+ else:
288
+ return value
289
+
290
+ return {k: mask_value(k, v) for k, v in data.items()}
291
+
292
+
293
+ # ============================================================================
294
+ # Command Resolution (PATH lookups)
295
+ # ============================================================================
296
+
297
+
298
+ def resolve_command_path(command: str) -> Path | None:
299
+ """Find command in PATH and return absolute path.
300
+
301
+ Args:
302
+ command: Command name to find (e.g., "uv", "mcp-ticketer")
303
+
304
+ Returns:
305
+ Absolute path to command if found, None otherwise
306
+
307
+ Example:
308
+ >>> path = resolve_command_path("python")
309
+ >>> print(path)
310
+ /usr/bin/python
311
+ """
312
+ found = shutil.which(command)
313
+ return Path(found) if found else None
314
+
315
+
316
+ def detect_install_method(package: str) -> str:
317
+ """Detect how a Python package is installed.
318
+
319
+ Checks in order:
320
+ 1. pipx (in ~/.local/bin or ~/.local/pipx/venvs/)
321
+ 2. pip (via pip show)
322
+ 3. Not installed
323
+
324
+ Args:
325
+ package: Package name (e.g., "mcp-ticketer")
326
+
327
+ Returns:
328
+ "pipx", "pip", or "not_installed"
329
+
330
+ Example:
331
+ >>> method = detect_install_method("mcp-ticketer")
332
+ >>> print(method)
333
+ pipx
334
+ """
335
+ # Check if pipx installed
336
+ pipx_path = Path.home() / ".local" / "pipx" / "venvs" / package
337
+ if pipx_path.exists():
338
+ return "pipx"
339
+
340
+ # Check if pip installed
341
+ try:
342
+ import subprocess
343
+
344
+ result = subprocess.run(
345
+ ["pip", "show", package],
346
+ capture_output=True,
347
+ text=True,
348
+ timeout=5,
349
+ )
350
+ if result.returncode == 0:
351
+ return "pip"
352
+ except Exception:
353
+ pass
354
+
355
+ return "not_installed"
356
+
357
+
358
+ # ============================================================================
359
+ # Validation Helpers
360
+ # ============================================================================
361
+
362
+
363
+ def validate_json_structure(data: dict[str, Any], path: Path) -> list[str]:
364
+ """Validate MCP config JSON structure.
365
+
366
+ Checks for:
367
+ - mcpServers key exists
368
+ - mcpServers is a dictionary
369
+ - Each server has required fields (command)
370
+
371
+ Args:
372
+ data: Parsed JSON config
373
+ path: Path to config file (for error messages)
374
+
375
+ Returns:
376
+ List of validation errors (empty if valid)
377
+
378
+ Example:
379
+ >>> errors = validate_json_structure(
380
+ ... {"mcpServers": {"test": {"command": "test"}}},
381
+ ... Path("/tmp/config.json")
382
+ ... )
383
+ >>> print(errors)
384
+ []
385
+ """
386
+ errors: list[str] = []
387
+
388
+ # Check for mcpServers key
389
+ if "mcpServers" not in data:
390
+ errors.append("Missing 'mcpServers' key")
391
+ return errors
392
+
393
+ servers = data["mcpServers"]
394
+ if not isinstance(servers, dict):
395
+ errors.append("'mcpServers' must be a dictionary")
396
+ return errors
397
+
398
+ # Validate each server
399
+ for server_name, server_config in servers.items():
400
+ if not isinstance(server_config, dict):
401
+ errors.append(f"Server '{server_name}' config must be a dictionary")
402
+ continue
403
+
404
+ if "command" not in server_config:
405
+ errors.append(f"Server '{server_name}' missing 'command' field")
406
+
407
+ if "args" in server_config and not isinstance(server_config["args"], list):
408
+ errors.append(f"Server '{server_name}' 'args' must be a list")
409
+
410
+ if "env" in server_config and not isinstance(server_config["env"], dict):
411
+ errors.append(f"Server '{server_name}' 'env' must be a dictionary")
412
+
413
+ return errors
414
+
415
+
416
+ def validate_toml_structure(data: dict[str, Any], path: Path) -> list[str]:
417
+ """Validate MCP config TOML structure.
418
+
419
+ Checks for:
420
+ - mcp_servers key exists (snake_case for TOML)
421
+ - mcp_servers is a dictionary
422
+ - Each server has required fields (command)
423
+
424
+ Args:
425
+ data: Parsed TOML config
426
+ path: Path to config file (for error messages)
427
+
428
+ Returns:
429
+ List of validation errors (empty if valid)
430
+
431
+ Example:
432
+ >>> errors = validate_toml_structure(
433
+ ... {"mcp_servers": {"test": {"command": "test"}}},
434
+ ... Path("/tmp/config.toml")
435
+ ... )
436
+ >>> print(errors)
437
+ []
438
+ """
439
+ errors: list[str] = []
440
+
441
+ # Check for mcp_servers key (TOML uses snake_case)
442
+ if "mcp_servers" not in data:
443
+ errors.append("Missing 'mcp_servers' key")
444
+ return errors
445
+
446
+ servers = data["mcp_servers"]
447
+ if not isinstance(servers, dict):
448
+ errors.append("'mcp_servers' must be a table")
449
+ return errors
450
+
451
+ # Validate each server
452
+ for server_name, server_config in servers.items():
453
+ if not isinstance(server_config, dict):
454
+ errors.append(f"Server '{server_name}' config must be a table")
455
+ continue
456
+
457
+ if "command" not in server_config:
458
+ errors.append(f"Server '{server_name}' missing 'command' field")
459
+
460
+ if "args" in server_config and not isinstance(server_config["args"], list):
461
+ errors.append(f"Server '{server_name}' 'args' must be an array")
462
+
463
+ return errors
File without changes
File without changes
@@ -0,0 +1,17 @@
1
+ """Tests for platform detector."""
2
+
3
+
4
+ from py_mcp_installer.platform_detector import PlatformDetector
5
+ from py_mcp_installer.types import Platform
6
+
7
+
8
+ def test_detect_returns_platform():
9
+ """Test that detect returns a valid Platform."""
10
+ result = PlatformDetector.detect()
11
+ assert isinstance(result, Platform)
12
+
13
+
14
+ def test_get_config_path():
15
+ """Test config path retrieval."""
16
+ path = PlatformDetector.get_config_path(Platform.CLAUDE_CODE)
17
+ assert path is not None or Platform.CLAUDE_CODE == Platform.UNKNOWN