mcp-ticketer 0.4.11__py3-none-any.whl → 0.12.0__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 (70) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +9 -3
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +313 -96
  10. mcp_ticketer/adapters/jira.py +251 -1
  11. mcp_ticketer/adapters/linear/adapter.py +524 -22
  12. mcp_ticketer/adapters/linear/client.py +61 -9
  13. mcp_ticketer/adapters/linear/mappers.py +9 -3
  14. mcp_ticketer/cache/memory.py +3 -3
  15. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  16. mcp_ticketer/cli/auggie_configure.py +1 -1
  17. mcp_ticketer/cli/codex_configure.py +80 -1
  18. mcp_ticketer/cli/configure.py +33 -43
  19. mcp_ticketer/cli/diagnostics.py +18 -16
  20. mcp_ticketer/cli/discover.py +288 -21
  21. mcp_ticketer/cli/gemini_configure.py +1 -1
  22. mcp_ticketer/cli/instruction_commands.py +429 -0
  23. mcp_ticketer/cli/linear_commands.py +99 -15
  24. mcp_ticketer/cli/main.py +1199 -227
  25. mcp_ticketer/cli/mcp_configure.py +1 -1
  26. mcp_ticketer/cli/migrate_config.py +12 -8
  27. mcp_ticketer/cli/platform_commands.py +6 -6
  28. mcp_ticketer/cli/platform_detection.py +412 -0
  29. mcp_ticketer/cli/queue_commands.py +15 -15
  30. mcp_ticketer/cli/simple_health.py +1 -1
  31. mcp_ticketer/cli/ticket_commands.py +14 -13
  32. mcp_ticketer/cli/update_checker.py +313 -0
  33. mcp_ticketer/cli/utils.py +45 -41
  34. mcp_ticketer/core/__init__.py +12 -0
  35. mcp_ticketer/core/adapter.py +4 -4
  36. mcp_ticketer/core/config.py +17 -10
  37. mcp_ticketer/core/env_discovery.py +33 -3
  38. mcp_ticketer/core/env_loader.py +7 -6
  39. mcp_ticketer/core/exceptions.py +3 -3
  40. mcp_ticketer/core/http_client.py +10 -10
  41. mcp_ticketer/core/instructions.py +405 -0
  42. mcp_ticketer/core/mappers.py +1 -1
  43. mcp_ticketer/core/models.py +1 -1
  44. mcp_ticketer/core/onepassword_secrets.py +379 -0
  45. mcp_ticketer/core/project_config.py +17 -1
  46. mcp_ticketer/core/registry.py +1 -1
  47. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  48. mcp_ticketer/mcp/__init__.py +2 -2
  49. mcp_ticketer/mcp/server/__init__.py +2 -2
  50. mcp_ticketer/mcp/server/main.py +82 -69
  51. mcp_ticketer/mcp/server/tools/__init__.py +9 -0
  52. mcp_ticketer/mcp/server/tools/attachment_tools.py +63 -16
  53. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  54. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +154 -5
  55. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  56. mcp_ticketer/mcp/server/tools/ticket_tools.py +157 -4
  57. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  58. mcp_ticketer/queue/health_monitor.py +1 -0
  59. mcp_ticketer/queue/manager.py +4 -4
  60. mcp_ticketer/queue/queue.py +3 -3
  61. mcp_ticketer/queue/run_worker.py +1 -1
  62. mcp_ticketer/queue/ticket_registry.py +2 -2
  63. mcp_ticketer/queue/worker.py +14 -12
  64. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +106 -52
  65. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  66. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  67. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  68. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  69. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  70. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,381 @@
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 AdapterType, ConfigResolver, TicketerConfig
33
+ from ..server_sdk import mcp
34
+
35
+
36
+ def get_resolver() -> ConfigResolver:
37
+ """Get or create the configuration resolver.
38
+
39
+ Returns:
40
+ ConfigResolver instance for current working directory
41
+
42
+ Design Decision: Uses CWD as project root, assuming MCP server
43
+ is started from project directory. This matches user expectations
44
+ and aligns with how other development tools operate.
45
+
46
+ Note: Creates a new resolver each time to avoid caching issues
47
+ in tests and ensure current working directory is always used.
48
+
49
+ """
50
+ return ConfigResolver(project_path=Path.cwd())
51
+
52
+
53
+ @mcp.tool()
54
+ async def config_set_primary_adapter(adapter: str) -> dict[str, Any]:
55
+ """Set the default adapter for ticket operations.
56
+
57
+ Updates the project-local configuration (.mcp-ticketer/config.json)
58
+ to use the specified adapter as the default for all ticket operations.
59
+
60
+ Args:
61
+ adapter: Adapter name to set as primary. Must be one of:
62
+ - "aitrackdown" (file-based tracking)
63
+ - "linear" (Linear.app)
64
+ - "github" (GitHub Issues)
65
+ - "jira" (Atlassian JIRA)
66
+
67
+ Returns:
68
+ Dictionary containing:
69
+ - status: "completed" or "error"
70
+ - message: Success or error message
71
+ - previous_adapter: Previous default adapter (if successful)
72
+ - new_adapter: New default adapter (if successful)
73
+ - error: Error details (if failed)
74
+
75
+ Example:
76
+ >>> result = await config_set_primary_adapter("linear")
77
+ >>> print(result)
78
+ {
79
+ "status": "completed",
80
+ "message": "Default adapter set to 'linear'",
81
+ "previous_adapter": "aitrackdown",
82
+ "new_adapter": "linear"
83
+ }
84
+
85
+ Error Conditions:
86
+ - Invalid adapter name: Returns error with valid options
87
+ - Configuration file write failure: Returns error with file path
88
+
89
+ """
90
+ try:
91
+ # Validate adapter name against registry
92
+ valid_adapters = [adapter_type.value for adapter_type in AdapterType]
93
+ if adapter.lower() not in valid_adapters:
94
+ return {
95
+ "status": "error",
96
+ "error": f"Invalid adapter '{adapter}'. Must be one of: {', '.join(valid_adapters)}",
97
+ "valid_adapters": valid_adapters,
98
+ }
99
+
100
+ # Load current configuration
101
+ resolver = get_resolver()
102
+ config = resolver.load_project_config() or TicketerConfig()
103
+
104
+ # Store previous adapter for response
105
+ previous_adapter = config.default_adapter
106
+
107
+ # Update default adapter
108
+ config.default_adapter = adapter.lower()
109
+
110
+ # Save configuration
111
+ resolver.save_project_config(config)
112
+
113
+ return {
114
+ "status": "completed",
115
+ "message": f"Default adapter set to '{adapter.lower()}'",
116
+ "previous_adapter": previous_adapter,
117
+ "new_adapter": adapter.lower(),
118
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
119
+ }
120
+ except Exception as e:
121
+ return {
122
+ "status": "error",
123
+ "error": f"Failed to set default adapter: {str(e)}",
124
+ }
125
+
126
+
127
+ @mcp.tool()
128
+ async def config_set_default_project(
129
+ project_id: str,
130
+ project_key: str | None = None,
131
+ ) -> dict[str, Any]:
132
+ """Set the default project/epic for new tickets.
133
+
134
+ Updates the project-local configuration to automatically assign new tickets
135
+ to the specified project or epic. This is useful for teams working primarily
136
+ on a single project or feature area.
137
+
138
+ Args:
139
+ project_id: Project or epic ID to set as default (required)
140
+ project_key: Optional project key (for adapters that use keys vs IDs)
141
+
142
+ Returns:
143
+ Dictionary containing:
144
+ - status: "completed" or "error"
145
+ - message: Success or error message
146
+ - previous_project: Previous default project (if any)
147
+ - new_project: New default project ID
148
+ - error: Error details (if failed)
149
+
150
+ Example:
151
+ >>> result = await config_set_default_project("PROJ-123")
152
+ >>> print(result)
153
+ {
154
+ "status": "completed",
155
+ "message": "Default project set to 'PROJ-123'",
156
+ "previous_project": None,
157
+ "new_project": "PROJ-123"
158
+ }
159
+
160
+ Usage Notes:
161
+ - This sets both default_project and default_epic (for backward compatibility)
162
+ - Empty string or null clears the default project
163
+ - Project ID is not validated (allows flexibility across adapters)
164
+
165
+ """
166
+ try:
167
+ # Load current configuration
168
+ resolver = get_resolver()
169
+ config = resolver.load_project_config() or TicketerConfig()
170
+
171
+ # Store previous project for response
172
+ previous_project = config.default_project or config.default_epic
173
+
174
+ # Update default project (and epic for backward compat)
175
+ config.default_project = project_id if project_id else None
176
+ config.default_epic = project_id if project_id else None
177
+
178
+ # Save configuration
179
+ resolver.save_project_config(config)
180
+
181
+ return {
182
+ "status": "completed",
183
+ "message": (
184
+ f"Default project set to '{project_id}'"
185
+ if project_id
186
+ else "Default project cleared"
187
+ ),
188
+ "previous_project": previous_project,
189
+ "new_project": project_id,
190
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
191
+ }
192
+ except Exception as e:
193
+ return {
194
+ "status": "error",
195
+ "error": f"Failed to set default project: {str(e)}",
196
+ }
197
+
198
+
199
+ @mcp.tool()
200
+ async def config_set_default_user(
201
+ user_id: str,
202
+ user_email: str | None = None,
203
+ ) -> dict[str, Any]:
204
+ """Set the default assignee for new tickets.
205
+
206
+ Updates the project-local configuration to automatically assign new tickets
207
+ to the specified user. Supports both user IDs and email addresses depending
208
+ on adapter requirements.
209
+
210
+ Args:
211
+ user_id: User identifier or email to set as default assignee (required)
212
+ user_email: Optional email (for adapters that require separate email field)
213
+
214
+ Returns:
215
+ Dictionary containing:
216
+ - status: "completed" or "error"
217
+ - message: Success or error message
218
+ - previous_user: Previous default user (if any)
219
+ - new_user: New default user ID
220
+ - error: Error details (if failed)
221
+
222
+ Example:
223
+ >>> result = await config_set_default_user("user123")
224
+ >>> print(result)
225
+ {
226
+ "status": "completed",
227
+ "message": "Default user set to 'user123'",
228
+ "previous_user": None,
229
+ "new_user": "user123"
230
+ }
231
+
232
+ Example with email:
233
+ >>> result = await config_set_default_user("user@example.com")
234
+ >>> print(result)
235
+ {
236
+ "status": "completed",
237
+ "message": "Default user set to 'user@example.com'",
238
+ "previous_user": "old_user@example.com",
239
+ "new_user": "user@example.com"
240
+ }
241
+
242
+ Usage Notes:
243
+ - User ID/email is not validated (allows flexibility across adapters)
244
+ - Empty string or null clears the default user
245
+ - Some adapters prefer email, others prefer user UUID
246
+
247
+ """
248
+ try:
249
+ # Load current configuration
250
+ resolver = get_resolver()
251
+ config = resolver.load_project_config() or TicketerConfig()
252
+
253
+ # Store previous user for response
254
+ previous_user = config.default_user
255
+
256
+ # Update default user
257
+ config.default_user = user_id if user_id else None
258
+
259
+ # Save configuration
260
+ resolver.save_project_config(config)
261
+
262
+ return {
263
+ "status": "completed",
264
+ "message": (
265
+ f"Default user set to '{user_id}'"
266
+ if user_id
267
+ else "Default user cleared"
268
+ ),
269
+ "previous_user": previous_user,
270
+ "new_user": user_id,
271
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
272
+ }
273
+ except Exception as e:
274
+ return {
275
+ "status": "error",
276
+ "error": f"Failed to set default user: {str(e)}",
277
+ }
278
+
279
+
280
+ @mcp.tool()
281
+ async def config_get() -> dict[str, Any]:
282
+ """Get current configuration settings.
283
+
284
+ Retrieves the current project-local configuration including default adapter,
285
+ project, user, and all adapter-specific settings.
286
+
287
+ Returns:
288
+ Dictionary containing:
289
+ - status: "completed" or "error"
290
+ - config: Complete configuration dictionary including:
291
+ - default_adapter: Primary adapter name
292
+ - default_project: Default project/epic ID (if set)
293
+ - default_user: Default assignee (if set)
294
+ - adapters: All adapter configurations
295
+ - hybrid_mode: Hybrid mode settings (if enabled)
296
+ - config_path: Path to configuration file
297
+ - error: Error details (if failed)
298
+
299
+ Example:
300
+ >>> result = await config_get()
301
+ >>> print(result)
302
+ {
303
+ "status": "completed",
304
+ "config": {
305
+ "default_adapter": "linear",
306
+ "default_project": "PROJ-123",
307
+ "default_user": "user@example.com",
308
+ "adapters": {
309
+ "linear": {"api_key": "***", "team_id": "..."}
310
+ }
311
+ },
312
+ "config_path": "/project/.mcp-ticketer/config.json"
313
+ }
314
+
315
+ Usage Notes:
316
+ - Sensitive values (API keys) are masked in the response
317
+ - Returns default values if no configuration file exists
318
+ - Configuration is merged from multiple sources (env vars, .env files, config.json)
319
+
320
+ """
321
+ try:
322
+ # Load current configuration
323
+ resolver = get_resolver()
324
+ config = resolver.load_project_config() or TicketerConfig()
325
+
326
+ # Convert to dictionary
327
+ config_dict = config.to_dict()
328
+
329
+ # Mask sensitive values (API keys, tokens)
330
+ masked_config = _mask_sensitive_values(config_dict)
331
+
332
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
333
+ config_exists = config_path.exists()
334
+
335
+ return {
336
+ "status": "completed",
337
+ "config": masked_config,
338
+ "config_path": str(config_path),
339
+ "config_exists": config_exists,
340
+ "message": (
341
+ "Configuration retrieved successfully"
342
+ if config_exists
343
+ else "No configuration file found, showing defaults"
344
+ ),
345
+ }
346
+ except Exception as e:
347
+ return {
348
+ "status": "error",
349
+ "error": f"Failed to retrieve configuration: {str(e)}",
350
+ }
351
+
352
+
353
+ def _mask_sensitive_values(config: dict[str, Any]) -> dict[str, Any]:
354
+ """Mask sensitive values in configuration dictionary.
355
+
356
+ Args:
357
+ config: Configuration dictionary
358
+
359
+ Returns:
360
+ Configuration dictionary with sensitive values masked
361
+
362
+ Implementation Details:
363
+ - Recursively processes nested dictionaries
364
+ - Masks any field containing: key, token, password, secret
365
+ - Preserves structure for debugging while protecting credentials
366
+
367
+ """
368
+ masked = {}
369
+ sensitive_keys = {"api_key", "token", "password", "secret", "api_token"}
370
+
371
+ for key, value in config.items():
372
+ if isinstance(value, dict):
373
+ # Recursively mask nested dictionaries
374
+ masked[key] = _mask_sensitive_values(value)
375
+ elif any(sensitive in key.lower() for sensitive in sensitive_keys):
376
+ # Mask sensitive values
377
+ masked[key] = "***" if value else None
378
+ else:
379
+ masked[key] = value
380
+
381
+ return masked
@@ -7,10 +7,13 @@ This module implements tools for managing the three-level ticket hierarchy:
7
7
  """
8
8
 
9
9
  from datetime import datetime
10
+ from pathlib import Path
10
11
  from typing import Any
11
12
 
12
13
  from ....core.models import Epic, Priority, Task, TicketType
14
+ from ....core.project_config import ConfigResolver, TicketerConfig
13
15
  from ..server_sdk import get_adapter, mcp
16
+ from .ticket_tools import detect_and_apply_labels
14
17
 
15
18
 
16
19
  @mcp.tool()
@@ -159,8 +162,13 @@ async def issue_create(
159
162
  epic_id: str | None = None,
160
163
  assignee: str | None = None,
161
164
  priority: str = "medium",
165
+ tags: list[str] | None = None,
166
+ auto_detect_labels: bool = True,
162
167
  ) -> dict[str, Any]:
163
- """Create a new issue (standard work item).
168
+ """Create a new issue (standard work item) with automatic label detection.
169
+
170
+ This tool automatically scans available labels/tags and intelligently
171
+ applies relevant ones based on the issue title and description.
164
172
 
165
173
  Args:
166
174
  title: Issue title (required)
@@ -168,6 +176,8 @@ async def issue_create(
168
176
  epic_id: Parent epic ID to link this issue to
169
177
  assignee: User ID or email to assign the issue to
170
178
  priority: Priority level - must be one of: low, medium, high, critical
179
+ tags: List of tags to categorize the issue (auto-detection adds to these)
180
+ auto_detect_labels: Automatically detect and apply relevant labels (default: True)
171
181
 
172
182
  Returns:
173
183
  Created issue details including ID and metadata, or error information
@@ -185,14 +195,41 @@ async def issue_create(
185
195
  "error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
186
196
  }
187
197
 
198
+ # Use default_user if no assignee specified
199
+ final_assignee = assignee
200
+ if final_assignee is None:
201
+ resolver = ConfigResolver(project_path=Path.cwd())
202
+ config = resolver.load_project_config() or TicketerConfig()
203
+ if config.default_user:
204
+ final_assignee = config.default_user
205
+
206
+ # Use default_project if no epic_id specified
207
+ final_epic_id = epic_id
208
+ if final_epic_id is None:
209
+ resolver = ConfigResolver(project_path=Path.cwd())
210
+ config = resolver.load_project_config() or TicketerConfig()
211
+ # Try default_project first, fall back to default_epic
212
+ if config.default_project:
213
+ final_epic_id = config.default_project
214
+ elif config.default_epic:
215
+ final_epic_id = config.default_epic
216
+
217
+ # Auto-detect labels if enabled
218
+ final_tags = tags
219
+ if auto_detect_labels:
220
+ final_tags = await detect_and_apply_labels(
221
+ adapter, title, description or "", tags
222
+ )
223
+
188
224
  # Create issue (Task with ISSUE type)
189
225
  issue = Task(
190
226
  title=title,
191
227
  description=description or "",
192
228
  ticket_type=TicketType.ISSUE,
193
- parent_epic=epic_id,
194
- assignee=assignee,
229
+ parent_epic=final_epic_id,
230
+ assignee=final_assignee,
195
231
  priority=priority_enum,
232
+ tags=final_tags or [],
196
233
  )
197
234
 
198
235
  # Create via adapter
@@ -201,6 +238,8 @@ async def issue_create(
201
238
  return {
202
239
  "status": "completed",
203
240
  "issue": created.model_dump(),
241
+ "labels_applied": created.tags or [],
242
+ "auto_detected": auto_detect_labels,
204
243
  }
205
244
  except Exception as e:
206
245
  return {
@@ -261,8 +300,13 @@ async def task_create(
261
300
  issue_id: str | None = None,
262
301
  assignee: str | None = None,
263
302
  priority: str = "medium",
303
+ tags: list[str] | None = None,
304
+ auto_detect_labels: bool = True,
264
305
  ) -> dict[str, Any]:
265
- """Create a new task (sub-work item).
306
+ """Create a new task (sub-work item) with automatic label detection.
307
+
308
+ This tool automatically scans available labels/tags and intelligently
309
+ applies relevant ones based on the task title and description.
266
310
 
267
311
  Args:
268
312
  title: Task title (required)
@@ -270,6 +314,8 @@ async def task_create(
270
314
  issue_id: Parent issue ID to link this task to
271
315
  assignee: User ID or email to assign the task to
272
316
  priority: Priority level - must be one of: low, medium, high, critical
317
+ tags: List of tags to categorize the task (auto-detection adds to these)
318
+ auto_detect_labels: Automatically detect and apply relevant labels (default: True)
273
319
 
274
320
  Returns:
275
321
  Created task details including ID and metadata, or error information
@@ -287,14 +333,30 @@ async def task_create(
287
333
  "error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
288
334
  }
289
335
 
336
+ # Use default_user if no assignee specified
337
+ final_assignee = assignee
338
+ if final_assignee is None:
339
+ resolver = ConfigResolver(project_path=Path.cwd())
340
+ config = resolver.load_project_config() or TicketerConfig()
341
+ if config.default_user:
342
+ final_assignee = config.default_user
343
+
344
+ # Auto-detect labels if enabled
345
+ final_tags = tags
346
+ if auto_detect_labels:
347
+ final_tags = await detect_and_apply_labels(
348
+ adapter, title, description or "", tags
349
+ )
350
+
290
351
  # Create task (Task with TASK type)
291
352
  task = Task(
292
353
  title=title,
293
354
  description=description or "",
294
355
  ticket_type=TicketType.TASK,
295
356
  parent_issue=issue_id,
296
- assignee=assignee,
357
+ assignee=final_assignee,
297
358
  priority=priority_enum,
359
+ tags=final_tags or [],
298
360
  )
299
361
 
300
362
  # Create via adapter
@@ -303,6 +365,8 @@ async def task_create(
303
365
  return {
304
366
  "status": "completed",
305
367
  "task": created.model_dump(),
368
+ "labels_applied": created.tags or [],
369
+ "auto_detected": auto_detect_labels,
306
370
  }
307
371
  except Exception as e:
308
372
  return {
@@ -311,6 +375,91 @@ async def task_create(
311
375
  }
312
376
 
313
377
 
378
+ @mcp.tool()
379
+ async def epic_update(
380
+ epic_id: str,
381
+ title: str | None = None,
382
+ description: str | None = None,
383
+ state: str | None = None,
384
+ target_date: str | None = None,
385
+ ) -> dict[str, Any]:
386
+ """Update an existing epic's metadata and description.
387
+
388
+ Args:
389
+ epic_id: Epic identifier (required)
390
+ title: New title for the epic
391
+ description: New description for the epic
392
+ state: New state (open, in_progress, done, closed)
393
+ target_date: Target completion date in ISO format (YYYY-MM-DD)
394
+
395
+ Returns:
396
+ Updated epic details, or error information
397
+
398
+ """
399
+ try:
400
+ adapter = get_adapter()
401
+
402
+ # Check if adapter supports epic updates
403
+ if not hasattr(adapter, "update_epic"):
404
+ return {
405
+ "status": "error",
406
+ "error": f"Epic updates not supported by {type(adapter).__name__} adapter",
407
+ "epic_id": epic_id,
408
+ "note": "Use ticket_update instead for basic field updates",
409
+ }
410
+
411
+ # Build updates dictionary
412
+ updates = {}
413
+ if title is not None:
414
+ updates["title"] = title
415
+ if description is not None:
416
+ updates["description"] = description
417
+ if state is not None:
418
+ updates["state"] = state
419
+ if target_date is not None:
420
+ # Parse target date if provided
421
+ try:
422
+ target_datetime = datetime.fromisoformat(target_date)
423
+ updates["target_date"] = target_datetime
424
+ except ValueError:
425
+ return {
426
+ "status": "error",
427
+ "error": f"Invalid date format '{target_date}'. Use ISO format: YYYY-MM-DD",
428
+ }
429
+
430
+ if not updates:
431
+ return {
432
+ "status": "error",
433
+ "error": "No updates provided. At least one field (title, description, state, target_date) must be specified.",
434
+ }
435
+
436
+ # Update via adapter
437
+ updated = await adapter.update_epic(epic_id, updates) # type: ignore
438
+
439
+ if updated is None:
440
+ return {
441
+ "status": "error",
442
+ "error": f"Epic {epic_id} not found or update failed",
443
+ }
444
+
445
+ return {
446
+ "status": "completed",
447
+ "epic": updated.model_dump(),
448
+ }
449
+ except AttributeError as e:
450
+ return {
451
+ "status": "error",
452
+ "error": f"Epic update method not available: {str(e)}",
453
+ "epic_id": epic_id,
454
+ }
455
+ except Exception as e:
456
+ return {
457
+ "status": "error",
458
+ "error": f"Failed to update epic: {str(e)}",
459
+ "epic_id": epic_id,
460
+ }
461
+
462
+
314
463
  @mcp.tool()
315
464
  async def hierarchy_tree(
316
465
  epic_id: str,