mcp-ticketer 0.12.0__py3-none-any.whl → 2.0.1__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 (87) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +385 -6
  4. mcp_ticketer/adapters/asana/adapter.py +108 -0
  5. mcp_ticketer/adapters/asana/mappers.py +14 -0
  6. mcp_ticketer/adapters/github.py +525 -11
  7. mcp_ticketer/adapters/hybrid.py +47 -5
  8. mcp_ticketer/adapters/jira.py +521 -0
  9. mcp_ticketer/adapters/linear/adapter.py +1784 -101
  10. mcp_ticketer/adapters/linear/client.py +85 -3
  11. mcp_ticketer/adapters/linear/mappers.py +96 -8
  12. mcp_ticketer/adapters/linear/queries.py +168 -1
  13. mcp_ticketer/adapters/linear/types.py +80 -4
  14. mcp_ticketer/analysis/__init__.py +56 -0
  15. mcp_ticketer/analysis/dependency_graph.py +255 -0
  16. mcp_ticketer/analysis/health_assessment.py +304 -0
  17. mcp_ticketer/analysis/orphaned.py +218 -0
  18. mcp_ticketer/analysis/project_status.py +594 -0
  19. mcp_ticketer/analysis/similarity.py +224 -0
  20. mcp_ticketer/analysis/staleness.py +266 -0
  21. mcp_ticketer/automation/__init__.py +11 -0
  22. mcp_ticketer/automation/project_updates.py +378 -0
  23. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  24. mcp_ticketer/cli/auggie_configure.py +17 -5
  25. mcp_ticketer/cli/codex_configure.py +97 -61
  26. mcp_ticketer/cli/configure.py +851 -103
  27. mcp_ticketer/cli/cursor_configure.py +314 -0
  28. mcp_ticketer/cli/diagnostics.py +13 -12
  29. mcp_ticketer/cli/discover.py +5 -0
  30. mcp_ticketer/cli/gemini_configure.py +17 -5
  31. mcp_ticketer/cli/init_command.py +880 -0
  32. mcp_ticketer/cli/instruction_commands.py +6 -0
  33. mcp_ticketer/cli/main.py +233 -3151
  34. mcp_ticketer/cli/mcp_configure.py +672 -98
  35. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  36. mcp_ticketer/cli/platform_detection.py +77 -12
  37. mcp_ticketer/cli/platform_installer.py +536 -0
  38. mcp_ticketer/cli/project_update_commands.py +350 -0
  39. mcp_ticketer/cli/setup_command.py +639 -0
  40. mcp_ticketer/cli/simple_health.py +12 -10
  41. mcp_ticketer/cli/ticket_commands.py +264 -24
  42. mcp_ticketer/core/__init__.py +28 -6
  43. mcp_ticketer/core/adapter.py +166 -1
  44. mcp_ticketer/core/config.py +21 -21
  45. mcp_ticketer/core/exceptions.py +7 -1
  46. mcp_ticketer/core/label_manager.py +732 -0
  47. mcp_ticketer/core/mappers.py +31 -19
  48. mcp_ticketer/core/models.py +135 -0
  49. mcp_ticketer/core/onepassword_secrets.py +1 -1
  50. mcp_ticketer/core/priority_matcher.py +463 -0
  51. mcp_ticketer/core/project_config.py +132 -14
  52. mcp_ticketer/core/session_state.py +171 -0
  53. mcp_ticketer/core/state_matcher.py +592 -0
  54. mcp_ticketer/core/url_parser.py +425 -0
  55. mcp_ticketer/core/validators.py +69 -0
  56. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  57. mcp_ticketer/mcp/server/main.py +106 -25
  58. mcp_ticketer/mcp/server/routing.py +655 -0
  59. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  60. mcp_ticketer/mcp/server/tools/__init__.py +31 -12
  61. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  62. mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
  63. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  64. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  65. mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
  66. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  67. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  68. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  69. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  70. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  71. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  72. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  73. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  74. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  75. mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
  76. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  77. mcp_ticketer/queue/worker.py +1 -1
  78. mcp_ticketer/utils/__init__.py +5 -0
  79. mcp_ticketer/utils/token_utils.py +246 -0
  80. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  81. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  82. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  83. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  84. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  85. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  86. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  87. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ This module implements advanced search capabilities for tickets using
