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
@@ -1,15 +1,64 @@
1
- """Basic CRUD operations for tickets.
1
+ """Unified ticket CRUD operations (v2.0.0).
2
2
 
3
- This module implements the core create, read, update, delete, and list
4
- operations for tickets using the FastMCP SDK.
3
+ This module implements ticket management through a single unified `ticket()` interface.
4
+
5
+ Version 2.0.0 changes:
6
+ - Removed @mcp.tool() decorators from individual operations (converted to private helpers)
7
+ - Single `ticket()` function is the only exposed MCP tool
8
+ - All operations accessible via ticket(action="create"|"get"|"update"|"delete"|"list"|"summary"|"get_activity"|"assign")
9
+ - Individual functions retained as internal helpers for code organization
5
10
  """
6
11
 
12
+ import logging
13
+ import warnings
7
14
  from pathlib import Path
8
- from typing import Any
15
+ from typing import Any, Literal
9
16
 
17
+ from ....core.adapter import BaseAdapter
10
18
  from ....core.models import Priority, Task, TicketState
19
+ from ....core.priority_matcher import get_priority_matcher
11
20
  from ....core.project_config import ConfigResolver, TicketerConfig
12
- from ..server_sdk import get_adapter, mcp
21
+ from ....core.session_state import SessionStateManager
22
+ from ....core.url_parser import extract_id_from_url, is_url
23
+ from ..diagnostic_helper import (
24
+ build_diagnostic_suggestion,
25
+ get_quick_diagnostic_info,
26
+ should_suggest_diagnostics,
27
+ )
28
+ from ..server_sdk import get_adapter, get_router, has_router, mcp
29
+
30
+ # Sentinel value to distinguish between "parameter not provided" and "explicitly None"
31
+ _UNSET = object()
32
+
33
+
34
+ def _build_adapter_metadata(
35
+ adapter: BaseAdapter,
36
+ ticket_id: str | None = None,
37
+ is_routed: bool = False,
38
+ ) -> dict[str, Any]:
39
+ """Build adapter metadata for MCP responses.
40
+
41
+ Args:
42
+ adapter: The adapter that handled the operation
43
+ ticket_id: Optional ticket ID to include in metadata
44
+ is_routed: Whether this was routed via URL detection
45
+
46
+ Returns:
47
+ Dictionary with adapter metadata fields
48
+
49
+ """
50
+ metadata = {
51
+ "adapter": adapter.adapter_type,
52
+ "adapter_name": adapter.adapter_display_name,
53
+ }
54
+
55
+ if ticket_id:
56
+ metadata["ticket_id"] = ticket_id
57
+
58
+ if is_routed:
59
+ metadata["routed_from_url"] = True
60
+
61
+ return metadata
13
62
 
14
63
 
15
64
  async def detect_and_apply_labels(
@@ -81,17 +130,15 @@ async def detect_and_apply_labels(
81
130
  # Extract label name (handle both dict and string formats)
82
131
  if isinstance(label, dict):
83
132
  label_name = label.get("name", "")
84
- label_id = label.get("id", label_name)
85
133
  else:
86
134
  label_name = str(label)
87
- label_id = label_name
88
135
 
89
136
  label_name_lower = label_name.lower()
90
137
 
91
138
  # Direct match: label name appears in content
92
139
  if label_name_lower in content:
93
- if label_id not in matched_labels:
94
- matched_labels.append(label_id)
140
+ if label_name not in matched_labels:
141
+ matched_labels.append(label_name)
95
142
  continue
96
143
 
97
144
  # Keyword match: check if label matches any keyword category
@@ -103,8 +150,8 @@ async def detect_and_apply_labels(
103
150
  ):
104
151
  # Check if any keyword from this category appears in content
105
152
  if any(kw in content for kw in keywords):
106
- if label_id not in matched_labels:
107
- matched_labels.append(label_id)
153
+ if label_name not in matched_labels:
154
+ matched_labels.append(label_name)
108
155
  break
109
156
 
110
157
  # Combine user-specified labels with auto-detected ones
@@ -117,80 +164,341 @@ async def detect_and_apply_labels(
117
164
 
118
165
 
119
166
  @mcp.tool()
120
- async def ticket_create(
121
- title: str,
167
+ async def ticket(
168
+ action: Literal[
169
+ "create", "get", "update", "delete", "list", "summary", "get_activity", "assign"
170
+ ],
171
+ # Ticket identification
172
+ ticket_id: str | None = None,
173
+ # Create parameters
174
+ title: str | None = None,
122
175
  description: str = "",
123
176
  priority: str = "medium",
124
177
  tags: list[str] | None = None,
125
178
  assignee: str | None = None,
126
- parent_epic: str | None = None,
179
+ parent_epic: str | None = _UNSET,
127
180
  auto_detect_labels: bool = True,
181
+ # Update parameters
182
+ state: str | None = None,
183
+ # List parameters
184
+ limit: int = 20,
185
+ offset: int = 0,
186
+ project_id: str | None = None,
187
+ compact: bool = True,
188
+ # Assign parameters
189
+ comment: str | None = None,
190
+ auto_transition: bool = True,
128
191
  ) -> dict[str, Any]:
129
- """Create a new ticket with automatic label/tag detection.
192
+ """Unified ticket management tool for all CRUD operations.
130
193
 
131
- This tool automatically scans available labels/tags and intelligently
132
- applies relevant ones based on the ticket title and description.
133
-
134
- Label Detection:
135
- - Scans all available labels in the configured adapter
136
- - Matches labels based on keywords in title/description
137
- - Combines auto-detected labels with user-specified ones
138
- - Can be disabled by setting auto_detect_labels=false
139
-
140
- Common label patterns detected:
141
- - bug, feature, improvement, documentation
142
- - test, security, performance
143
- - ui, api, backend, frontend
194
+ Handles ticket creation, reading, updating, deletion, listing,
195
+ summarization, activity tracking, and assignment in a single interface.
144
196
 
145
197
  Args:
146
- title: Ticket title (required)
147
- description: Detailed description of the ticket
148
- priority: Priority level - must be one of: low, medium, high, critical
149
- tags: List of tags to categorize the ticket (auto-detection adds to these)
150
- assignee: User ID or email to assign the ticket to
151
- parent_epic: Parent epic/project ID to assign this ticket to (optional)
152
- auto_detect_labels: Automatically detect and apply relevant labels (default: True)
198
+ action: Operation to perform (create, get, update, delete, list, summary, get_activity, assign)
199
+ ticket_id: Ticket ID for get/update/delete/summary/assign operations
200
+ title: Ticket title (required for create)
201
+ description: Ticket description
202
+ priority: Ticket priority (low, medium, high, critical)
203
+ tags: List of tags/labels
204
+ assignee: User ID or email to assign ticket
205
+ parent_epic: Parent epic/project ID
206
+ auto_detect_labels: Auto-detect labels from content
207
+ state: Ticket state for updates
208
+ limit: Maximum results for list/get_activity operations
209
+ offset: Pagination offset for list
210
+ project_id: Project filter for list operations
211
+ compact: Return compact format for list (saves tokens)
212
+ comment: Comment when assigning ticket
213
+ auto_transition: Auto-transition state when assigning
153
214
 
154
215
  Returns:
155
- Created ticket details including ID and metadata, or error information
216
+ dict: Operation results
217
+
218
+ Raises:
219
+ ValueError: If action is invalid or required parameters missing
220
+
221
+ Examples:
222
+ # Create ticket
223
+ await ticket(
224
+ action="create",
225
+ title="Fix login bug",
226
+ priority="high",
227
+ tags=["bug", "security"]
228
+ )
229
+
230
+ # Get ticket details
231
+ await ticket(
232
+ action="get",
233
+ ticket_id="PROJ-123"
234
+ )
235
+
236
+ # Update ticket
237
+ await ticket(
238
+ action="update",
239
+ ticket_id="PROJ-123",
240
+ state="in_progress",
241
+ priority="critical"
242
+ )
243
+
244
+ # List tickets
245
+ await ticket(
246
+ action="list",
247
+ project_id="PROJ",
248
+ state="open",
249
+ limit=50
250
+ )
251
+
252
+ # Get compact summary
253
+ await ticket(
254
+ action="summary",
255
+ ticket_id="PROJ-123"
256
+ )
257
+
258
+ # Get activity/comments
259
+ await ticket(
260
+ action="get_activity",
261
+ ticket_id="PROJ-123",
262
+ limit=5
263
+ )
264
+
265
+ # Assign ticket
266
+ await ticket(
267
+ action="assign",
268
+ ticket_id="PROJ-123",
269
+ assignee="user@example.com",
270
+ comment="Taking this one"
271
+ )
272
+
273
+ # Delete ticket
274
+ await ticket(
275
+ action="delete",
276
+ ticket_id="PROJ-123"
277
+ )
278
+ """
279
+ # Normalize action to lowercase for case-insensitive matching
280
+ action_lower = action.lower()
281
+
282
+ if action_lower == "create":
283
+ if not title:
284
+ return {
285
+ "status": "error",
286
+ "error": "title parameter required for action='create'",
287
+ "hint": "Example: ticket(action='create', title='Fix bug', priority='high')",
288
+ }
289
+ return await ticket_create(
290
+ title,
291
+ description,
292
+ priority,
293
+ tags,
294
+ assignee,
295
+ parent_epic,
296
+ auto_detect_labels,
297
+ )
298
+
299
+ elif action_lower == "get":
300
+ if not ticket_id:
301
+ return {
302
+ "status": "error",
303
+ "error": "ticket_id parameter required for action='get'",
304
+ "hint": "Example: ticket(action='get', ticket_id='PROJ-123')",
305
+ }
306
+ return await ticket_read(ticket_id)
307
+
308
+ elif action_lower == "update":
309
+ if not ticket_id:
310
+ return {
311
+ "status": "error",
312
+ "error": "ticket_id parameter required for action='update'",
313
+ "hint": "Example: ticket(action='update', ticket_id='PROJ-123', state='done')",
314
+ }
315
+ return await ticket_update(
316
+ ticket_id, title, description, priority, state, assignee, tags
317
+ )
318
+
319
+ elif action_lower == "delete":
320
+ if not ticket_id:
321
+ return {
322
+ "status": "error",
323
+ "error": "ticket_id parameter required for action='delete'",
324
+ "hint": "Example: ticket(action='delete', ticket_id='PROJ-123')",
325
+ }
326
+ return await ticket_delete(ticket_id)
327
+
328
+ elif action_lower == "list":
329
+ return await ticket_list(
330
+ limit, offset, state, priority, assignee, project_id, compact
331
+ )
332
+
333
+ elif action_lower == "summary":
334
+ if not ticket_id:
335
+ return {
336
+ "status": "error",
337
+ "error": "ticket_id parameter required for action='summary'",
338
+ "hint": "Example: ticket(action='summary', ticket_id='PROJ-123')",
339
+ }
340
+ return await ticket_summary(ticket_id)
341
+
342
+ elif action_lower == "get_activity":
343
+ if not ticket_id:
344
+ return {
345
+ "status": "error",
346
+ "error": "ticket_id parameter required for action='get_activity'",
347
+ "hint": "Example: ticket(action='get_activity', ticket_id='PROJ-123', limit=5)",
348
+ }
349
+ return await ticket_latest(ticket_id, limit)
350
+
351
+ elif action_lower == "assign":
352
+ if not ticket_id:
353
+ return {
354
+ "status": "error",
355
+ "error": "ticket_id parameter required for action='assign'",
356
+ "hint": "Example: ticket(action='assign', ticket_id='PROJ-123', assignee='user@example.com')",
357
+ }
358
+ return await ticket_assign(ticket_id, assignee, comment, auto_transition)
156
359
 
