mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +58 -16
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +91 -54
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1544
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -2030
- mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
- mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.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
|