4
4
  various filters and criteria.
5
5
  """
6
6
 
7
+ import logging
7
8
  from typing import Any
8
9
 
9
10
  from ....core.models import Priority, SearchQuery, TicketState
@@ -17,12 +18,37 @@ async def ticket_search(
17
18
  priority: str | None = None,
18
19
  tags: list[str] | None = None,
19
20
  assignee: str | None = None,
21
+ project_id: str | None = None,
20
22
  limit: int = 10,
23
+ include_hierarchy: bool = False,
24
+ include_children: bool = True,
25
+ max_depth: int = 3,
21
26
  ) -> dict[str, Any]:
22
- """Search tickets using advanced filters.
27
+ """Search tickets with optional hierarchy information.
28
+
29
+ **Consolidates:**
30
+ - ticket_search() → Default behavior (include_hierarchy=False)
31
+ - ticket_search_hierarchy() → Set include_hierarchy=True
32
+
33
+ ⚠️ Project Filtering Required:
34
+ This tool requires project_id parameter OR default_project configuration.
35
+ To set default project: config_set_default_project(project_id="YOUR-PROJECT")
36
+ To check current config: config_get()
23
37
 
24
- Searches for tickets matching the specified criteria. All filters are
25
- optional and can be combined.
38
+ Exception: Single ticket operations (ticket_read) don't require project filtering.
39
+
40
+ **Search Filters:**
41
+ - query: Text search in title and description
42
+ - state: Filter by workflow state
43
+ - priority: Filter by priority level
44
+ - tags: Filter by tags (AND logic)
45
+ - assignee: Filter by assigned user
46
+ - project_id: Scope to specific project
47
+
48
+ **Hierarchy Options:**
49
+ - include_hierarchy: Include parent/child relationships (default: False)
50
+ - include_children: Include child tickets (default: True, requires include_hierarchy=True)
51
+ - max_depth: Maximum hierarchy depth (default: 3, requires include_hierarchy=True)
26
52
 
27
53
  Args:
28
54
  query: Text search query to match against title and description
@@ -30,15 +56,56 @@ async def ticket_search(
30
56
  priority: Filter by priority - must be one of: low, medium, high, critical
31
57
  tags: Filter by tags - tickets must have all specified tags
32
58
  assignee: Filter by assigned user ID or email
59
+ project_id: Project/epic ID (required unless default_project configured)
33
60
  limit: Maximum number of results to return (default: 10, max: 100)
61
+ include_hierarchy: Include parent/child relationships (default: False)
62
+ include_children: Include child tickets in hierarchy (default: True)
63
+ max_depth: Maximum hierarchy depth to traverse (default: 3)
34
64
 
35
65
  Returns:
36
66
  List of tickets matching search criteria, or error information
37
67
 
68
+ Examples:
69
+ # Simple search (backward compatible)
70
+ await ticket_search(query="authentication bug", state="open", limit=5)
71
+
72
+ # Search with hierarchy
73
+ await ticket_search(
74
+ query="oauth implementation",
75
+ project_id="proj-123",
76
+ include_hierarchy=True,
77
+ max_depth=2
78
+ )
79
+
38
80
  """
39
81
  try:
82
+ # Validate project context (NEW: Required for search operations)
83
+ from pathlib import Path
84
+
85
+ from ....core.project_config import ConfigResolver
86
+
87
+ resolver = ConfigResolver(project_path=Path.cwd())
88
+ config = resolver.load_project_config()
89
+ final_project = project_id or (config.default_project if config else None)
90
+
91
+ if not final_project:
92
+ return {
93
+ "status": "error",
94
+ "error": "project_id required. Provide project_id parameter or configure default_project.",
95
+ "help": "Use config_set_default_project(project_id='YOUR-PROJECT') to set default project",
96
+ "check_config": "Use config_get() to view current configuration",
97
+ }
98
+
40
99
  adapter = get_adapter()