360
+ else:
361
+ return {
362
+ "status": "error",
363
+ "error": f"Invalid action: {action}",
364
+ "valid_actions": [
365
+ "create",
366
+ "get",
367
+ "update",
368
+ "delete",
369
+ "list",
370
+ "summary",
371
+ "get_activity",
372
+ "assign",
373
+ ],
374
+ "hint": "Use one of the valid actions listed above",
375
+ }
376
+
377
+
378
+ async def ticket_create(
379
+ title: str,
380
+ description: str = "",
381
+ priority: str = "medium",
382
+ tags: list[str] | None = None,
383
+ assignee: str | None = None,
384
+ parent_epic: str | None = _UNSET,
385
+ auto_detect_labels: bool = True,
386
+ ) -> dict[str, Any]:
387
+ """Create ticket with auto-label detection and semantic priority matching.
388
+
389
+ .. deprecated:: 1.5.0
390
+ Use :func:`ticket` with ``action='create'`` instead.
391
+ This function will be removed in version 2.0.0.
392
+
393
+ Args: title (required), description, priority (supports natural language), tags, assignee, parent_epic (optional), auto_detect_labels (default: True)
394
+ Returns: TicketResponse with created ticket, ID, metadata
395
+ See: docs/mcp-api-reference.md#ticket-response-format, docs/mcp-api-reference.md#semantic-priority-matching
157
396
  """
397
+ warnings.warn(
398
+ "ticket_create is deprecated. Use ticket(action='create', ...) instead. "
399
+ "This function will be removed in version 2.0.0.",
400
+ DeprecationWarning,
401
+ stacklevel=2,
402
+ )
158
403
  try:
159
404
  adapter = get_adapter()
160
405
 
