mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 (109) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +796 -46
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +879 -129
  11. mcp_ticketer/adapters/hybrid.py +11 -11
  12. mcp_ticketer/adapters/jira.py +973 -73
  13. mcp_ticketer/adapters/linear/__init__.py +24 -0
  14. mcp_ticketer/adapters/linear/adapter.py +2732 -0
  15. mcp_ticketer/adapters/linear/client.py +344 -0
  16. mcp_ticketer/adapters/linear/mappers.py +420 -0
  17. mcp_ticketer/adapters/linear/queries.py +479 -0
  18. mcp_ticketer/adapters/linear/types.py +360 -0
  19. mcp_ticketer/adapters/linear.py +10 -2315
  20. mcp_ticketer/analysis/__init__.py +23 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/similarity.py +224 -0
  23. mcp_ticketer/analysis/staleness.py +266 -0
  24. mcp_ticketer/cache/memory.py +9 -8
  25. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  26. mcp_ticketer/cli/auggie_configure.py +116 -15
  27. mcp_ticketer/cli/codex_configure.py +274 -82
  28. mcp_ticketer/cli/configure.py +888 -151
  29. mcp_ticketer/cli/diagnostics.py +400 -157
  30. mcp_ticketer/cli/discover.py +297 -26
  31. mcp_ticketer/cli/gemini_configure.py +119 -26
  32. mcp_ticketer/cli/init_command.py +880 -0
  33. mcp_ticketer/cli/instruction_commands.py +435 -0
  34. mcp_ticketer/cli/linear_commands.py +616 -0
  35. mcp_ticketer/cli/main.py +203 -1165
  36. mcp_ticketer/cli/mcp_configure.py +474 -90
  37. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  38. mcp_ticketer/cli/migrate_config.py +12 -8
  39. mcp_ticketer/cli/platform_commands.py +123 -0
  40. mcp_ticketer/cli/platform_detection.py +418 -0
  41. mcp_ticketer/cli/platform_installer.py +513 -0
  42. mcp_ticketer/cli/python_detection.py +126 -0
  43. mcp_ticketer/cli/queue_commands.py +15 -15
  44. mcp_ticketer/cli/setup_command.py +639 -0
  45. mcp_ticketer/cli/simple_health.py +90 -65
  46. mcp_ticketer/cli/ticket_commands.py +1013 -0
  47. mcp_ticketer/cli/update_checker.py +313 -0
  48. mcp_ticketer/cli/utils.py +114 -66
  49. mcp_ticketer/core/__init__.py +24 -1
  50. mcp_ticketer/core/adapter.py +250 -16
  51. mcp_ticketer/core/config.py +145 -37
  52. mcp_ticketer/core/env_discovery.py +101 -22
  53. mcp_ticketer/core/env_loader.py +349 -0
  54. mcp_ticketer/core/exceptions.py +160 -0
  55. mcp_ticketer/core/http_client.py +26 -26
  56. mcp_ticketer/core/instructions.py +405 -0
  57. mcp_ticketer/core/label_manager.py +732 -0
  58. mcp_ticketer/core/mappers.py +42 -30
  59. mcp_ticketer/core/models.py +280 -28
  60. mcp_ticketer/core/onepassword_secrets.py +379 -0
  61. mcp_ticketer/core/project_config.py +183 -49
  62. mcp_ticketer/core/registry.py +3 -3
  63. mcp_ticketer/core/session_state.py +171 -0
  64. mcp_ticketer/core/state_matcher.py +592 -0
  65. mcp_ticketer/core/url_parser.py +425 -0
  66. mcp_ticketer/core/validators.py +69 -0
  67. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  68. mcp_ticketer/mcp/__init__.py +29 -1
  69. mcp_ticketer/mcp/__main__.py +60 -0
  70. mcp_ticketer/mcp/server/__init__.py +25 -0
  71. mcp_ticketer/mcp/server/__main__.py +60 -0
  72. mcp_ticketer/mcp/server/constants.py +58 -0
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/dto.py +195 -0
  75. mcp_ticketer/mcp/server/main.py +1343 -0
  76. mcp_ticketer/mcp/server/response_builder.py +206 -0
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +56 -0
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
  90. mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
  91. mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
  92. mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
  93. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
  94. mcp_ticketer/queue/__init__.py +1 -0
  95. mcp_ticketer/queue/health_monitor.py +168 -136
  96. mcp_ticketer/queue/manager.py +95 -25
  97. mcp_ticketer/queue/queue.py +40 -21
  98. mcp_ticketer/queue/run_worker.py +6 -1
  99. mcp_ticketer/queue/ticket_registry.py +213 -155
  100. mcp_ticketer/queue/worker.py +109 -49
  101. mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
  102. mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
  103. mcp_ticketer/mcp/server.py +0 -1895
  104. mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
  105. mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
  106. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
  107. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
  108. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
  109. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1439 @@