41
100
 
101
+ # Add warning for unscoped searches
102
+ if not query and not (state or priority or tags or assignee):
103
+ logging.warning(
104
+ "Unscoped search with no query or filters. "
105
+ "This will search ALL tickets across all projects. "
106
+ "Tip: Configure default_project or default_team for automatic scoping."
107
+ )
108
+
42
109
  # Validate and build search query
43
110
  state_enum = None
44
111
  if state is not None:
@@ -60,19 +127,99 @@ async def ticket_search(
60
127
  "error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
61
128
  }
62
129
 
63
- # Create search query
130
+ # Create search query with project scoping
64
131
  search_query = SearchQuery(
65
132
  query=query,
66
133
  state=state_enum,
67
134
  priority=priority_enum,
68
135
  tags=tags,
69
136
  assignee=assignee,
137
+ project=final_project, # Always required for search operations
70
138
  limit=min(limit, 100), # Enforce max limit
71
139
  )
72
140
 
73
141
  # Execute search via adapter
74
142
  results = await adapter.search(search_query)
75
143
 
144
+ # Add hierarchy if requested
145
+ if include_hierarchy:
146
+ # Validate max_depth
147
+ if max_depth < 1 or max_depth > 3:
148
+ return {
149
+ "status": "error",
150
+ "error": "max_depth must be between 1 and 3",
151
+ }
152
+
153
+ # Build hierarchical results
154
+ hierarchical_results = []
155
+ for ticket in results:
156
+ ticket_data = {
157
+ "ticket": ticket.model_dump(),
158
+ "hierarchy": {},
159
+ }
160
+
161
+ # Get parent epic if applicable
162
+ parent_epic_id = getattr(ticket, "parent_epic", None)
163
+ if parent_epic_id and max_depth >= 2:
164
+ try:
165
+ parent_epic = await adapter.read(parent_epic_id)
166
+ if parent_epic:
167
+ ticket_data["hierarchy"][
168
+ "parent_epic"
169
+ ] = parent_epic.model_dump()
170
+ except Exception:
171
+ pass # Parent not found, continue
172
+
173
+ # Get parent issue if applicable (for tasks)
174
+ parent_issue_id = getattr(ticket, "parent_issue", None)
175
+ if parent_issue_id and max_depth >= 2:
176
+ try:
177
+ parent_issue = await adapter.read(parent_issue_id)
178
+ if parent_issue:
179
+ ticket_data["hierarchy"][
180
+ "parent_issue"
181
+ ] = parent_issue.model_dump()
182
+ except Exception:
183
+ pass # Parent not found, continue
184
+
185
+ # Get children if requested
186
+ if include_children and max_depth >= 2:
187
+ children = []
188
+
189
+ # Get child issues (for epics)
190
+ child_issue_ids = getattr(ticket, "child_issues", [])
191
+ for child_id in child_issue_ids:
192
+ try:
193
+ child = await adapter.read(child_id)
194
+ if child:
195
+ children.append(child.model_dump())
196
+ except Exception:
197
+ pass # Child not found, continue
198
+
199
+ # Get child tasks (for issues)
200
+ child_task_ids = getattr(ticket, "children", [])
201
+ for child_id in child_task_ids:
202
+ try:
203
+ child = await adapter.read(child_id)
204
+ if child:
205
+ children.append(child.model_dump())
206
+ except Exception:
207
+ pass # Child not found, continue
208
+
209
+ if children:
210
+ ticket_data["hierarchy"]["children"] = children
211
+
212
+ hierarchical_results.append(ticket_data)
213
+
214
+ return {
215
+ "status": "completed",
216
+ "results": hierarchical_results,
217
+ "count": len(hierarchical_results),
218
+ "query": query,
219
+ "max_depth": max_depth,
220
+ }
221
+
222
+ # Standard search response
76
223
  return {
77
224
  "status": "completed",
78
225
  "tickets": [ticket.model_dump() for ticket in results],
@@ -83,6 +230,7 @@ async def ticket_search(
83
230
  "priority": priority,
84
231
  "tags": tags,
85
232
  "assignee": assignee,
233
+ "project": final_project,
86
234
  },
87
235
  }