161
- # Validate and convert priority
162
- try:
163
- priority_enum = Priority(priority.lower())
164
- except ValueError:
406
+ # Validate and convert priority using semantic matcher (ISS-0002)
407
+ priority_matcher = get_priority_matcher()
408
+ match_result = priority_matcher.match_priority(priority)
409
+
410
+ # Handle low confidence matches - provide suggestions
411
+ if match_result.is_low_confidence():
412
+ suggestions = priority_matcher.suggest_priorities(priority, top_n=3)
165
413
  return {
166
- "status": "error",
167
- "error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
414
+ "status": "ambiguous",
415
+ "message": f"Priority input '{priority}' is ambiguous. Please choose from suggestions or use exact values.",
416
+ "original_input": priority,
417
+ "suggestions": [
418
+ {
419
+ "priority": s.priority.value,
420
+ "confidence": round(s.confidence, 2),
421
+ }
422
+ for s in suggestions
423
+ ],
424
+ "exact_values": ["low", "medium", "high", "critical"],
168
425
  }
169
426
 
170
- # Use default_user if no assignee specified
427
+ priority_enum = match_result.priority
428
+
429
+ # Apply configuration defaults if values not provided
430
+ resolver = ConfigResolver(project_path=Path.cwd())
431
+ config = resolver.load_project_config() or TicketerConfig()
432
+
433
+ # Determine final_parent_epic based on priority order:
434
+ # Priority 1: Explicit parent_epic argument (including explicit None for opt-out)
435
+ # Priority 2: Config default (default_epic or default_project)
436
+ # Priority 3: Session-attached ticket
437
+ # Priority 4: Prompt user (last resort only if nothing configured)
438
+
439
+ final_parent_epic: str | None = None
440
+
441
+ if parent_epic is not _UNSET:
442
+ # Priority 1: Explicit value provided (including None for opt-out)
443
+ final_parent_epic = parent_epic
444
+ if parent_epic is not None:
445
+ logging.debug(f"Using explicit parent_epic: {parent_epic}")
446
+ else:
447
+ logging.debug("Explicitly opted out of parent_epic (parent_epic=None)")
448
+ elif config.default_project or config.default_epic:
449
+ # Priority 2: Use configured default
450
+ final_parent_epic = config.default_project or config.default_epic
451
+ logging.debug(f"Using default epic from config: {final_parent_epic}")
452
+ else:
453
+ # Priority 3 & 4: Check session, then prompt
454
+ session_manager = SessionStateManager(project_path=Path.cwd())
455
+ session_state = session_manager.load_session()
456
+
457
+ if session_state.current_ticket:
458
+ # Priority 3: Use session ticket as parent_epic
459
+ final_parent_epic = session_state.current_ticket
460
+ logging.info(
461
+ f"Using session ticket as parent_epic: {final_parent_epic}"
462
+ )
463
+ elif not session_state.ticket_opted_out:
464
+ # Priority 4: No default, no session, no opt-out - provide guidance
465
+ return {
466
+ "status": "error",
467
+ "requires_ticket_association": True,
468
+ "guidance": (
469
+ "⚠️ No ticket association found for this work session.\n\n"
470
+ "It's recommended to associate your work with a ticket for proper tracking.\n\n"
471
+ "**Options**:\n"
472
+ "1. Associate with a ticket: attach_ticket(action='set', ticket_id='PROJ-123')\n"
473
+ "2. Skip for this session: attach_ticket(action='none')\n"
474
+ "3. Provide parent_epic directly: ticket_create(..., parent_epic='PROJ-123')\n"
475
+ "4. Set a default: config_set_default_project(project_id='PROJ-123')\n\n"
476
+ "After associating, run ticket_create again to create the ticket."
477
+ ),
478
+ "session_id": session_state.session_id,
479
+ }
480
+ # else: session opted out, final_parent_epic stays None
481
+
482
+ # Default user/assignee
171
483
  final_assignee = assignee
172
- if final_assignee is None:
173
- resolver = ConfigResolver(project_path=Path.cwd())
174
- config = resolver.load_project_config() or TicketerConfig()
175
- if config.default_user:
176
- final_assignee = config.default_user
177
-
178
- # Use default_project if no parent_epic specified
179
- final_parent_epic = parent_epic
180
- if final_parent_epic is None:
181
- resolver = ConfigResolver(project_path=Path.cwd())
182
- config = resolver.load_project_config() or TicketerConfig()
183
- # Try default_project first, fall back to default_epic
184
- if config.default_project:
185
- final_parent_epic = config.default_project
186
- elif config.default_epic:
187
- final_parent_epic = config.default_epic
188
-
189
- # Auto-detect labels if enabled
190
- final_tags = tags
484
+ if final_assignee is None and config.default_user:
485
+ final_assignee = config.default_user
486
+ logging.debug(f"Using default assignee from config: {final_assignee}")
487
+
488
+ # Default tags - merge with provided tags
489
+ final_tags = tags or []
490
+ if config.default_tags:
491
+ # Add default tags that aren't already in the provided tags
492
+ for default_tag in config.default_tags:
493
+ if default_tag not in final_tags:
494
+ final_tags.append(default_tag)
495
+ if final_tags != (tags or []):
496
+ logging.debug(f"Merged default tags from config: {config.default_tags}")
497
+
498
+ # Auto-detect labels if enabled (adds to existing tags)
191
499
  if auto_detect_labels:
192
500
  final_tags = await detect_and_apply_labels(
193
- adapter, title, description or "", tags
501
+ adapter, title, description or "", final_tags
194
502
  )
195
503
 
196
504
  # Create task object