1
+ """Configuration management tools for MCP ticketer.
2
+
3
+ This module provides tools for managing project-local configuration including
4
+ default adapter, project, and user settings. All configuration is stored in
5
+ .mcp-ticketer/config.json within the project root.
6
+
7
+ Design Decision: Project-Local Configuration Only
8
+ -------------------------------------------------
9
+ For security and isolation, this module ONLY manages project-local configuration
10
+ stored in .mcp-ticketer/config.json. It never reads from or writes to user home
11
+ directory or system-wide locations to prevent configuration leakage across projects.
12
+
13
+ Configuration stored:
14
+ - default_adapter: Primary adapter to use for ticket operations
15
+ - default_project: Default epic/project ID for new tickets
16
+ - default_user: Default assignee for new tickets (user_id or email)
17
+ - default_epic: Alias for default_project (backward compatibility)
18
+
19
+ Error Handling:
20
+ - All tools validate input before modifying configuration
21
+ - Adapter names are validated against AdapterRegistry
22
+ - Configuration file is created atomically to prevent corruption
23
+ - Detailed error messages for invalid configurations
24
+
25
+ Performance: Configuration is cached in memory by ConfigResolver,
26
+ so repeated reads are fast (O(1) after first load).
27
+ """
28
+
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from ....core.project_config import (
33
+ AdapterType,
34
+ ConfigResolver,
35
+ ConfigValidator,
36
+ TicketerConfig,
37
+ )
38
+ from ....core.registry import AdapterRegistry
39
+ from ..server_sdk import mcp
40
+
41
+
42
+ def get_resolver() -> ConfigResolver:
43
+ """Get or create the configuration resolver.
44
+
45
+ Returns:
46
+ ConfigResolver instance for current working directory
47
+
48
+ Design Decision: Uses CWD as project root, assuming MCP server
49
+ is started from project directory. This matches user expectations
50
+ and aligns with how other development tools operate.
51
+
52
+ Note: Creates a new resolver each time to avoid caching issues
53
+ in tests and ensure current working directory is always used.
54
+
55
+ """
56
+ return ConfigResolver(project_path=Path.cwd())
57
+
58
+
59
+ @mcp.tool()
60
+ async def config_set_primary_adapter(adapter: str) -> dict[str, Any]:
61
+ """Set the default adapter for ticket operations.
62
+
63
+ Updates the project-local configuration (.mcp-ticketer/config.json)
64
+ to use the specified adapter as the default for all ticket operations.
65
+
66
+ Args:
67
+ adapter: Adapter name to set as primary. Must be one of:
68
+ - "aitrackdown" (file-based tracking)
69
+ - "linear" (Linear.app)
70
+ - "github" (GitHub Issues)
71
+ - "jira" (Atlassian JIRA)
72
+
73
+ Returns:
74
+ Dictionary containing:
75
+ - status: "completed" or "error"
76
+ - message: Success or error message
77
+ - previous_adapter: Previous default adapter (if successful)
78
+ - new_adapter: New default adapter (if successful)
79
+ - error: Error details (if failed)
80
+
81
+ Example:
82
+ >>> result = await config_set_primary_adapter("linear")
83
+ >>> print(result)
84
+ {
85
+ "status": "completed",
86
+ "message": "Default adapter set to 'linear'",
87
+ "previous_adapter": "aitrackdown",
88
+ "new_adapter": "linear"
89
+ }
90
+
91
+ Error Conditions:
92
+ - Invalid adapter name: Returns error with valid options
93
+ - Configuration file write failure: Returns error with file path
94
+
95
+ """
96
+ try:
97
+ # Validate adapter name against registry
98
+ valid_adapters = [adapter_type.value for adapter_type in AdapterType]
99
+ if adapter.lower() not in valid_adapters:
100
+ return {
101
+ "status": "error",
102
+ "error": f"Invalid adapter '{adapter}'. Must be one of: {', '.join(valid_adapters)}",
103
+ "valid_adapters": valid_adapters,
104
+ }
105
+
106
+ # Load current configuration
107
+ resolver = get_resolver()
108
+ config = resolver.load_project_config() or TicketerConfig()
109
+
110
+ # Store previous adapter for response
111
+ previous_adapter = config.default_adapter
112
+
113
+ # Update default adapter
114
+ config.default_adapter = adapter.lower()
115
+
116
+ # Save configuration
117
+ resolver.save_project_config(config)
118
+
119
+ return {
120
+ "status": "completed",
121
+ "message": f"Default adapter set to '{adapter.lower()}'",
122
+ "previous_adapter": previous_adapter,
123
+ "new_adapter": adapter.lower(),
124
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
125
+ }
126
+ except Exception as e:
127
+ return {
128
+ "status": "error",
129
+ "error": f"Failed to set default adapter: {str(e)}",
130
+ }
131
+
132
+
133
+ @mcp.tool()
134
+ async def config_set_default_project(
135
+ project_id: str,
136
+ project_key: str | None = None,
137
+ ) -> dict[str, Any]:
138
+ """Set the default project/epic for new tickets.
139
+
140
+ Updates the project-local configuration to automatically assign new tickets
141
+ to the specified project or epic. This is useful for teams working primarily
142
+ on a single project or feature area.
143
+
144
+ Args:
145
+ project_id: Project or epic ID to set as default (required)
146
+ project_key: Optional project key (for adapters that use keys vs IDs)
147
+
148
+ Returns:
149
+ Dictionary containing:
150
+ - status: "completed" or "error"
151
+ - message: Success or error message
152
+ - previous_project: Previous default project (if any)
153
+ - new_project: New default project ID
154
+ - error: Error details (if failed)
155
+
156
+ Example:
157
+ >>> result = await config_set_default_project("PROJ-123")
158
+ >>> print(result)
159
+ {
160
+ "status": "completed",
161
+ "message": "Default project set to 'PROJ-123'",
162
+ "previous_project": None,
163
+ "new_project": "PROJ-123"
164
+ }
165
+
166
+ Usage Notes:
167
+ - This sets both default_project and default_epic (for backward compatibility)
168
+ - Empty string or null clears the default project
169
+ - Project ID is not validated (allows flexibility across adapters)
170
+
171
+ """
172
+ try:
173
+ # Load current configuration
174
+ resolver = get_resolver()
175
+ config = resolver.load_project_config() or TicketerConfig()
176
+
177
+ # Store previous project for response
178
+ previous_project = config.default_project or config.default_epic
179
+
180
+ # Update default project (and epic for backward compat)
181
+ config.default_project = project_id if project_id else None
182
+ config.default_epic = project_id if project_id else None
183
+
184
+ # Save configuration
185
+ resolver.save_project_config(config)
186
+
187
+ return {
188
+ "status": "completed",
189
+ "message": (
190
+ f"Default project set to '{project_id}'"
191
+ if project_id
192
+ else "Default project cleared"
193
+ ),
194
+ "previous_project": previous_project,
195
+ "new_project": project_id,
196
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
197
+ }
198
+ except Exception as e:
199
+ return {
200
+ "status": "error",
201
+ "error": f"Failed to set default project: {str(e)}",
202
+ }
203
+
204
+
205
+ @mcp.tool()
206
+ async def config_set_default_user(
207
+ user_id: str,
208
+ user_email: str | None = None,
209
+ ) -> dict[str, Any]:
210
+ """Set the default assignee for new tickets.
211
+
212
+ Updates the project-local configuration to automatically assign new tickets
213
+ to the specified user. Supports both user IDs and email addresses depending
214
+ on adapter requirements.
215
+
216
+ Args:
217
+ user_id: User identifier or email to set as default assignee (required)
218
+ user_email: Optional email (for adapters that require separate email field)
219
+
220
+ Returns:
221
+ Dictionary containing:
222
+ - status: "completed" or "error"
223
+ - message: Success or error message
224
+ - previous_user: Previous default user (if any)
225
+ - new_user: New default user ID
226
+ - error: Error details (if failed)
227
+
228
+ Example:
229
+ >>> result = await config_set_default_user("user123")
230
+ >>> print(result)
231
+ {
232
+ "status": "completed",
233
+ "message": "Default user set to 'user123'",
234
+ "previous_user": None,
235
+ "new_user": "user123"
236
+ }
237
+
238
+ Example with email:
239
+ >>> result = await config_set_default_user("user@example.com")
240
+ >>> print(result)
241
+ {
242
+ "status": "completed",
243
+ "message": "Default user set to 'user@example.com'",
244
+ "previous_user": "old_user@example.com",
245
+ "new_user": "user@example.com"
246
+ }
247
+
248
+ Usage Notes:
249
+ - User ID/email is not validated (allows flexibility across adapters)
250
+ - Empty string or null clears the default user
251
+ - Some adapters prefer email, others prefer user UUID
252
+
253
+ """
254
+ try:
255
+ # Load current configuration
256
+ resolver = get_resolver()
257
+ config = resolver.load_project_config() or TicketerConfig()
258
+
259
+ # Store previous user for response
260
+ previous_user = config.default_user
261
+
262
+ # Update default user
263
+ config.default_user = user_id if user_id else None
264
+
265
+ # Save configuration
266
+ resolver.save_project_config(config)
267
+
268
+ return {
269
+ "status": "completed",
270
+ "message": (
271
+ f"Default user set to '{user_id}'"
272
+ if user_id
273
+ else "Default user cleared"
274
+ ),
275
+ "previous_user": previous_user,
276
+ "new_user": user_id,
277
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
278
+ }
279
+ except Exception as e:
280
+ return {
281
+ "status": "error",
282
+ "error": f"Failed to set default user: {str(e)}",
283
+ }
284
+
285
+
286
+ @mcp.tool()
287
+ async def config_get() -> dict[str, Any]:
288
+ """Get current configuration settings.
289
+
290
+ Retrieves the current project-local configuration including default adapter,
291
+ project, user, and all adapter-specific settings.
292
+
293
+ Returns:
294
+ Dictionary containing:
295
+ - status: "completed" or "error"
296
+ - config: Complete configuration dictionary including:
297
+ - default_adapter: Primary adapter name
298
+ - default_project: Default project/epic ID (if set)
299
+ - default_user: Default assignee (if set)
300
+ - adapters: All adapter configurations
301
+ - hybrid_mode: Hybrid mode settings (if enabled)
302
+ - config_path: Path to configuration file
303
+ - error: Error details (if failed)
304
+
305
+ Example:
306
+ >>> result = await config_get()
307
+ >>> print(result)
308
+ {
309
+ "status": "completed",
310
+ "config": {
311
+ "default_adapter": "linear",
312
+ "default_project": "PROJ-123",
313
+ "default_user": "user@example.com",
314
+ "default_tags": ["backend", "api"],
315
+ "adapters": {
316
+ "linear": {"api_key": "***", "team_id": "..."}
317
+ }
318
+ },
319
+ "config_path": "/project/.mcp-ticketer/config.json"
320
+ }
321
+
322
+ Usage Notes:
323
+ - Sensitive values (API keys) are masked in the response
324
+ - Returns default values if no configuration file exists
325
+ - Configuration is merged from multiple sources (env vars, .env files, config.json)
326
+ - default_tags returns empty list if not configured
327
+
328
+ """
329
+ try:
330
+ # Load current configuration
331
+ resolver = get_resolver()
332
+ config = resolver.load_project_config() or TicketerConfig()
333
+
334
+ # Convert to dictionary
335
+ config_dict = config.to_dict()
336
+
337
+ # Mask sensitive values (API keys, tokens)
338
+ masked_config = _mask_sensitive_values(config_dict)
339
+
340
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
341
+ config_exists = config_path.exists()
342
+
343
+ return {
344
+ "status": "completed",
345
+ "config": masked_config,
346
+ "config_path": str(config_path),
347
+ "config_exists": config_exists,
348
+ "message": (
349
+ "Configuration retrieved successfully"
350
+ if config_exists
351
+ else "No configuration file found, showing defaults"
352
+ ),
353
+ }
354
+ except Exception as e:
355
+ return {
356
+ "status": "error",
357
+ "error": f"Failed to retrieve configuration: {str(e)}",
358
+ }
359
+
360
+
361
+ @mcp.tool()
362
+ async def config_set_default_tags(
363
+ tags: list[str],
364
+ ) -> dict[str, Any]:
365
+ """Set default tags for new ticket creation.
366
+
367
+ Updates the project-local configuration to automatically apply the specified
368
+ tags to all new tickets. These tags are merged with any tags provided when
369
+ creating a ticket.
370
+
371
+ Args:
372
+ tags: List of default tags to apply to new tickets
373
+
374
+ Returns:
375
+ Dictionary containing:
376
+ - status: "completed" or "error"
377
+ - message: Success or error message
378
+ - default_tags: List of default tags that were set
379
+ - error: Error details (if failed)
380
+
381
+ Example:
382
+ >>> result = await config_set_default_tags(["bug", "urgent"])
383
+ >>> print(result)
384
+ {
385
+ "status": "completed",
386
+ "message": "Default tags set to: bug, urgent",
387
+ "default_tags": ["bug", "urgent"]
388
+ }
389
+
390
+ Usage Notes:
391
+ - Empty list clears the default tags
392
+ - Tags are validated for reasonable length (2-50 characters)
393
+ - Tags are merged with user-provided tags during ticket creation
394
+
395
+ """
396
+ try:
397
+ # Validate tags
398
+ if not tags:
399
+ return {
400
+ "status": "error",
401
+ "error": "Please provide at least one tag",
402
+ }
403
+
404
+ for tag in tags:
405
+ if not tag or len(tag.strip()) < 2:
406
+ return {
407
+ "status": "error",
408
+ "error": f"Tag '{tag}' must be at least 2 characters",
409
+ }
410
+ if len(tag.strip()) > 50:
411
+ return {
412
+ "status": "error",
413
+ "error": f"Tag '{tag}' is too long (max 50 characters)",
414
+ }
415
+
416
+ # Load current configuration
417
+ resolver = get_resolver()
418
+ config = resolver.load_project_config() or TicketerConfig()
419
+
420
+ # Update config
421
+ config.default_tags = [tag.strip() for tag in tags]
422
+ resolver.save_project_config(config)
423
+
424
+ return {
425
+ "status": "completed",
426
+ "default_tags": config.default_tags,
427
+ "message": f"Default tags set to: {', '.join(config.default_tags)}",
428
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
429
+ }
430
+ except Exception as e:
431
+ return {
432
+ "status": "error",
433
+ "error": f"Failed to set default tags: {str(e)}",
434
+ }
435
+
436
+
437
+ @mcp.tool()
438
+ async def config_set_default_team(
439
+ team_id: str,
440
+ ) -> dict[str, Any]:
441
+ """Set the default team for ticket operations.
442
+
443
+ Updates the project-local configuration to automatically scope ticket
444
+ operations to the specified team. This is useful for multi-team platforms
445
+ like Linear where teams have separate workspaces.
446
+
447
+ Args:
448
+ team_id: Team ID or key to set as default (e.g., "ENG", UUID)
449
+
450
+ Returns:
451
+ Dictionary containing:
452
+ - status: "completed" or "error"
453
+ - message: Success or error message
454
+ - previous_team: Previous default team (if any)
455
+ - new_team: New default team ID
456
+ - error: Error details (if failed)
457
+
458
+ Example:
459
+ >>> result = await config_set_default_team("ENG")
460
+ >>> print(result)
461
+ {
462
+ "status": "completed",
463
+ "message": "Default team set to 'ENG'",
464
+ "previous_team": None,
465
+ "new_team": "ENG"
466
+ }
467
+
468
+ Usage Notes:
469
+ - Team ID is not validated (allows flexibility across adapters)
470
+ - Empty string or null clears the default team
471
+ - Helps scope ticket_list and ticket_search operations
472
+
473
+ """
474
+ try:
475
+ # Validate team ID
476
+ if not team_id or len(team_id.strip()) < 1:
477
+ return {
478
+ "status": "error",
479
+ "error": "Team ID must be at least 1 character",
480
+ }
481
+
482
+ # Load current configuration
483
+ resolver = get_resolver()
484
+ config = resolver.load_project_config() or TicketerConfig()
485
+
486
+ # Store previous team for response
487
+ previous_team = config.default_team
488
+
489
+ # Update default team
490
+ config.default_team = team_id.strip()
491
+ resolver.save_project_config(config)
492
+
493
+ return {
494
+ "status": "completed",
495
+ "message": f"Default team set to '{team_id}'",
496
+ "previous_team": previous_team,
497
+ "new_team": config.default_team,
498
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
499
+ }
500
+ except Exception as e:
501
+ return {
502
+ "status": "error",
503
+ "error": f"Failed to set default team: {str(e)}",
504
+ }
505
+
506
+
507
+ @mcp.tool()
508
+ async def config_set_default_cycle(
509
+ cycle_id: str,
510
+ ) -> dict[str, Any]:
511
+ """Set the default cycle/sprint for ticket operations.
512
+
513
+ Updates the project-local configuration to automatically scope ticket
514
+ operations to the specified cycle or sprint. This is useful for platforms
515
+ that support sprint/cycle-based planning.
516
+
517
+ Args:
518
+ cycle_id: Cycle/sprint ID to set as default (e.g., "Sprint 23", UUID)
519
+
520
+ Returns:
521
+ Dictionary containing:
522
+ - status: "completed" or "error"
523
+ - message: Success or error message
524
+ - previous_cycle: Previous default cycle (if any)
525
+ - new_cycle: New default cycle ID
526
+ - error: Error details (if failed)
527
+
528
+ Example:
529
+ >>> result = await config_set_default_cycle("Sprint 23")
530
+ >>> print(result)
531
+ {
532
+ "status": "completed",
533
+ "message": "Default cycle set to 'Sprint 23'",
534
+ "previous_cycle": None,
535
+ "new_cycle": "Sprint 23"
536
+ }
537
+
538
+ Usage Notes:
539
+ - Cycle ID is not validated (allows flexibility across adapters)
540
+ - Empty string or null clears the default cycle
541
+ - Helps scope ticket_list and ticket_search operations
542
+
543
+ """
544
+ try:
545
+ # Validate cycle ID
546
+ if not cycle_id or len(cycle_id.strip()) < 1:
547
+ return {
548
+ "status": "error",
549
+ "error": "Cycle ID must be at least 1 character",
550
+ }
551
+
552
+ # Load current configuration
553
+ resolver = get_resolver()
554
+ config = resolver.load_project_config() or TicketerConfig()
555
+
556
+ # Store previous cycle for response
557
+ previous_cycle = config.default_cycle
558
+
559
+ # Update default cycle
560
+ config.default_cycle = cycle_id.strip()
561
+ resolver.save_project_config(config)
562
+
563
+ return {
564
+ "status": "completed",
565
+ "message": f"Default cycle set to '{cycle_id}'",
566
+ "previous_cycle": previous_cycle,
567
+ "new_cycle": config.default_cycle,
568
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
569
+ }
570
+ except Exception as e:
571
+ return {
572
+ "status": "error",
573
+ "error": f"Failed to set default cycle: {str(e)}",
574
+ }
575
+
576
+
577
+ @mcp.tool()
578
+ async def config_set_default_epic(
579
+ epic_id: str,
580
+ ) -> dict[str, Any]:
581
+ """Set default epic/project for new ticket creation.
582
+
583
+ Updates the project-local configuration to automatically assign new tickets
584
+ to the specified epic or project. This is an alias for config_set_default_project
585
+ but uses the "epic" terminology which some teams prefer.
586
+
587
+ Args:
588
+ epic_id: Epic or project identifier (e.g., "PROJ-123" or UUID)
589
+
590
+ Returns:
591
+ Dictionary containing:
592
+ - status: "completed" or "error"
593
+ - message: Success or error message
594
+ - default_epic: The epic ID that was set
595
+ - default_project: Same value (for compatibility)
596
+ - error: Error details (if failed)
597
+
598
+ Example:
599
+ >>> result = await config_set_default_epic("PROJ-123")
600
+ >>> print(result)
601
+ {
602
+ "status": "completed",
603
+ "message": "Default epic/project set to: PROJ-123",
604
+ "default_epic": "PROJ-123",
605
+ "default_project": "PROJ-123"
606
+ }
607
+
608
+ Usage Notes:
609
+ - Epic ID is not validated (allows flexibility across adapters)
610
+ - Empty string or null clears the default epic
611
+ - Sets both default_epic and default_project for backward compatibility
612
+
613
+ """
614
+ try:
615
+ # Validate epic ID
616
+ if not epic_id or len(epic_id.strip()) < 2:
617
+ return {
618
+ "status": "error",
619
+ "error": "Epic/project ID must be at least 2 characters",
620
+ }
621
+
622
+ # Load current configuration
623
+ resolver = get_resolver()
624
+ config = resolver.load_project_config() or TicketerConfig()
625
+
626
+ # Update config (set both for compatibility)
627
+ config.default_epic = epic_id.strip()
628
+ config.default_project = epic_id.strip()
629
+ resolver.save_project_config(config)
630
+
631
+ return {
632
+ "status": "completed",
633
+ "default_epic": config.default_epic,
634
+ "default_project": config.default_project,
635
+ "message": f"Default epic/project set to: {epic_id}",
636
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
637
+ }
638
+ except Exception as e:
639
+ return {
640
+ "status": "error",
641
+ "error": f"Failed to set default epic: {str(e)}",
642
+ }
643
+
644
+
645
+ @mcp.tool()
646
+ async def config_set_assignment_labels(labels: list[str]) -> dict[str, Any]:
647
+ """Set labels that indicate ticket assignment to user.
648
+
649
+ Assignment labels are used by the check_open_tickets feature to find
650
+ tickets that should be worked on, in addition to tickets directly
651
+ assigned to the default_user.
652
+
653
+ This is useful for teams that use labels like "assigned-to-me",
654
+ "my-work", "in-progress" to indicate ticket ownership beyond
655
+ formal assignment fields.
656
+
657
+ Args:
658
+ labels: List of label names that indicate ticket is assigned to user.
659
+ Examples: ["assigned-to-me", "my-work", "active-sprint"]
660
+
661
+ Returns:
662
+ Dictionary containing:
663
+ - status: "completed" or "error"
664
+ - message: Success message with label list
665
+ - assignment_labels: The labels that were set
666
+ - config_path: Path to configuration file
667
+ - error: Error details (if failed)
668
+
669
+ Example:
670
+ >>> result = await config_set_assignment_labels(["my-work", "in-progress"])
671
+ >>> print(result)
672
+ {
673
+ "status": "completed",
674
+ "message": "Assignment labels set to: my-work, in-progress",
675
+ "assignment_labels": ["my-work", "in-progress"],
676
+ "config_path": "/path/to/.mcp-ticketer/config.json"
677
+ }
678
+
679
+ Usage Notes:
680
+ - Labels are platform-specific (Linear uses different names than GitHub)
681
+ - Empty list is valid (disables assignment label filtering)
682
+ - Labels are case-sensitive
683
+ - Labels must be 2-50 characters
684
+
685
+ """
686
+ try:
687
+ # Validate label format
688
+ for label in labels:
689
+ if not label or len(label) < 2 or len(label) > 50:
690
+ return {
691
+ "status": "error",
692
+ "error": f"Invalid label '{label}': must be 2-50 characters",
693
+ }
694
+
695
+ resolver = get_resolver()
696
+ config = resolver.load_project_config() or TicketerConfig()
697
+
698
+ config.assignment_labels = labels if labels else None
699
+ resolver.save_project_config(config)
700
+
701
+ config_path = Path.cwd() / ".mcp-ticketer" / "config.json"
702
+
703
+ return {
704
+ "status": "completed",
705
+ "message": (
706
+ f"Assignment labels set to: {', '.join(labels)}"
707
+ if labels
708
+ else "Assignment labels cleared"
709
+ ),
710
+ "assignment_labels": labels,
711
+ "config_path": str(config_path),
712
+ }
713
+ except Exception as e:
714
+ return {
715
+ "status": "error",
716
+ "error": f"Failed to set assignment labels: {str(e)}",
717
+ }
718
+
719
+
720
+ @mcp.tool()
721
+ async def config_validate() -> dict[str, Any]:
722
+ """Validate all adapter configurations without testing connectivity.
723
+
724
+ Performs structural validation of adapter configurations including:
725
+ - Required field presence
726
+ - Field format validation (API keys, URLs, emails)
727
+ - Team ID/key validation
728
+ - Configuration consistency checks
729
+
730
+ Does NOT test actual API connectivity. Use config_test_adapter() for that.
731
+
732
+ Returns:
733
+ Dictionary containing:
734
+ - status: "completed" or "error"
735
+ - validation_results: Dict mapping adapter names to validation status
736
+ - all_valid: Boolean indicating if all configs are valid
737
+ - issues: List of validation errors (empty if all valid)
738
+ - message: Summary message
739
+
740
+ Example:
741
+ >>> result = await config_validate()
742
+ >>> print(result)
743
+ {
744
+ "status": "completed",
745
+ "validation_results": {
746
+ "linear": {"valid": True, "error": None},
747
+ "github": {"valid": False, "error": "GitHub token is missing"}
748
+ },
749
+ "all_valid": False,
750
+ "issues": ["github: GitHub token is missing"]
751
+ }
752
+
753
+ """
754
+ try:
755
+ resolver = get_resolver()
756
+ config = resolver.load_project_config() or TicketerConfig()
757
+
758
+ if not config.adapters:
759
+ return {
760
+ "status": "completed",
761
+ "validation_results": {},
762
+ "all_valid": True,
763
+ "issues": [],
764
+ "message": "No adapters configured",
765
+ }
766
+
767
+ results = {}
768
+ issues = []
769
+
770
+ for adapter_name, adapter_config in config.adapters.items():
771
+ is_valid, error = ConfigValidator.validate(
772
+ adapter_name, adapter_config.to_dict()
773
+ )
774
+
775
+ results[adapter_name] = {
776
+ "valid": is_valid,
777
+ "error": error,
778
+ }
779
+
780
+ if not is_valid:
781
+ issues.append(f"{adapter_name}: {error}")
782
+
783
+ return {
784
+ "status": "completed",
785
+ "validation_results": results,
786
+ "all_valid": len(issues) == 0,
787
+ "issues": issues,
788
+ "message": (
789
+ "All configurations valid"
790
+ if len(issues) == 0
791
+ else f"Found {len(issues)} validation issue(s)"
792
+ ),
793
+ }
794
+ except Exception as e:
795
+ return {
796
+ "status": "error",
797
+ "error": f"Failed to validate configuration: {str(e)}",
798
+ }
799
+
800
+
801
+ @mcp.tool()
802
+ async def config_test_adapter(adapter_name: str) -> dict[str, Any]:
803
+ """Test connectivity for a specific adapter.
804
+
805
+ Performs actual API connectivity test by:
806
+ 1. Loading adapter configuration
807
+ 2. Initializing adapter with credentials
808
+ 3. Making test API call (list operation)
809
+ 4. Reporting success or specific error
810
+
811
+ Args:
812
+ adapter_name: Name of adapter to test (linear, github, jira, aitrackdown)
813
+
814
+ Returns:
815
+ Dictionary containing:
816
+ - status: "completed" or "error"
817
+ - adapter: Adapter name
818
+ - healthy: Boolean indicating if test passed
819
+ - message: Success or error message
820
+ - error_type: Type of error (if failed)
821
+
822
+ Example:
823
+ >>> result = await config_test_adapter("linear")
824
+ >>> print(result)
825
+ {
826
+ "status": "completed",
827
+ "adapter": "linear",
828
+ "healthy": True,
829
+ "message": "Adapter initialized and API call successful"
830
+ }
831
+
832
+ Error Conditions:
833
+ - Adapter not configured: Returns error with available adapters
834
+ - Invalid credentials: Returns healthy=False with specific error
835
+ - Network issues: Returns healthy=False with connection error
836
+
837
+ """
838
+ try:
839
+ # Import diagnostic tool
840
+ from .diagnostic_tools import check_adapter_health
841
+
842
+ # Validate adapter name
843
+ valid_adapters = [adapter_type.value for adapter_type in AdapterType]
844
+ if adapter_name.lower() not in valid_adapters:
845
+ return {
846
+ "status": "error",
847
+ "error": f"Invalid adapter '{adapter_name}'",
848
+ "valid_adapters": valid_adapters,
849
+ }
850
+
851
+ # Use existing health check infrastructure
852
+ result = await check_adapter_health(adapter_name=adapter_name)
853
+
854
+ if result["status"] == "error":
855
+ return result
856
+
857
+ # Extract adapter-specific result
858
+ adapter_result = result["adapters"][adapter_name]
859
+
860
+ return {
861
+ "status": "completed",
862
+ "adapter": adapter_name,
863
+ "healthy": adapter_result["status"] == "healthy",
864
+ "message": adapter_result.get("message") or adapter_result.get("error"),
865
+ "error_type": adapter_result.get("error_type"),
866
+ }
867
+ except Exception as e:
868
+ return {
869
+ "status": "error",
870
+ "error": f"Failed to test adapter: {str(e)}",
871
+ }
872
+
873
+
874
+ @mcp.tool()
875
+ async def config_list_adapters() -> dict[str, Any]:
876
+ """List all available adapters with configuration status.
877
+
878
+ Returns information about all supported adapters including:
879
+ - Which adapters are available
880
+ - Which are currently configured
881
+ - Which is the default adapter
882
+ - Adapter metadata and descriptions
883
+
884
+ Returns:
885
+ Dictionary containing:
886
+ - status: "completed" or "error"
887
+ - adapters: List of adapter info dictionaries
888
+ - default_adapter: Current default adapter name
889
+ - total_configured: Count of configured adapters
890
+ - error: Error details (if failed)
891
+
892
+ Example:
893
+ >>> result = await config_list_adapters()
894
+ >>> print(result)
895
+ {
896
+ "status": "completed",
897
+ "adapters": [
898
+ {
899
+ "type": "linear",
900
+ "name": "Linear",
901
+ "configured": True,
902
+ "is_default": True,
903
+ "description": "Linear issue tracking"
904
+ },
905
+ {
906
+ "type": "github",
907
+ "name": "Github",
908
+ "configured": False,
909
+ "is_default": False,
910
+ "description": "GitHub Issues"
911
+ }
912
+ ],
913
+ "default_adapter": "linear",
914
+ "total_configured": 1
915
+ }
916
+
917
+ Usage Notes:
918
+ - Adapters are considered configured if they exist in config.adapters
919
+ - Default adapter is determined by config.default_adapter
920
+ - Adapter descriptions are static (not from API)
921
+
922
+ """
923
+ try:
924
+ # Get all registered adapters from registry
925
+ available_adapters = AdapterRegistry.list_adapters()
926
+
927
+ # Load project config to check which are configured
928
+ resolver = get_resolver()
929
+ config = resolver.load_project_config() or TicketerConfig()
930
+
931
+ # Map of adapter type to human-readable descriptions
932
+ adapter_descriptions = {
933
+ "linear": "Linear issue tracking",
934
+ "github": "GitHub Issues",
935
+ "jira": "Atlassian JIRA",
936
+ "aitrackdown": "File-based ticket tracking",
937
+ "asana": "Asana project management",
938
+ }
939
+
940
+ # Build adapter list with status
941
+ adapters = []
942
+ for adapter_type, _adapter_class in available_adapters.items():
943
+ # Check if this adapter is configured
944
+ is_configured = adapter_type in config.adapters
945
+ is_default = config.default_adapter == adapter_type
946
+
947
+ # Get display name from adapter class
948
+ # Create temporary instance to get display name
949
+ try:
950
+ # Use adapter_type.title() as fallback for display name
951
+ display_name = adapter_type.title()
952
+ except Exception:
953
+ display_name = adapter_type.title()
954
+
955
+ adapters.append(
956
+ {
957
+ "type": adapter_type,
958
+ "name": display_name,
959
+ "configured": is_configured,
960
+ "is_default": is_default,
961
+ "description": adapter_descriptions.get(
962
+ adapter_type, f"{display_name} adapter"
963
+ ),
964
+ }
965
+ )
966
+
967
+ # Sort adapters: configured first, then by name
968
+ adapters.sort(key=lambda x: (not x["configured"], x["type"]))
969
+
970
+ total_configured = sum(1 for a in adapters if a["configured"])
971
+
972
+ return {
973
+ "status": "completed",
974
+ "adapters": adapters,
975
+ "default_adapter": config.default_adapter,
976
+ "total_configured": total_configured,
977
+ "message": (
978
+ f"{total_configured} adapter(s) configured"
979
+ if total_configured > 0
980
+ else "No adapters configured"
981
+ ),
982
+ }
983
+ except Exception as e:
984
+ return {
985
+ "status": "error",
986
+ "error": f"Failed to list adapters: {str(e)}",
987
+ }
988
+
989
+
990
+ @mcp.tool()
991
+ async def config_get_adapter_requirements(adapter: str) -> dict[str, Any]:
992
+ """Get configuration requirements for a specific adapter.
993
+
994
+ Returns required and optional configuration fields for the specified
995
+ adapter, including field types, descriptions, validation patterns,
996
+ and environment variable names.
997
+
998
+ Args:
999
+ adapter: Adapter name (linear, github, jira, aitrackdown, asana)
1000
+
1001
+ Returns:
1002
+ Dictionary containing:
1003
+ - status: "completed" or "error"
1004
+ - adapter: Adapter name
1005
+ - requirements: Dict of field name to field spec
1006
+ - error: Error details (if failed)
1007
+
1008
+ Example:
1009
+ >>> result = await config_get_adapter_requirements("linear")
1010
+ >>> print(result)
1011
+ {
1012
+ "status": "completed",
1013
+ "adapter": "linear",
1014
+ "requirements": {
1015
+ "api_key": {
1016
+ "type": "string",
1017
+ "required": True,
1018
+ "description": "Linear API key",
1019
+ "env_var": "LINEAR_API_KEY",
1020
+ "validation": "^lin_api_[a-zA-Z0-9]{40}$"
1021
+ },
1022
+ "team_key": {
1023
+ "type": "string",
1024
+ "required": True,
1025
+ "description": "Team key (e.g., 'ENG') or team_id (UUID)",
1026
+ "env_var": "LINEAR_TEAM_KEY"
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ Usage Notes:
1032
+ - Requirements are based on ConfigValidator validation logic
1033
+ - env_var shows which environment variable can provide the value
1034
+ - validation patterns are regex strings (when applicable)
1035
+ - Some adapters accept alternative field names (aliases)
1036
+
1037
+ """
1038
+ try:
1039
+ # Validate adapter name
1040
+ valid_adapters = [adapter_type.value for adapter_type in AdapterType]
1041
+ if adapter.lower() not in valid_adapters:
1042
+ return {
1043
+ "status": "error",
1044
+ "error": f"Invalid adapter '{adapter}'. Must be one of: {', '.join(valid_adapters)}",
1045
+ "valid_adapters": valid_adapters,
1046
+ }
1047
+
1048
+ adapter_type = adapter.lower()
1049
+
1050
+ # Define requirements for each adapter based on ConfigValidator logic
1051
+ requirements_map = {
1052
+ "linear": {
1053
+ "api_key": {
1054
+ "type": "string",
1055
+ "required": True,
1056
+ "description": "Linear API key (get from Linear Settings > API)",
1057
+ "env_var": "LINEAR_API_KEY",
1058
+ "validation": "^lin_api_[a-zA-Z0-9]{40}$",
1059
+ },
1060
+ "team_key": {
1061
+ "type": "string",
1062
+ "required": True,
1063
+ "description": "Team key (e.g., 'ENG') OR team_id (UUID). At least one required.",
1064
+ "env_var": "LINEAR_TEAM_KEY",
1065
+ },
1066
+ "team_id": {
1067
+ "type": "string",
1068
+ "required": False,
1069
+ "description": "Team UUID (alternative to team_key)",
1070
+ "env_var": "LINEAR_TEAM_ID",
1071
+ "validation": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
1072
+ },
1073
+ "workspace": {
1074
+ "type": "string",
1075
+ "required": False,
1076
+ "description": "Linear workspace name (for documentation only)",
1077
+ "env_var": "LINEAR_WORKSPACE",
1078
+ },
1079
+ },
1080
+ "github": {
1081
+ "token": {
1082
+ "type": "string",
1083
+ "required": True,
1084
+ "description": "GitHub personal access token (or api_key alias)",
1085
+ "env_var": "GITHUB_TOKEN",
1086
+ },
1087
+ "owner": {
1088
+ "type": "string",
1089
+ "required": True,
1090
+ "description": "Repository owner (username or organization)",
1091
+ "env_var": "GITHUB_OWNER",
1092
+ },
1093
+ "repo": {
1094
+ "type": "string",
1095
+ "required": True,
1096
+ "description": "Repository name",
1097
+ "env_var": "GITHUB_REPO",
1098
+ },
1099
+ },
1100
+ "jira": {
1101
+ "server": {
1102
+ "type": "string",
1103
+ "required": True,
1104
+ "description": "JIRA server URL (e.g., https://company.atlassian.net)",
1105
+ "env_var": "JIRA_SERVER",
1106
+ "validation": "^https?://",
1107
+ },
1108
+ "email": {
1109
+ "type": "string",
1110
+ "required": True,
1111
+ "description": "JIRA account email address",
1112
+ "env_var": "JIRA_EMAIL",
1113
+ "validation": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
1114
+ },
1115
+ "api_token": {
1116
+ "type": "string",
1117
+ "required": True,
1118
+ "description": "JIRA API token (get from Atlassian Account Settings)",
1119
+ "env_var": "JIRA_API_TOKEN",
1120
+ },
1121
+ "project_key": {
1122
+ "type": "string",
1123
+ "required": False,
1124
+ "description": "Default JIRA project key (e.g., 'PROJ')",
1125
+ "env_var": "JIRA_PROJECT_KEY",
1126
+ },
1127
+ },
1128
+ "aitrackdown": {
1129
+ "base_path": {
1130
+ "type": "string",
1131
+ "required": False,
1132
+ "description": "Base directory for ticket storage (defaults to .aitrackdown)",
1133
+ "env_var": "AITRACKDOWN_BASE_PATH",
1134
+ },
1135
+ },
1136
+ "asana": {
1137
+ "api_key": {
1138
+ "type": "string",
1139
+ "required": True,
1140
+ "description": "Asana Personal Access Token",
1141
+ "env_var": "ASANA_API_KEY",
1142
+ },
1143
+ "workspace": {
1144
+ "type": "string",
1145
+ "required": False,
1146
+ "description": "Asana workspace GID (optional, can be auto-detected)",
1147
+ "env_var": "ASANA_WORKSPACE",
1148
+ },
1149
+ },
1150
+ }
1151
+
1152
+ requirements = requirements_map.get(adapter_type, {})
1153
+
1154
+ return {
1155
+ "status": "completed",
1156
+ "adapter": adapter_type,
1157
+ "requirements": requirements,
1158
+ "total_fields": len(requirements),
1159
+ "required_fields": [
1160
+ field for field, spec in requirements.items() if spec.get("required")
1161
+ ],
1162
+ "optional_fields": [
1163
+ field
1164
+ for field, spec in requirements.items()
1165
+ if not spec.get("required")
1166
+ ],
1167
+ }
1168
+ except Exception as e:
1169
+ return {
1170
+ "status": "error",
1171
+ "error": f"Failed to get adapter requirements: {str(e)}",
1172
+ }
1173
+
1174
+
1175
+ @mcp.tool()
1176
+ async def config_setup_wizard(
1177
+ adapter_type: str,
1178
+ credentials: dict[str, Any],
1179
+ set_as_default: bool = True,
1180
+ test_connection: bool = True,
1181
+ ) -> dict[str, Any]:
1182
+ """Interactive setup wizard for adapter configuration.
1183
+
1184
+ Single-call tool that validates, tests, and saves adapter configuration.
1185
+
1186
+ Args:
1187
+ adapter_type: Adapter to configure (linear, github, jira, aitrackdown, asana)
1188
+ credentials: Dict with adapter-specific credentials
1189
+ set_as_default: Set as default adapter (default: True)
1190
+ test_connection: Test connection before saving (default: True)
1191
+
1192
+ Returns:
1193
+ Dictionary containing:
1194
+ - status: "completed" or "error"
1195
+ - adapter: Adapter type that was configured
1196
+ - message: Success or error message
1197
+ - tested: Boolean indicating if connection was tested
1198
+ - connection_healthy: Boolean indicating if test passed (if tested)
1199
+ - set_as_default: Boolean indicating if set as default
1200
+ - config_path: Path to configuration file (if successful)
1201
+ - error: Error details (if failed)
1202
+
1203
+ Example:
1204
+ >>> result = await config_setup_wizard(
1205
+ ... adapter_type="linear",
1206
+ ... credentials={
1207
+ ... "api_key": "lin_api_...",
1208
+ ... "team_key": "ENG"
1209
+ ... }
1210
+ ... )
1211
+ >>> print(result)
1212
+ {
1213
+ "status": "completed",
1214
+ "adapter": "linear",
1215
+ "message": "Linear adapter configured successfully",
1216
+ "tested": True,
1217
+ "connection_healthy": True,
1218
+ "set_as_default": True,
1219
+ "config_path": "/path/to/.mcp-ticketer/config.json"
1220
+ }
1221
+
1222
+ Error Conditions:
1223
+ - Invalid adapter_type: Returns error with valid adapters list
1224
+ - Missing required credentials: Returns error with missing fields
1225
+ - Invalid credential format: Returns error with validation pattern
1226
+ - Connection test failure: Returns error with connection details
1227
+ - File write failure: Returns error with path and permissions info
1228
+
1229
+ """
1230
+ try:
1231
+ # Step 1: Validate adapter type
1232
+ valid_adapters = [adapter_type.value for adapter_type in AdapterType]
1233
+ adapter_lower = adapter_type.lower()
1234
+
1235
+ if adapter_lower not in valid_adapters:
1236
+ return {
1237
+ "status": "error",
1238
+ "error": f"Invalid adapter '{adapter_type}'. Must be one of: {', '.join(valid_adapters)}",
1239
+ "valid_adapters": valid_adapters,
1240
+ }
1241
+
1242
+ # Step 2: Get adapter requirements
1243
+ requirements_result = await config_get_adapter_requirements(adapter_lower)
1244
+ if requirements_result["status"] == "error":
1245
+ return requirements_result
1246
+
1247
+ requirements = requirements_result["requirements"]
1248
+
1249
+ # Step 3: Validate credentials structure
1250
+ missing_fields = []
1251
+ invalid_fields = []
1252
+
1253
+ # Check for required fields
1254
+ for field_name, field_spec in requirements.items():
1255
+ if field_spec.get("required"):
1256
+ # Check if field is present and non-empty
1257
+ if field_name not in credentials or not credentials.get(field_name):
1258
+ # For Linear, check if either team_key or team_id is provided
1259
+ if adapter_lower == "linear" and field_name in [
1260
+ "team_key",
1261
+ "team_id",
1262
+ ]:
1263
+ # Special handling: either team_key OR team_id is required
1264
+ has_team_key = (
1265
+ credentials.get("team_key")
1266
+ and str(credentials["team_key"]).strip()
1267
+ )
1268
+ has_team_id = (
1269
+ credentials.get("team_id")
1270
+ and str(credentials["team_id"]).strip()
1271
+ )
1272
+ if not has_team_key and not has_team_id:
1273
+ missing_fields.append(
1274
+ "team_key OR team_id (at least one required)"
1275
+ )
1276
+ # If one is provided, we're good - don't add to missing_fields
1277
+ else:
1278
+ missing_fields.append(field_name)
1279
+
1280
+ if missing_fields:
1281
+ return {
1282
+ "status": "error",
1283
+ "error": f"Missing required credentials: {', '.join(missing_fields)}",
1284
+ "missing_fields": missing_fields,
1285
+ "required_fields": requirements_result["required_fields"],
1286
+ "hint": "Use config_get_adapter_requirements() to see all required fields",
1287
+ }
1288
+
1289
+ # Step 4: Validate credential formats
1290
+ import re
1291
+
1292
+ for field_name, field_value in credentials.items():
1293
+ if field_name not in requirements:
1294
+ continue
1295
+
1296
+ field_spec = requirements[field_name]
1297
+ validation_pattern = field_spec.get("validation")
1298
+
1299
+ if validation_pattern and field_value:
1300
+ try:
1301
+ if not re.match(validation_pattern, str(field_value)):
1302
+ invalid_fields.append(
1303
+ {
1304
+ "field": field_name,
1305
+ "error": f"Invalid format for {field_name}",
1306
+ "pattern": validation_pattern,
1307
+ "description": field_spec.get("description", ""),
1308
+ }
1309
+ )
1310
+ except Exception as e:
1311
+ # If regex fails, log but continue (don't block on validation)
1312
+ import logging
1313
+
1314
+ logger = logging.getLogger(__name__)
1315
+ logger.warning(f"Validation pattern error for {field_name}: {e}")
1316
+
1317
+ if invalid_fields:
1318
+ return {
1319
+ "status": "error",
1320
+ "error": f"Invalid credential format for: {', '.join(f['field'] for f in invalid_fields)}",
1321
+ "invalid_fields": invalid_fields,
1322
+ }
1323
+
1324
+ # Step 5: Build adapter config
1325
+ from ....core.project_config import AdapterConfig
1326
+
1327
+ adapter_config = AdapterConfig(adapter=adapter_lower, **credentials)
1328
+
1329
+ # Step 6: Validate using ConfigValidator
1330
+ is_valid, validation_error = ConfigValidator.validate(
1331
+ adapter_lower, adapter_config.to_dict()
1332
+ )
1333
+
1334
+ if not is_valid:
1335
+ return {
1336
+ "status": "error",
1337
+ "error": f"Configuration validation failed: {validation_error}",
1338
+ "validation_error": validation_error,
1339
+ }
1340
+
1341
+ # Step 7: Test connection if enabled
1342
+ connection_healthy = None
1343
+ test_error = None
1344
+
1345
+ if test_connection:
1346
+ # Save config temporarily for testing
1347
+ resolver = get_resolver()
1348
+ config = resolver.load_project_config() or TicketerConfig()
1349
+ config.adapters[adapter_lower] = adapter_config
1350
+ resolver.save_project_config(config)
1351
+
1352
+ # Test the adapter
1353
+ test_result = await config_test_adapter(adapter_lower)
1354
+
1355
+ if test_result["status"] == "error":
1356
+ return {
1357
+ "status": "error",
1358
+ "error": f"Connection test failed: {test_result.get('error')}",
1359
+ "test_result": test_result,
1360
+ "message": "Configuration was saved but connection test failed. Please verify your credentials.",
1361
+ }
1362
+
1363
+ connection_healthy = test_result.get("healthy", False)
1364
+
1365
+ if not connection_healthy:
1366
+ test_error = test_result.get("message", "Unknown connection error")
1367
+ return {
1368
+ "status": "error",
1369
+ "error": f"Connection test failed: {test_error}",
1370
+ "test_result": test_result,
1371
+ "message": "Configuration was saved but adapter could not connect. Please verify your credentials and network connection.",
1372
+ }
1373
+ else:
1374
+ # Save config without testing
1375
+ resolver = get_resolver()
1376
+ config = resolver.load_project_config() or TicketerConfig()
1377
+ config.adapters[adapter_lower] = adapter_config
1378
+ resolver.save_project_config(config)
1379
+
1380
+ # Step 8: Set as default if enabled
1381
+ if set_as_default:
1382
+ # Update default adapter
1383
+ resolver = get_resolver()
1384
+ config = resolver.load_project_config() or TicketerConfig()
1385
+ config.default_adapter = adapter_lower
1386
+ resolver.save_project_config(config)
1387
+
1388
+ # Step 9: Return success
1389
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
1390
+
1391
+ return {
1392
+ "status": "completed",
1393
+ "adapter": adapter_lower,
1394
+ "message": f"{adapter_lower.title()} adapter configured successfully",
1395
+ "tested": test_connection,
1396
+ "connection_healthy": connection_healthy if test_connection else None,
1397
+ "set_as_default": set_as_default,
1398
+ "config_path": str(config_path),
1399
+ }
1400
+
1401
+ except Exception as e:
1402
+ import traceback
1403
+
1404
+ return {
1405
+ "status": "error",
1406
+ "error": f"Setup wizard failed: {str(e)}",
1407
+ "traceback": traceback.format_exc(),
1408
+ }
1409
+
1410
+
1411
+ def _mask_sensitive_values(config: dict[str, Any]) -> dict[str, Any]:
1412
+ """Mask sensitive values in configuration dictionary.
1413
+
1414
+ Args:
1415
+ config: Configuration dictionary
1416
+
1417
+ Returns:
1418
+ Configuration dictionary with sensitive values masked
1419
+
1420
+ Implementation Details:
1421
+ - Recursively processes nested dictionaries
1422
+ - Masks any field containing: key, token, password, secret
1423
+ - Preserves structure for debugging while protecting credentials
1424
+
1425
+ """
1426
+ masked = {}
1427
+ sensitive_keys = {"api_key", "token", "password", "secret", "api_token"}
1428
+
1429
+ for key, value in config.items():
1430
+ if isinstance(value, dict):
1431
+ # Recursively mask nested dictionaries
1432
+ masked[key] = _mask_sensitive_values(value)
1433
+ elif any(sensitive in key.lower() for sensitive in sensitive_keys):
1434
+ # Mask sensitive values
1435
+ masked[key] = "***" if value else None
1436
+ else:
1437
+ masked[key] = value
1438
+
1439
+ return masked