88
236
  except Exception as e:
@@ -95,112 +243,47 @@ async def ticket_search(
95
243
  @mcp.tool()
96
244
  async def ticket_search_hierarchy(
97
245
  query: str,
246
+ project_id: str | None = None,
98
247
  include_children: bool = True,
99
248
  max_depth: int = 3,
100
249
  ) -> dict[str, Any]:
101
- """Search tickets and include their hierarchy.
250
+ """DEPRECATED: Use ticket_search(include_hierarchy=True, ...) instead.
102
251
 
103
- Performs a text search and returns matching tickets along with their
104
- hierarchical context (parent epics/issues and child issues/tasks).
252
+ This tool will be removed in v2.0.0. Migrate to the unified ticket_search tool.
105
253
 
106
254
  Args:
107
255
  query: Text search query to match against title and description
256
+ project_id: Project/epic ID (required unless default_project configured)
108
257
  include_children: Whether to include child tickets in results
109
258
  max_depth: Maximum hierarchy depth to include (1-3, default: 3)
110
259
 
111
260
  Returns:
112
261
  List of tickets with hierarchy information, or error information
113
262
 
114
- """
115
- try:
116
- adapter = get_adapter()
117
-
118
- # Validate max_depth
119
- if max_depth < 1 or max_depth > 3:
120
- return {
121
- "status": "error",
122
- "error": "max_depth must be between 1 and 3",
123
- }
124
-
125
- # Create search query
126
- search_query = SearchQuery(
127
- query=query,
128
- limit=50, # Reasonable limit for hierarchical search
129
- )
263
+ Migration:
264
+ Before (ticket_search_hierarchy):
265
+ >>> await ticket_search_hierarchy(query="feature", project_id="proj-123", max_depth=2)
130
266
 
131
- # Execute search via adapter
132
- results = await adapter.search(search_query)
267
+ After (ticket_search with include_hierarchy):
268
+ >>> await ticket_search(query="feature", project_id="proj-123", include_hierarchy=True, max_depth=2)
133
269
 
134
- # Build hierarchical results
135
- hierarchical_results = []
136
- for ticket in results:
137
- ticket_data = {
138
- "ticket": ticket.model_dump(),
139
- "hierarchy": {},
140
- }
270
+ See: docs/UPGRADING-v2.0.md#ticket-search-consolidation
141
271
 
142
- # Get parent epic if applicable
143
- parent_epic_id = getattr(ticket, "parent_epic", None)
144
- if parent_epic_id and max_depth >= 2:
145
- try:
146
- parent_epic = await adapter.read(parent_epic_id)
147
- if parent_epic:
148
- ticket_data["hierarchy"][
149
- "parent_epic"
150
- ] = parent_epic.model_dump()
151
- except Exception:
152
- pass # Parent not found, continue
153
-
154
- # Get parent issue if applicable (for tasks)
155
- parent_issue_id = getattr(ticket, "parent_issue", None)
156
- if parent_issue_id and max_depth >= 2:
157
- try:
158
- parent_issue = await adapter.read(parent_issue_id)
159
- if parent_issue:
160
- ticket_data["hierarchy"][
161
- "parent_issue"
162
- ] = parent_issue.model_dump()
163
- except Exception:
164
- pass # Parent not found, continue
165
-
166
- # Get children if requested
167
- if include_children and max_depth >= 2:
168
- children = []
169
-
170
- # Get child issues (for epics)
171
- child_issue_ids = getattr(ticket, "child_issues", [])
172
- for child_id in child_issue_ids:
173
- try:
174
- child = await adapter.read(child_id)
175
- if child:
176
- children.append(child.model_dump())
177
- except Exception:
178
- pass # Child not found, continue
179
-
180
- # Get child tasks (for issues)
181
- child_task_ids = getattr(ticket, "children", [])
182
- for child_id in child_task_ids:
183
- try:
184
- child = await adapter.read(child_id)
185
- if child:
186
- children.append(child.model_dump())
187
- except Exception:
188
- pass # Child not found, continue
189
-
190
- if children:
191
- ticket_data["hierarchy"]["children"] = children
192
-
193
- hierarchical_results.append(ticket_data)
194
-
195
- return {
196
- "status": "completed",
197
- "results": hierarchical_results,
198
- "count": len(hierarchical_results),
199
- "query": query,
200
- "max_depth": max_depth,
201
- }
202
- except Exception as e:
203
- return {
204
- "status": "error",
205
- "error": f"Failed to search with hierarchy: {str(e)}",
206
- }
272
+ """
273
+ import warnings
274
+
275
+ warnings.warn(
276
+ "ticket_search_hierarchy is deprecated. Use ticket_search(include_hierarchy=True, ...) instead. "
277
+ "See docs/UPGRADING-v2.0.md#ticket-search-consolidation",
278
+ DeprecationWarning,
279
+ stacklevel=2,
280
+ )
281
+
282
+ # Route to unified ticket_search tool
283
+ return await ticket_search(
284
+ query=query,
285
+ project_id=project_id,
286
+ include_hierarchy=True,
287
+ include_children=include_children,
288
+ max_depth=max_depth,
289
+ )
@@ -0,0 +1,308 @@
1
+ """MCP tools for session and ticket association management.
2
+
3
+ This module implements tools for session management and user ticket operations.
4
+
5
+ Features:
6
+ - user_session: Unified interface for user ticket queries and session info
7
+ - attach_ticket: Associate work session with ticket
8
+
9
+ All tools follow the MCP response pattern:
10
+ {
11
+ "status": "completed" | "error",
12
+ "data": {...}
13
+ }
14
+ """
15
+
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ from ....core.session_state import SessionStateManager
21
+ from ..server_sdk import mcp
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @mcp.tool()
27
+ async def user_session(
28
+ action: Literal["get_my_tickets", "get_session_info"],
29
+ state: str | None = None,
30
+ project_id: str | None = None,
31
+ limit: int = 10,
32
+ ) -> dict[str, Any]:
33
+ """Unified user session management tool.
34
+
35
+ Handles user ticket queries and session information through a single
36
+ interface. This tool consolidates get_my_tickets and get_session_info.
37
+
38
+ Args:
39
+ action: Operation to perform. Valid values:
40
+ - "get_my_tickets": Get tickets assigned to default user
41
+ - "get_session_info": Get current session information
42
+ state: Filter tickets by state (for get_my_tickets only)
43
+ project_id: Filter tickets by project (for get_my_tickets only)
44
+ limit: Maximum tickets to return (for get_my_tickets, default: 10, max: 100)
45
+
46
+ Returns:
47
+ Results dictionary containing operation-specific data
48
+
49
+ Raises:
50
+ ValueError: If action is invalid
51
+
52
+ Examples:
53
+ # Get user's tickets
54
+ result = await user_session(
55
+ action="get_my_tickets",
56
+ state="open",
57
+ limit=20
58
+ )
59
+
60
+ # Get user's tickets with project filter
61
+ result = await user_session(
62
+ action="get_my_tickets",
63
+ project_id="PROJ-123",
64
+ state="in_progress"
65
+ )
66
+
67
+ # Get session info
68
+ result = await user_session(
69
+ action="get_session_info"
70
+ )
71
+
72
+ Migration from old tools:
73
+ - get_my_tickets(state=..., limit=...) → user_session(action="get_my_tickets", state=..., limit=...)
74
+ - get_session_info() → user_session(action="get_session_info")
75
+
76
+ See: docs/mcp-api-reference.md for detailed response formats
77
+ """
78
+ action_lower = action.lower()
79
+
80
+ # Route to appropriate handler based on action
81
+ if action_lower == "get_my_tickets":
82
+ # Inline implementation of get_my_tickets
83
+ try:
84
+ from ....core.models import TicketState
85
+ from ....core.project_config import ConfigResolver, TicketerConfig
86
+ from ..server_sdk import get_adapter
87
+
88
+ # Validate limit
89
+ if limit > 100:
90
+ limit = 100
91
+
92
+ # Load configuration to get default user and project
93
+ resolver = ConfigResolver(project_path=Path.cwd())
94
+ config = resolver.load_project_config() or TicketerConfig()
95
+
96
+ if not config.default_user:
97
+ return {
98
+ "status": "error",
99
+ "error": "No default user configured. Use config_set_default_user() to set a default user first.",
100
+ "setup_command": "config_set_default_user",
101
+ }
102
+
103
+ # Validate project context (Required for list operations)
104
+ final_project = project_id or config.default_project
105
+
106
+ if not final_project:
107
+ return {
108
+ "status": "error",
109
+ "error": "project_id required. Provide project_id parameter or configure default_project.",
110
+ "help": "Use config_set_default_project(project_id='YOUR-PROJECT') to set default project",
111
+ "check_config": "Use config_get() to view current configuration",
112
+ }
113
+
114
+ # Validate state if provided
115
+ state_filter = None
116
+ if state is not None:
117
+ try:
118
+ state_filter = TicketState(state.lower())
119
+ except ValueError:
120
+ valid_states = [s.value for s in TicketState]
121
+ return {
122
+ "status": "error",
123
+ "error": f"Invalid state '{state}'. Must be one of: {', '.join(valid_states)}",
124
+ "valid_states": valid_states,
125
+ }
126
+
127
+ # Build filters with required project scoping
128
+ filters: dict[str, Any] = {
129
+ "assignee": config.default_user,
130
+ "project": final_project,
131
+ }
132
+ if state_filter:
133
+ filters["state"] = state_filter
134
+
135
+ # Query adapter
136
+ adapter = get_adapter()
137
+ tickets = await adapter.list(limit=limit, offset=0, filters=filters)
138
+
139
+ # Build adapter metadata
140
+ metadata = {
141
+ "adapter": adapter.adapter_type,
142
+ "adapter_name": adapter.adapter_display_name,
143
+ }
144
+
145
+ return {
146
+ "status": "completed",
147
+ **metadata,
148
+ "tickets": [ticket.model_dump() for ticket in tickets],
149
+ "count": len(tickets),
150
+ "user": config.default_user,
151
+ "state_filter": state if state else "all",
152
+ "limit": limit,
153
+ }
154
+ except Exception as e:
155
+ return {
156
+ "status": "error",
157
+ "error": f"Failed to retrieve tickets: {str(e)}",
158
+ }
159
+ elif action_lower == "get_session_info":
160
+ # Inline implementation of get_session_info
161
+ try:
162
+ manager = SessionStateManager(project_path=Path.cwd())
163
+ state_obj = manager.load_session()
164
+
165
+ return {
166
+ "success": True,
167
+ "session_id": state_obj.session_id,
168
+ "current_ticket": state_obj.current_ticket,
169
+ "opted_out": state_obj.ticket_opted_out,
170
+ "last_activity": state_obj.last_activity,
171
+ "session_timeout_minutes": 30,
172
+ }
173
+
174
+ except Exception as e:
175
+ logger.error(f"Error in get_session_info: {e}")
176
+ return {
177
+ "success": False,
178
+ "error": str(e),
179
+ }
180
+ else:
181
+ valid_actions = ["get_my_tickets", "get_session_info"]
182
+ return {
183
+ "status": "error",
184
+ "error": f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}",
185
+ "valid_actions": valid_actions,
186
+ "hint": "Use user_session(action='get_my_tickets'|'get_session_info', ...)",
187
+ }
188
+
189
+
190
+ @mcp.tool()
191
+ async def attach_ticket(
192
+ action: str,
193
+ ticket_id: str | None = None,
194
+ ) -> dict[str, Any]:
195
+ """Associate current work session with a ticket.
196
+
197
+ This tool helps track which ticket your current work is related to.
198
+ The association persists for the session (30 minutes of inactivity).
199
+
200
+ **Important**: It's recommended to associate work with a ticket for proper
201
+ tracking and organization.
202
+
203
+ Actions:
204
+ - **set**: Associate work with a specific ticket
205
+ - **clear**: Remove current ticket association
206
+ - **none**: Opt out of ticket association for this session
207
+ - **status**: Check current ticket association
208
+
209
+ Args:
210
+ action: What to do with the ticket association (set/clear/none/status)
211
+ ticket_id: Ticket ID to associate (e.g., "PROJ-123", UUID), required for 'set'
212
+
213
+ Returns:
214
+ Success status and current session state
215
+
216
+ Examples:
217
+ # Associate with a ticket
218
+ attach_ticket(action="set", ticket_id="PROJ-123")
219
+
220
+ # Opt out for this session
221
+ attach_ticket(action="none")
222
+
223
+ # Check current status
224
+ attach_ticket(action="status")
225
+
226
+ """
227
+ try:
228
+ manager = SessionStateManager(project_path=Path.cwd())
229
+ state = manager.load_session()
230
+
231
+ if action == "set":
232
+ if not ticket_id:
233
+ return {
234
+ "success": False,
235
+ "error": "ticket_id is required when action='set'",
236
+ "guidance": "Please provide a ticket ID to associate with this session",
237
+ }
238
+
239
+ manager.set_current_ticket(ticket_id)
240
+ return {
241
+ "success": True,
242
+ "message": f"Work session now associated with ticket: {ticket_id}",
243
+ "current_ticket": ticket_id,
244
+ "session_id": state.session_id,
245
+ "opted_out": False,
246
+ }
247
+
248
+ elif action == "clear":
249
+ manager.set_current_ticket(None)
250
+ return {
251
+ "success": True,
252
+ "message": "Ticket association cleared",
253
+ "current_ticket": None,
254
+ "session_id": state.session_id,
255
+ "opted_out": False,
256
+ "guidance": "You can associate with a ticket anytime using attach_ticket(action='set', ticket_id='...')",
257
+ }
258
+
259
+ elif action == "none":
260
+ manager.opt_out_ticket()
261
+ return {
262
+ "success": True,
263
+ "message": "Opted out of ticket association for this session",
264
+ "current_ticket": None,
265
+ "session_id": state.session_id,
266
+ "opted_out": True,
267
+ "note": "This opt-out will reset after 30 minutes of inactivity",
268
+ }
269
+
270
+ elif action == "status":
271
+ current_ticket = manager.get_current_ticket()
272
+
273
+ if state.ticket_opted_out:
274
+ status_msg = "No ticket associated (opted out for this session)"
275
+ elif current_ticket:
276
+ status_msg = f"Currently associated with ticket: {current_ticket}"
277
+ else:
278
+ status_msg = "No ticket associated"
279
+
280
+ return {
281
+ "success": True,
282
+ "message": status_msg,
283
+ "current_ticket": current_ticket,
284
+ "session_id": state.session_id,
285
+ "opted_out": state.ticket_opted_out,
286
+ "guidance": (
287
+ (
288
+ "Associate with a ticket: attach_ticket(action='set', ticket_id='...')\n"
289
+ "Opt out: attach_ticket(action='none')"
290
+ )
291
+ if not current_ticket and not state.ticket_opted_out
292
+ else None
293
+ ),
294
+ }
295
+
296
+ else:
297
+ return {
298
+ "success": False,
299
+ "error": f"Invalid action: {action}",
300
+ "valid_actions": ["set", "clear", "none", "status"],
301
+ }
302
+
303
+ except Exception as e:
304
+ logger.error(f"Error in attach_ticket: {e}")
305
+ return {
306
+ "success": False,
307
+ "error": str(e),
308
+ }