@@ -206,33 +514,91 @@ async def ticket_create(
206
514
  # Create via adapter
207
515
  created = await adapter.create(task)
208
516
 
209
- return {
517
+ # Build response with adapter metadata
518
+ response = {
210
519
  "status": "completed",
520
+ **_build_adapter_metadata(adapter, created.id),
211
521
  "ticket": created.model_dump(),
212
522
  "labels_applied": created.tags or [],
213
523
  "auto_detected": auto_detect_labels,
214
524
  }
525
+ return response
215
526
  except Exception as e:
216
- return {
527
+ error_response = {
217
528
  "status": "error",
218
529
  "error": f"Failed to create ticket: {str(e)}",
219
530
  }
531
+ try:
532
+ adapter = get_adapter()
533
+ error_response.update(_build_adapter_metadata(adapter))
534
+ except Exception:
535
+ pass # If adapter not available, return error without metadata
536
+
537
+ # Add diagnostic suggestion for system-level errors
538
+ if should_suggest_diagnostics(e):
539
+ logging.debug(
540
+ "Error classified as system-level, adding diagnostic suggestion"
541
+ )
542
+ try:
543
+ quick_info = await get_quick_diagnostic_info()
544
+ error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
545
+ e, quick_info
546
+ )
547
+ except Exception as diag_error:
548
+ # Never block error response on diagnostic failure
549
+ logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
220
550
 
551
+ return error_response
221
552
 
222
- @mcp.tool()
223
- async def ticket_read(ticket_id: str) -> dict[str, Any]:
224
- """Read a ticket by its ID.
225
553
 
226
- Args:
227
- ticket_id: Unique identifier of the ticket to retrieve
554
+ async def ticket_read(ticket_id: str) -> dict[str, Any]:
555
+ """Read ticket by ID or URL (supports Linear, GitHub, JIRA, Asana URLs with multi-platform routing).
228
556
 
229
- Returns:
230
- Ticket details if found, or error information
557
+ .. deprecated:: 1.5.0
558
+ Use :func:`ticket` with ``action='get'`` instead.
559
+ This function will be removed in version 2.0.0.
231
560
 
561
+ Args: ticket_id (ID or full URL)
562
+ Returns: TicketResponse with ticket details
563
+ See: docs/mcp-api-reference.md#ticket-response-format, docs/mcp-api-reference.md#url-routing
232
564
  """
565
+ warnings.warn(
566
+ "ticket_read is deprecated. Use ticket(action='get', ticket_id=...) instead. "
567
+ "This function will be removed in version 2.0.0.",
568
+ DeprecationWarning,
569
+ stacklevel=2,
570
+ )
233
571
  try:
234
- adapter = get_adapter()
235
- ticket = await adapter.read(ticket_id)
572
+ is_routed = False
573
+ # Check if multi-platform routing is available
574
+ if is_url(ticket_id) and has_router():
575
+ # Use router for URL-based access
576
+ router = get_router()
577
+ logging.info(f"Routing ticket_read for URL: {ticket_id}")
578
+ ticket = await router.route_read(ticket_id)
579
+ is_routed = True
580
+ # Get adapter from router's cache to extract metadata
581
+ normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
582
+ adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
583
+ else:
584
+ # Use default adapter for plain IDs OR URLs (without multi-platform routing)
585
+ adapter = get_adapter()
586
+
587
+ # If URL provided, extract ID for the adapter
588
+ if is_url(ticket_id):
589
+ # Extract ID from URL for default adapter
590
+ adapter_type = type(adapter).__name__.lower().replace("adapter", "")
591
+ extracted_id, error = extract_id_from_url(
592
+ ticket_id, adapter_type=adapter_type
593
+ )
594
+ if error or not extracted_id:
595
+ return {
596
+ "status": "error",
597
+ "error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
598
+ }
599
+ ticket = await adapter.read(extracted_id)
600
+ else:
601
+ ticket = await adapter.read(ticket_id)
236
602
 
237
603
  if ticket is None:
238
604
  return {
@@ -242,16 +608,40 @@ async def ticket_read(ticket_id: str) -> dict[str, Any]:
242
608
 
243
609
  return {
244
610
  "status": "completed",
611
+ **_build_adapter_metadata(adapter, ticket.id, is_routed),
245
612
  "ticket": ticket.model_dump(),
246
613
  }
247
- except Exception as e:
614
+ except ValueError as e:
615
+ # ValueError from adapters contains helpful user-facing messages
616
+ # (e.g., Linear view URL detection error)
617
+ # Return the error message directly without generic wrapper
248
618
  return {
619
+ "status": "error",
620
+ "error": str(e),
621
+ }
622
+ except Exception as e:
623
+ error_response = {
249
624
  "status": "error",
250
625
  "error": f"Failed to read ticket: {str(e)}",
251
626
  }
252
627
 
628
+ # Add diagnostic suggestion for system-level errors
629
+ if should_suggest_diagnostics(e):
630
+ logging.debug(
631
+ "Error classified as system-level, adding diagnostic suggestion"
632
+ )
633
+ try:
634
+ quick_info = await get_quick_diagnostic_info()
635
+ error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
636
+ e, quick_info
637
+ )
638
+ except Exception as diag_error:
639
+ # Never block error response on diagnostic failure
640
+ logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
641
+
642
+ return error_response
643
+
253
644
 
254
- @mcp.tool()
255
645
  async def ticket_update(
256
646
  ticket_id: str,
257
647
  title: str | None = None,
@@ -261,24 +651,23 @@ async def ticket_update(
261
651
  assignee: str | None = None,
262
652
  tags: list[str] | None = None,
263
653
  ) -> dict[str, Any]:
264
- """Update an existing ticket.
265
-
266
- Args:
267
- ticket_id: Unique identifier of the ticket to update
268
- title: New title for the ticket
269
- description: New description for the ticket
270
- priority: New priority - must be one of: low, medium, high, critical
271
- state: New state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
272
- assignee: User ID or email to assign the ticket to
273
- tags: New list of tags (replaces existing tags)
654
+ """Update ticket using ID or URL (semantic priority matching, workflow states).
274
655
 
275
- Returns:
276
- Updated ticket details, or error information
656
+ .. deprecated:: 1.5.0
657
+ Use :func:`ticket` with ``action='update'`` instead.
658
+ This function will be removed in version 2.0.0.
277
659
 
660
+ Args: ticket_id (ID or URL), title, description, priority (natural language), state (workflow), assignee, tags
661
+ Returns: TicketResponse with updated ticket
662
+ See: docs/mcp-api-reference.md#ticket-response-format, docs/mcp-api-reference.md#semantic-priority-matching
278
663
  """
664
+ warnings.warn(
665
+ "ticket_update is deprecated. Use ticket(action='update', ticket_id=...) instead. "
666
+ "This function will be removed in version 2.0.0.",
667
+ DeprecationWarning,
668
+ stacklevel=2,
669
+ )
279
670
  try:
280
- adapter = get_adapter()
281
-
282
671
  # Build updates dictionary with only provided fields
283
672
  updates: dict[str, Any] = {}
284
673
 
@@ -291,16 +680,30 @@ async def ticket_update(
291
680
  if tags is not None:
292
681
  updates["tags"] = tags
293
682
 
294
- # Validate and convert priority if provided
683
+ # Validate and convert priority if provided (ISS-0002)
295
684
  if priority is not None:
296
- try:
297
- updates["priority"] = Priority(priority.lower())
298
- except ValueError:
685
+ priority_matcher = get_priority_matcher()
686
+ match_result = priority_matcher.match_priority(priority)
687
+
688
+ # Handle low confidence matches - provide suggestions
689
+ if match_result.is_low_confidence():
690
+ suggestions = priority_matcher.suggest_priorities(priority, top_n=3)
299
691
  return {
300
- "status": "error",
301
- "error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
692
+ "status": "ambiguous",
693
+ "message": f"Priority input '{priority}' is ambiguous. Please choose from suggestions or use exact values.",
694
+ "original_input": priority,
695
+ "suggestions": [
696
+ {
697
+ "priority": s.priority.value,
698
+ "confidence": round(s.confidence, 2),
699
+ }
700
+ for s in suggestions
701
+ ],
702
+ "exact_values": ["low", "medium", "high", "critical"],
302
703
  }
303
704
 
705
+ updates["priority"] = match_result.priority
706
+
304
707
  # Validate and convert state if provided
305
708
  if state is not None:
306
709
  try:
@@ -311,8 +714,33 @@ async def ticket_update(
311
714
  "error": f"Invalid state '{state}'. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
312
715
  }
313
716
 
314
- # Update via adapter
315
- updated = await adapter.update(ticket_id, updates)
717
+ # Route to appropriate adapter
718
+ is_routed = False
719
+ if is_url(ticket_id) and has_router():
720
+ router = get_router()
721
+ logging.info(f"Routing ticket_update for URL: {ticket_id}")
722
+ updated = await router.route_update(ticket_id, updates)
723
+ is_routed = True
724
+ normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
725
+ adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
726
+ else:
727
+ adapter = get_adapter()
728
+
729
+ # If URL provided, extract ID for the adapter
730
+ if is_url(ticket_id):
731
+ # Extract ID from URL for default adapter
732
+ adapter_type = type(adapter).__name__.lower().replace("adapter", "")
733
+ extracted_id, error = extract_id_from_url(
734
+ ticket_id, adapter_type=adapter_type
735
+ )
736
+ if error or not extracted_id:
737
+ return {
738
+ "status": "error",
739
+ "error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
740
+ }
741
+ updated = await adapter.update(extracted_id, updates)
742
+ else:
743
+ updated = await adapter.update(ticket_id, updates)
316
744
 
317
745
  if updated is None:
318
746
  return {
@@ -322,29 +750,77 @@ async def ticket_update(
322
750
 
323
751
  return {
324
752
  "status": "completed",
753
+ **_build_adapter_metadata(adapter, updated.id, is_routed),
325
754
  "ticket": updated.model_dump(),
326
755
  }
327
756
  except Exception as e:
328
- return {
757
+ error_response = {
329
758
  "status": "error",
330
759
  "error": f"Failed to update ticket: {str(e)}",
331
760
  }
332
761
 
762
+ # Add diagnostic suggestion for system-level errors
763
+ if should_suggest_diagnostics(e):
764
+ logging.debug(
765
+ "Error classified as system-level, adding diagnostic suggestion"
766
+ )
767
+ try:
768
+ quick_info = await get_quick_diagnostic_info()
769
+ error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
770
+ e, quick_info
771
+ )
772
+ except Exception as diag_error:
773
+ # Never block error response on diagnostic failure
774
+ logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
775
+
776
+ return error_response
333
777
 
334
- @mcp.tool()
335
- async def ticket_delete(ticket_id: str) -> dict[str, Any]:
336
- """Delete a ticket by its ID.
337
778
 
338
- Args:
339
- ticket_id: Unique identifier of the ticket to delete
779
+ async def ticket_delete(ticket_id: str) -> dict[str, Any]:
780
+ """Delete ticket by ID or URL.
340
781
 
341
- Returns:
342
- Success confirmation or error information
782
+ .. deprecated:: 1.5.0
783
+ Use :func:`ticket` with ``action='delete'`` instead.
784
+ This function will be removed in version 2.0.0.
343
785
 
786
+ Args: ticket_id (ID or URL)
787
+ Returns: DeleteResponse with status confirmation
788
+ See: docs/mcp-api-reference.md#delete-response
344
789
  """
790
+ warnings.warn(
791
+ "ticket_delete is deprecated. Use ticket(action='delete', ticket_id=...) instead. "
792
+ "This function will be removed in version 2.0.0.",
793
+ DeprecationWarning,
794
+ stacklevel=2,
795
+ )
345
796
  try:
346
- adapter = get_adapter()
347
- success = await adapter.delete(ticket_id)
797
+ # Route to appropriate adapter
798
+ is_routed = False
799
+ if is_url(ticket_id) and has_router():
800
+ router = get_router()
801
+ logging.info(f"Routing ticket_delete for URL: {ticket_id}")
802
+ success = await router.route_delete(ticket_id)
803
+ is_routed = True
804
+ normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
805
+ adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
806
+ else:
807
+ adapter = get_adapter()
808
+
809
+ # If URL provided, extract ID for the adapter
810
+ if is_url(ticket_id):
811
+ # Extract ID from URL for default adapter
812
+ adapter_type = type(adapter).__name__.lower().replace("adapter", "")
813
+ extracted_id, error = extract_id_from_url(
814
+ ticket_id, adapter_type=adapter_type
815
+ )
816
+ if error or not extracted_id:
817
+ return {
818
+ "status": "error",
819
+ "error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
820
+ }
821
+ success = await adapter.delete(extracted_id)
822
+ else:
823
+ success = await adapter.delete(ticket_id)
348
824
 
349
825
  if not success:
350
826
  return {
@@ -354,6 +830,7 @@ async def ticket_delete(ticket_id: str) -> dict[str, Any]:
354
830
 
355
831
  return {
356
832
  "status": "completed",
833
+ **_build_adapter_metadata(adapter, ticket_id, is_routed),
357
834
  "message": f"Ticket {ticket_id} deleted successfully",
358
835
  }
359
836
  except Exception as e:
@@ -363,32 +840,106 @@ async def ticket_delete(ticket_id: str) -> dict[str, Any]:
363
840
  }
364
841
 
365
842
 
366
- @mcp.tool()
843
+ def _compact_ticket(ticket_dict: dict[str, Any]) -> dict[str, Any]:
844
+ """Extract compact representation of ticket for reduced token usage.
845
+
846
+ This helper function reduces ticket data from ~185 tokens to ~50 tokens by
847
+ including only the most essential fields. Use for listing operations where full
848
+ details are not needed.
849
+
850
+ Args:
851
+ ticket_dict: Full ticket dictionary from model_dump()
852
+
853
+ Returns:
854
+ Compact ticket dictionary with essential fields:
855
+ - id: Ticket identifier
856
+ - title: Ticket title
857
+ - state: Current state (for quick status check)
858
+ - priority: Priority level
859
+ - assignee: Assigned user (if any)
860
+ - tags: List of tags/labels (if any)
861
+ - parent_epic: Parent epic ID (if any)
862
+
863
+ """
864
+ return {
865
+ "id": ticket_dict.get("id"),
866
+ "title": ticket_dict.get("title"),
867
+ "state": ticket_dict.get("state"),
868
+ "priority": ticket_dict.get("priority"),
869
+ "assignee": ticket_dict.get("assignee"),
870
+ "tags": ticket_dict.get("tags") or [],
871
+ "parent_epic": ticket_dict.get("parent_epic"),
872
+ }
873
+
874
+
367
875
  async def ticket_list(
368
- limit: int = 10,
876
+ limit: int = 20,
369
877
  offset: int = 0,
370
878
  state: str | None = None,
371
879
  priority: str | None = None,
372
880
  assignee: str | None = None,
881
+ project_id: str | None = None,
882
+ compact: bool = True,
373
883
  ) -> dict[str, Any]:
374
- """List tickets with pagination and optional filters.
884
+ """List tickets with pagination and filters (compact mode default, project scoping required).
375
885
 
376
- Args:
377
- limit: Maximum number of tickets to return (default: 10)
378
- offset: Number of tickets to skip for pagination (default: 0)
379
- state: Filter by state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
380
- priority: Filter by priority - must be one of: low, medium, high, critical
381
- assignee: Filter by assigned user ID or email
886
+ .. deprecated:: 1.5.0
887
+ Use :func:`ticket` with ``action='list'`` instead.
888
+ This function will be removed in version 2.0.0.
382
889
 
383
- Returns:
384
- List of tickets matching criteria, or error information
890
+ ⚠️ Project Filtering Required:
891
+ This tool requires project_id parameter OR default_project configuration.
892
+ To set default project: config_set_default_project(project_id="YOUR-PROJECT")
893
+ To check current config: config_get()
385
894
 
895
+ Args: limit (max: 100, default: 20), offset (pagination), state, priority, assignee, project_id (required), compact (default: True, ~50 tokens/ticket vs ~185 full)
896
+ Returns: ListResponse with tickets array, count, pagination
897
+ See: docs/mcp-api-reference.md#list-response-format, docs/mcp-api-reference.md#token-usage-optimization
386
898
  """
899
+ warnings.warn(
900
+ "ticket_list is deprecated. Use ticket(action='list', ...) instead. "
901
+ "This function will be removed in version 2.0.0.",
902
+ DeprecationWarning,
903
+ stacklevel=2,
904
+ )
387
905
  try:
906
+ # Validate project context (NEW: Required for list operations)
907
+ from pathlib import Path
908
+
909
+ from ....core.project_config import ConfigResolver
910
+
911
+ resolver = ConfigResolver(project_path=Path.cwd())
912
+ config = resolver.load_project_config()
913
+ final_project = project_id or (config.default_project if config else None)
914
+
915
+ if not final_project:
916
+ return {
917
+ "status": "error",
918
+ "error": "project_id required. Provide project_id parameter or configure default_project.",
919
+ "help": "Use config_set_default_project(project_id='YOUR-PROJECT') to set default project",
920
+ "check_config": "Use config_get() to view current configuration",
921
+ }
922
+
388
923
  adapter = get_adapter()
389
924
 
390
- # Build filters dictionary
391
- filters: dict[str, Any] = {}
925
+ # Add warning for large non-compact queries
926
+ if limit > 30 and not compact:
927
+ logging.warning(
928
+ f"Large query requested: limit={limit}, compact={compact}. "
929
+ f"This may generate ~{limit * 185} tokens. "
930
+ f"Consider using compact=True to reduce token usage."
931
+ )
932
+
933
+ # Add warning for large unscoped queries
934
+ if limit > 50 and not (state or priority or assignee):
935
+ logging.warning(
936
+ f"Large unscoped query: limit={limit} with no filters. "
937
+ f"Consider using state, priority, or assignee filters to reduce result set. "
938
+ f"Tip: Configure default_team or default_project for automatic scoping."
939
+ )
940
+
941
+ # Build filters dictionary with required project scoping
942
+ filters: dict[str, Any] = {"project": final_project}
392
943
 
393
944
  if state is not None:
394
945
  try:
@@ -416,15 +967,447 @@ async def ticket_list(
416
967
  limit=limit, offset=offset, filters=filters if filters else None
417
968
  )
418
969
 
419
- return {
970
+ # Apply compact mode if requested
971
+ if compact:
972
+ ticket_data = [_compact_ticket(ticket.model_dump()) for ticket in tickets]
973
+ else:
974
+ ticket_data = [ticket.model_dump() for ticket in tickets]
975
+
976
+ # Build response
977
+ response_data = {
420
978
  "status": "completed",
421
- "tickets": [ticket.model_dump() for ticket in tickets],
979
+ **_build_adapter_metadata(adapter),
980
+ "tickets": ticket_data,
422
981
  "count": len(tickets),
423
982
  "limit": limit,
424
983
  "offset": offset,
984
+ "compact": compact,
425
985
  }
986
+
987
+ # Estimate and validate token count to prevent MCP limit violations
988
+ # MCP has a 25k token limit per response; we use 20k as safety margin
989
+ from ....utils.token_utils import estimate_json_tokens
990
+
991
+ estimated_tokens = estimate_json_tokens(response_data)
992
+
993
+ # If exceeds 20k tokens (safety margin below 25k MCP limit)
994
+ if estimated_tokens > 20_000:
995
+ # Calculate recommended limit based on current token-per-ticket ratio
996
+ if len(tickets) > 0:
997
+ tokens_per_ticket = estimated_tokens / len(tickets)
998
+ recommended_limit = int(20_000 / tokens_per_ticket)
999
+ else:
1000
+ recommended_limit = 20
1001
+
1002
+ return {
1003
+ "status": "error",
1004
+ "error": f"Response would exceed MCP token limit ({estimated_tokens:,} tokens)",
1005
+ "recommendation": (
1006
+ f"Use smaller limit (try limit={recommended_limit}), "
1007
+ "add filters (state=open, project_id=...), or enable compact mode"
1008
+ ),
1009
+ "current_settings": {
1010
+ "limit": limit,
1011
+ "compact": compact,
1012
+ "estimated_tokens": estimated_tokens,
1013
+ "max_allowed": 25_000,
1014
+ },
1015
+ }
1016
+
1017
+ # Add token estimate to successful response for monitoring
1018
+ response_data["estimated_tokens"] = estimated_tokens
1019
+
1020
+ return response_data
426
1021
  except Exception as e:
427
- return {
1022
+ error_response = {
428
1023
  "status": "error",
429
1024
  "error": f"Failed to list tickets: {str(e)}",
430
1025
  }
1026
+
1027
+ # Add diagnostic suggestion for system-level errors
1028
+ if should_suggest_diagnostics(e):
1029
+ logging.debug(
1030
+ "Error classified as system-level, adding diagnostic suggestion"
1031
+ )
1032
+ try:
1033
+ quick_info = await get_quick_diagnostic_info()
1034
+ error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
1035
+ e, quick_info
1036
+ )
1037
+ except Exception as diag_error:
1038
+ # Never block error response on diagnostic failure
1039
+ logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
1040
+
1041
+ return error_response
1042
+
1043
+
1044
+ async def ticket_summary(ticket_id: str) -> dict[str, Any]:
1045
+ """Get ultra-compact summary (id, title, state, priority, assignee only - ~20 tokens vs ~185 full).
1046
+
1047
+ .. deprecated:: 1.5.0
1048
+ Use :func:`ticket` with ``action='summary'`` instead.
1049
+ This function will be removed in version 2.0.0.
1050
+
1051
+ Args: ticket_id (ID or URL)
1052
+ Returns: SummaryResponse with minimal fields (90% token savings)
1053
+ See: docs/mcp-api-reference.md#compact-ticket-format
1054
+ """
1055
+ warnings.warn(
1056
+ "ticket_summary is deprecated. Use ticket(action='summary', ticket_id=...) instead. "
1057
+ "This function will be removed in version 2.0.0.",
1058
+ DeprecationWarning,
1059
+ stacklevel=2,
1060
+ )
1061
+ try:
1062
+ # Use ticket_read to get full ticket
1063
+ result = await ticket_read(ticket_id)
1064
+
1065
+ if result["status"] == "error":
1066
+ return result
1067
+
1068
+ ticket = result["ticket"]
1069
+
1070
+ # Extract only ultra-essential fields
1071
+ summary = {
1072
+ "id": ticket.get("id"),
1073
+ "title": ticket.get("title"),
1074
+ "state": ticket.get("state"),
1075
+ "priority": ticket.get("priority"),
1076
+ "assignee": ticket.get("assignee"),
1077
+ }
1078
+
1079
+ return {
1080
+ "status": "completed",
1081
+ **_build_adapter_metadata(
1082
+ get_adapter(), ticket.get("id"), result.get("routed_from_url", False)
1083
+ ),
1084
+ "summary": summary,
1085
+ "token_savings": "~90% smaller than full ticket_read",
1086
+ }
1087
+ except Exception as e:
1088
+ return {
1089
+ "status": "error",
1090
+ "error": f"Failed to get ticket summary: {str(e)}",
1091
+ }
1092
+
1093
+
1094
+ async def ticket_latest(ticket_id: str, limit: int = 5) -> dict[str, Any]:
1095
+ """Get recent activity (comments, state changes, updates - adapter-dependent behavior).
1096
+
1097
+ .. deprecated:: 1.5.0
1098
+ Use :func:`ticket` with ``action='get_activity'`` instead.
1099
+ This function will be removed in version 2.0.0.
1100
+
1101
+ Args: ticket_id (ID or URL), limit (max: 20, default: 5)
1102
+ Returns: ActivityResponse with recent activities, timestamps, change descriptions
1103
+ See: docs/mcp-api-reference.md#activity-response-format
1104
+ """
1105
+ warnings.warn(
1106
+ "ticket_latest is deprecated. Use ticket(action='get_activity', ticket_id=...) instead. "
1107
+ "This function will be removed in version 2.0.0.",
1108
+ DeprecationWarning,
1109
+ stacklevel=2,
1110
+ )
1111
+ try:
1112
+ # Validate limit
1113
+ if limit < 1 or limit > 20:
1114
+ return {
1115
+ "status": "error",
1116
+ "error": "Limit must be between 1 and 20",
1117
+ }
1118
+
1119
+ # Route to appropriate adapter
1120
+ is_routed = False
1121
+ if is_url(ticket_id) and has_router():
1122
+ router = get_router()
1123
+ logging.info(f"Routing ticket_latest for URL: {ticket_id}")
1124
+ # First get the ticket to verify it exists
1125
+ ticket = await router.route_read(ticket_id)
1126
+ is_routed = True
1127
+ normalized_id, adapter_name, _ = router._normalize_ticket_id(ticket_id)
1128
+ adapter = router._get_adapter(adapter_name)
1129
+ actual_ticket_id = normalized_id
1130
+ else:
1131
+ adapter = get_adapter()
1132
+
1133
+ # If URL provided, extract ID for the adapter
1134
+ actual_ticket_id = ticket_id
1135
+ if is_url(ticket_id):
1136
+ adapter_type = type(adapter).__name__.lower().replace("adapter", "")
1137
+ extracted_id, error = extract_id_from_url(
1138
+ ticket_id, adapter_type=adapter_type
1139
+ )
1140
+ if error or not extracted_id:
1141
+ return {
1142
+ "status": "error",
1143
+ "error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
1144
+ }
1145
+ actual_ticket_id = extracted_id
1146
+
1147
+ # Get ticket to verify it exists
1148
+ ticket = await adapter.read(actual_ticket_id)
1149
+
1150
+ if ticket is None:
1151
+ return {
1152
+ "status": "error",
1153
+ "error": f"Ticket {ticket_id} not found",
1154
+ }
1155
+
1156
+ # Try to get comments if adapter supports it
1157
+ recent_activity = []
1158
+ supports_comments = False
1159
+
1160
+ try:
1161
+ # Check if adapter has list_comments method
1162
+ if hasattr(adapter, "list_comments"):
1163
+ comments = await adapter.list_comments(actual_ticket_id, limit=limit)
1164
+ supports_comments = True
1165
+
1166
+ # Convert comments to activity format
1167
+ for comment in comments[:limit]:
1168
+ activity_item = {
1169
+ "type": "comment",
1170
+ "timestamp": (
1171
+ comment.created_at
1172
+ if hasattr(comment, "created_at")
1173
+ else None
1174
+ ),
1175
+ "author": (
1176
+ comment.author if hasattr(comment, "author") else None
1177
+ ),
1178
+ "content": comment.content[:200]
1179
+ + ("..." if len(comment.content) > 200 else ""),
1180
+ }
1181
+ recent_activity.append(activity_item)
1182
+ except Exception as e:
1183
+ logging.debug(f"Comment listing not supported or failed: {e}")
1184
+
1185
+ # If no comments available, provide last update info
1186
+ if not recent_activity:
1187
+ recent_activity.append(
1188
+ {
1189
+ "type": "last_update",
1190
+ "timestamp": (
1191
+ ticket.updated_at if hasattr(ticket, "updated_at") else None
1192
+ ),
1193
+ "state": ticket.state,
1194
+ "priority": ticket.priority,
1195
+ "assignee": ticket.assignee,
1196
+ }
1197
+ )
1198
+
1199
+ return {
1200
+ "status": "completed",
1201
+ **_build_adapter_metadata(adapter, ticket.id, is_routed),
1202
+ "ticket_id": ticket.id,
1203
+ "ticket_title": ticket.title,
1204
+ "recent_activity": recent_activity,
1205
+ "activity_count": len(recent_activity),
1206
+ "supports_full_history": supports_comments,
1207
+ "limit": limit,
1208
+ }
1209
+
1210
+ except Exception as e:
1211
+ error_response = {
1212
+ "status": "error",
1213
+ "error": f"Failed to get recent activity: {str(e)}",
1214
+ }
1215
+
1216
+ # Add diagnostic suggestion for system-level errors
1217
+ if should_suggest_diagnostics(e):
1218
+ logging.debug(
1219
+ "Error classified as system-level, adding diagnostic suggestion"
1220
+ )
1221
+ try:
1222
+ quick_info = await get_quick_diagnostic_info()
1223
+ error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
1224
+ e, quick_info
1225
+ )
1226
+ except Exception as diag_error:
1227
+ logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
1228
+
1229
+ return error_response
1230
+
1231
+
1232
+ async def ticket_assign(
1233
+ ticket_id: str,
1234
+ assignee: str | None,
1235
+ comment: str | None = None,
1236
+ auto_transition: bool = True,
1237
+ ) -> dict[str, Any]:
1238
+ """Assign/unassign ticket with auto-transition to IN_PROGRESS (OPEN/WAITING/BLOCKED → IN_PROGRESS when assigned).
1239
+
1240
+ .. deprecated:: 1.5.0
1241
+ Use :func:`ticket` with ``action='assign'`` instead.
1242
+ This function will be removed in version 2.0.0.
1243
+
1244
+ Args: ticket_id (ID or URL), assignee (user ID/email or None to unassign), comment (optional audit trail), auto_transition (default: True)
1245
+ Returns: AssignmentResponse with ticket, previous/new assignee, previous/new state, state_auto_transitioned, comment_added
1246
+ See: docs/ticket-workflows.md#auto-transitions, docs/mcp-api-reference.md#user-identifiers
1247
+ """
1248
+ warnings.warn(
1249
+ "ticket_assign is deprecated. Use ticket(action='assign', ticket_id=...) instead. "
1250
+ "This function will be removed in version 2.0.0.",
1251
+ DeprecationWarning,
1252
+ stacklevel=2,
1253
+ )
1254
+ try:
1255
+ # Read current ticket to get previous assignee
1256
+ is_routed = False
1257
+ if is_url(ticket_id) and has_router():
1258
+ router = get_router()
1259
+ logging.info(f"Routing ticket_assign for URL: {ticket_id}")
1260
+ ticket = await router.route_read(ticket_id)
1261
+ is_routed = True
1262
+ normalized_id, adapter_name, _ = router._normalize_ticket_id(ticket_id)
1263
+ adapter = router._get_adapter(adapter_name)
1264
+ else:
1265
+ adapter = get_adapter()
1266
+
1267
+ # If URL provided, extract ID for the adapter
1268
+ actual_ticket_id = ticket_id
1269
+ if is_url(ticket_id):
1270
+ # Extract ID from URL for default adapter
1271
+ adapter_type = type(adapter).__name__.lower().replace("adapter", "")
1272
+ extracted_id, error = extract_id_from_url(
1273
+ ticket_id, adapter_type=adapter_type
1274
+ )
1275
+ if error or not extracted_id:
1276
+ return {
1277
+ "status": "error",
1278
+ "error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
1279
+ }
1280
+ actual_ticket_id = extracted_id
1281
+
1282
+ ticket = await adapter.read(actual_ticket_id)
1283
+
1284
+ if ticket is None:
1285
+ return {
1286
+ "status": "error",
1287
+ "error": f"Ticket {ticket_id} not found",
1288
+ }
1289
+
1290
+ # Store previous assignee and state for response
1291
+ previous_assignee = ticket.assignee
1292
+ current_state = ticket.state
1293
+
1294
+ # Import TicketState for state transitions
1295
+ from ....core.models import TicketState
1296
+
1297
+ # Convert string state to enum if needed (Pydantic uses use_enum_values=True)
1298
+ if isinstance(current_state, str):
1299
+ current_state = TicketState(current_state)
1300
+
1301
+ # Build updates dictionary
1302
+ updates: dict[str, Any] = {"assignee": assignee}
1303
+
1304
+ # Auto-transition logic
1305
+ state_transitioned = False
1306
+ auto_comment = None
1307
+
1308
+ if (
1309
+ auto_transition and assignee is not None
1310
+ ): # Only when assigning (not unassigning)
1311
+ # Check if current state should auto-transition to IN_PROGRESS
1312
+ if current_state in [
1313
+ TicketState.OPEN,
1314
+ TicketState.WAITING,
1315
+ TicketState.BLOCKED,
1316
+ ]:
1317
+ # Validate workflow allows this transition
1318
+ if current_state.can_transition_to(TicketState.IN_PROGRESS):
1319
+ updates["state"] = TicketState.IN_PROGRESS
1320
+ state_transitioned = True
1321
+
1322
+ # Add automatic comment if no comment provided
1323
+ if comment is None:
1324
+ auto_comment = f"Automatically transitioned from {current_state.value} to in_progress when assigned to {assignee}"
1325
+ else:
1326
+ # Log warning if transition validation fails (shouldn't happen based on our rules)
1327
+ logging.warning(
1328
+ f"State transition from {current_state.value} to IN_PROGRESS failed validation"
1329
+ )
1330
+
1331
+ if is_routed:
1332
+ updated = await router.route_update(ticket_id, updates)
1333
+ else:
1334
+ updated = await adapter.update(actual_ticket_id, updates)
1335
+
1336
+ if updated is None:
1337
+ return {
1338
+ "status": "error",
1339
+ "error": f"Failed to update assignment for ticket {ticket_id}",
1340
+ }
1341
+
1342
+ # Add comment if provided or auto-generated, and adapter supports it
1343
+ comment_added = False
1344
+ comment_to_add = comment or auto_comment
1345
+
1346
+ if comment_to_add:
1347
+ try:
1348
+ from ....core.models import Comment as CommentModel
1349
+
1350
+ # Use actual_ticket_id for non-routed case, original ticket_id for routed
1351
+ comment_ticket_id = ticket_id if is_routed else actual_ticket_id
1352
+
1353
+ comment_obj = CommentModel(
1354
+ ticket_id=comment_ticket_id, content=comment_to_add, author=""
1355
+ )
1356
+
1357
+ if is_routed:
1358
+ await router.route_add_comment(ticket_id, comment_obj)
1359
+ else:
1360
+ await adapter.add_comment(comment_obj)
1361
+ comment_added = True
1362
+ except Exception as e:
1363
+ # Comment failed but assignment succeeded - log and continue
1364
+ logging.warning(f"Assignment succeeded but comment failed: {str(e)}")
1365
+
1366
+ # Build response
1367
+ # Handle both string and enum state values
1368
+ previous_state_value = (
1369
+ current_state.value
1370
+ if hasattr(current_state, "value")
1371
+ else str(current_state)
1372
+ )
1373
+ new_state_value = (
1374
+ updated.state.value
1375
+ if hasattr(updated.state, "value")
1376
+ else str(updated.state)
1377
+ )
1378
+
1379
+ response = {
1380
+ "status": "completed",
1381
+ **_build_adapter_metadata(adapter, updated.id, is_routed),
1382
+ "ticket": updated.model_dump(),
1383
+ "previous_assignee": previous_assignee,
1384
+ "new_assignee": assignee,
1385
+ "previous_state": previous_state_value,
1386
+ "new_state": new_state_value,
1387
+ "state_auto_transitioned": state_transitioned,
1388
+ "comment_added": comment_added,
1389
+ }
1390
+
1391
+ return response
1392
+
1393
+ except Exception as e:
1394
+ error_response = {
1395
+ "status": "error",
1396
+ "error": f"Failed to assign ticket: {str(e)}",
1397
+ }
1398
+
1399
+ # Add diagnostic suggestion for system-level errors
1400
+ if should_suggest_diagnostics(e):
1401
+ logging.debug(
1402
+ "Error classified as system-level, adding diagnostic suggestion"
1403
+ )
1404
+ try:
1405
+ quick_info = await get_quick_diagnostic_info()
1406
+ error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
1407
+ e, quick_info
1408
+ )
1409
+ except Exception as diag_error:
1410
+ # Never block error response on diagnostic failure
1411
+ logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
1412
+
1413
+ return error_response