mcp-ticketer 0.3.5__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 (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  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 +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.3.5"
3
+ __version__ = "0.12.0"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -27,7 +27,7 @@ __features__ = {
27
27
  }
28
28
 
29
29
 
30
- def get_version():
30
+ def get_version() -> str:
31
31
  """Return the full version string with build metadata if available."""
32
32
  version = __version__
33
33
  if __build__:
@@ -37,6 +37,6 @@ def get_version():
37
37
  return version
38
38
 
39
39
 
40
- def get_user_agent():
40
+ def get_user_agent() -> str:
41
41
  """Return a user agent string for API requests."""
42
42
  return f"{__title__}/{__version__}"
@@ -1,6 +1,7 @@
1
1
  """Adapter implementations for various ticket systems."""
2
2
 
3
3
  from .aitrackdown import AITrackdownAdapter
4
+ from .asana import AsanaAdapter
4
5
  from .github import GitHubAdapter
5
6
  from .hybrid import HybridAdapter
6
7
  from .jira import JiraAdapter
@@ -8,6 +9,7 @@ from .linear import LinearAdapter
8
9
 
9
10
  __all__ = [
10
11
  "AITrackdownAdapter",
12
+ "AsanaAdapter",
11
13
  "LinearAdapter",
12
14
  "JiraAdapter",
13
15
  "GitHubAdapter",
@@ -2,14 +2,25 @@
2
2
 
3
3
  import builtins
4
4
  import json
5
+ import logging
5
6
  from datetime import datetime
6
7
  from pathlib import Path
7
- from typing import Any, Optional, Union
8
+ from typing import Any
8
9
 
9
10
  from ..core.adapter import BaseAdapter
10
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
11
+ from ..core.models import (
12
+ Attachment,
13
+ Comment,
14
+ Epic,
15
+ Priority,
16
+ SearchQuery,
17
+ Task,
18
+ TicketState,
19
+ )
11
20
  from ..core.registry import AdapterRegistry
12
21
 
22
+ logger = logging.getLogger(__name__)
23
+
13
24
  # Import ai-trackdown-pytools when available
14
25
  try:
15
26
  from ai_trackdown_pytools import AITrackdown
@@ -80,7 +91,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
80
91
  TicketState.CLOSED: "closed",
81
92
  }
82
93
 
83
- def _priority_to_ai(self, priority: Union[Priority, str]) -> str:
94
+ def _priority_to_ai(self, priority: Priority | str) -> str:
84
95
  """Convert universal priority to AI-Trackdown priority."""
85
96
  if isinstance(priority, Priority):
86
97
  return priority.value
@@ -219,7 +230,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
219
230
  "type": "epic",
220
231
  }
221
232
 
222
- def _read_ticket_file(self, ticket_id: str) -> Optional[dict[str, Any]]:
233
+ def _read_ticket_file(self, ticket_id: str) -> dict[str, Any] | None:
223
234
  """Read ticket from file system."""
224
235
  ticket_file = self.tickets_dir / f"{ticket_id}.json"
225
236
  if ticket_file.exists():
@@ -233,7 +244,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
233
244
  with open(ticket_file, "w") as f:
234
245
  json.dump(data, f, indent=2, default=str)
235
246
 
236
- async def create(self, ticket: Union[Task, Epic]) -> Union[Task, Epic]:
247
+ async def create(self, ticket: Task | Epic) -> Task | Epic:
237
248
  """Create a new task."""
238
249
  # Generate ID if not provided
239
250
  if not ticket.id:
@@ -269,7 +280,9 @@ class AITrackdownAdapter(BaseAdapter[Task]):
269
280
 
270
281
  return ticket
271
282
 
272
- async def create_epic(self, title: str, description: str = None, **kwargs) -> Epic:
283
+ async def create_epic(
284
+ self, title: str, description: str = None, **kwargs: Any
285
+ ) -> Epic:
273
286
  """Create a new epic.
274
287
 
275
288
  Args:
@@ -285,7 +298,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
285
298
  return await self.create(epic)
286
299
 
287
300
  async def create_issue(
288
- self, title: str, parent_epic: str = None, description: str = None, **kwargs
301
+ self,
302
+ title: str,
303
+ parent_epic: str = None,
304
+ description: str = None,
305
+ **kwargs: Any,
289
306
  ) -> Task:
290
307
  """Create a new issue.
291
308
 
@@ -305,7 +322,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
305
322
  return await self.create(task)
306
323
 
307
324
  async def create_task(
308
- self, title: str, parent_id: str, description: str = None, **kwargs
325
+ self, title: str, parent_id: str, description: str = None, **kwargs: Any
309
326
  ) -> Task:
310
327
  """Create a new task under an issue.
311
328
 
@@ -324,7 +341,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
324
341
  )
325
342
  return await self.create(task)
326
343
 
327
- async def read(self, ticket_id: str) -> Optional[Union[Task, Epic]]:
344
+ async def read(self, ticket_id: str) -> Task | Epic | None:
328
345
  """Read a task by ID."""
329
346
  if self.tracker:
330
347
  ai_ticket = self.tracker.get_ticket(ticket_id)
@@ -340,8 +357,8 @@ class AITrackdownAdapter(BaseAdapter[Task]):
340
357
  return None
341
358
 
342
359
  async def update(
343
- self, ticket_id: str, updates: Union[dict[str, Any], Task]
344
- ) -> Optional[Union[Task, Epic]]:
360
+ self, ticket_id: str, updates: dict[str, Any] | Task
361
+ ) -> Task | Epic | None:
345
362
  """Update a task or epic.
346
363
 
347
364
  Args:
@@ -403,7 +420,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
403
420
  return False
404
421
 
405
422
  async def list(
406
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
423
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
407
424
  ) -> list[Task]:
408
425
  """List tasks with pagination."""
409
426
  tasks = []
@@ -487,7 +504,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
487
504
 
488
505
  async def transition_state(
489
506
  self, ticket_id: str, target_state: TicketState
490
- ) -> Optional[Task]:
507
+ ) -> Task | None:
491
508
  """Transition task to new state."""
492
509
  # Validate transition
493
510
  if not await self.validate_transition(ticket_id, target_state):
@@ -534,7 +551,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
534
551
  # Apply limit and offset AFTER filtering
535
552
  return comments[offset : offset + limit]
536
553
 
537
- async def get_epic(self, epic_id: str) -> Optional[Epic]:
554
+ async def get_epic(self, epic_id: str) -> Epic | None:
538
555
  """Get epic by ID.
539
556
 
540
557
  Args:
@@ -612,6 +629,238 @@ class AITrackdownAdapter(BaseAdapter[Task]):
612
629
  tasks.append(ticket)
613
630
  return tasks
614
631
 
632
+ def _sanitize_filename(self, filename: str) -> str:
633
+ """Sanitize filename to prevent security issues.
634
+
635
+ Args:
636
+ filename: Original filename
637
+
638
+ Returns:
639
+ Sanitized filename safe for filesystem
640
+
641
+ """
642
+ # Remove path separators and other dangerous characters
643
+ safe_chars = set(
644
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- "
645
+ )
646
+ sanitized = "".join(c if c in safe_chars else "_" for c in filename)
647
+
648
+ # Ensure filename is not empty
649
+ if not sanitized.strip():
650
+ return "unnamed_file"
651
+
652
+ return sanitized.strip()
653
+
654
+ def _guess_content_type(self, file_path: Path) -> str:
655
+ """Guess MIME type from file extension.
656
+
657
+ Args:
658
+ file_path: Path to file
659
+
660
+ Returns:
661
+ MIME type string
662
+
663
+ """
664
+ import mimetypes
665
+
666
+ content_type, _ = mimetypes.guess_type(str(file_path))
667
+ return content_type or "application/octet-stream"
668
+
669
+ def _calculate_checksum(self, file_path: Path) -> str:
670
+ """Calculate SHA256 checksum of file.
671
+
672
+ Args:
673
+ file_path: Path to file
674
+
675
+ Returns:
676
+ Hexadecimal checksum string
677
+
678
+ """
679
+ import hashlib
680
+
681
+ sha256 = hashlib.sha256()
682
+ with open(file_path, "rb") as f:
683
+ # Read in chunks to handle large files
684
+ for chunk in iter(lambda: f.read(4096), b""):
685
+ sha256.update(chunk)
686
+
687
+ return sha256.hexdigest()
688
+
689
+ async def add_attachment(
690
+ self,
691
+ ticket_id: str,
692
+ file_path: str,
693
+ description: str | None = None,
694
+ ) -> Attachment:
695
+ """Attach a file to a ticket (local filesystem storage).
696
+
697
+ Args:
698
+ ticket_id: Ticket identifier
699
+ file_path: Local file path to attach
700
+ description: Optional attachment description
701
+
702
+ Returns:
703
+ Attachment metadata
704
+
705
+ Raises:
706
+ ValueError: If ticket doesn't exist
707
+ FileNotFoundError: If file doesn't exist
708
+
709
+ """
710
+ import shutil
711
+
712
+ # Validate ticket exists
713
+ ticket = await self.read(ticket_id)
714
+ if not ticket:
715
+ raise ValueError(f"Ticket {ticket_id} not found")
716
+
717
+ # Validate file exists
718
+ source_path = Path(file_path).resolve()
719
+ if not source_path.exists():
720
+ raise FileNotFoundError(f"File not found: {file_path}")
721
+
722
+ # Check file size (max 100MB for local storage)
723
+ size_mb = source_path.stat().st_size / (1024 * 1024)
724
+ if size_mb > 100:
725
+ raise ValueError(f"File too large: {size_mb:.2f}MB (max: 100MB)")
726
+
727
+ # Create attachments directory for this ticket
728
+ attachments_dir = self.base_path / "attachments" / ticket_id
729
+ attachments_dir.mkdir(parents=True, exist_ok=True)
730
+
731
+ # Generate unique filename with timestamp
732
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
733
+ safe_filename = self._sanitize_filename(source_path.name)
734
+ attachment_id = f"{timestamp}-{safe_filename}"
735
+ dest_path = attachments_dir / attachment_id
736
+
737
+ # Copy file to attachments directory
738
+ shutil.copy2(source_path, dest_path)
739
+
740
+ # Create attachment metadata
741
+ attachment = Attachment(
742
+ id=attachment_id,
743
+ ticket_id=ticket_id,
744
+ filename=source_path.name,
745
+ url=f"file://{dest_path.absolute()}",
746
+ content_type=self._guess_content_type(source_path),
747
+ size_bytes=source_path.stat().st_size,
748
+ created_at=datetime.now(),
749
+ description=description,
750
+ metadata={
751
+ "original_path": str(source_path),
752
+ "storage_path": str(dest_path),
753
+ "checksum": self._calculate_checksum(dest_path),
754
+ },
755
+ )
756
+
757
+ # Save metadata to JSON file
758
+ metadata_file = attachments_dir / f"{attachment_id}.json"
759
+ with open(metadata_file, "w") as f:
760
+ # Convert to dict and handle datetime serialization
761
+ data = attachment.model_dump()
762
+ json.dump(data, f, indent=2, default=str)
763
+
764
+ return attachment
765
+
766
+ async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
767
+ """Get all attachments for a ticket with path traversal protection.
768
+
769
+ Args:
770
+ ticket_id: Ticket identifier
771
+
772
+ Returns:
773
+ List of attachments (empty if none)
774
+
775
+ """
776
+ # Resolve and validate attachments directory
777
+ attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
778
+
779
+ # CRITICAL SECURITY CHECK: Ensure ticket directory is within base attachments
780
+ base_attachments = (self.base_path / "attachments").resolve()
781
+ if not str(attachments_dir).startswith(str(base_attachments)):
782
+ raise ValueError("Invalid ticket_id: path traversal detected")
783
+
784
+ if not attachments_dir.exists():
785
+ return []
786
+
787
+ attachments = []
788
+ for metadata_file in attachments_dir.glob("*.json"):
789
+ try:
790
+ with open(metadata_file) as f:
791
+ data = json.load(f)
792
+ # Convert ISO datetime strings back to datetime objects
793
+ if isinstance(data.get("created_at"), str):
794
+ data["created_at"] = datetime.fromisoformat(
795
+ data["created_at"].replace("Z", "+00:00")
796
+ )
797
+ attachment = Attachment(**data)
798
+ attachments.append(attachment)
799
+ except (json.JSONDecodeError, ValueError) as e:
800
+ # Log error but continue processing other attachments
801
+ logger.warning(
802
+ "Failed to load attachment metadata from %s: %s",
803
+ metadata_file,
804
+ e,
805
+ )
806
+ continue
807
+
808
+ # Sort by creation time (newest first)
809
+ return sorted(
810
+ attachments,
811
+ key=lambda a: a.created_at or datetime.min,
812
+ reverse=True,
813
+ )
814
+
815
+ async def delete_attachment(
816
+ self,
817
+ ticket_id: str,
818
+ attachment_id: str,
819
+ ) -> bool:
820
+ """Delete an attachment and its metadata with path traversal protection.
821
+
822
+ Args:
823
+ ticket_id: Ticket identifier
824
+ attachment_id: Attachment identifier
825
+
826
+ Returns:
827
+ True if deleted, False if not found
828
+
829
+ """
830
+ # Resolve base directory
831
+ attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
832
+
833
+ # Validate attachments directory exists
834
+ if not attachments_dir.exists():
835
+ return False
836
+
837
+ # Resolve file paths
838
+ attachment_file = (attachments_dir / attachment_id).resolve()
839
+ metadata_file = (attachments_dir / f"{attachment_id}.json").resolve()
840
+
841
+ # CRITICAL SECURITY CHECK: Ensure paths are within attachments_dir
842
+ base_resolved = attachments_dir.resolve()
843
+ if not str(attachment_file).startswith(str(base_resolved)):
844
+ raise ValueError(
845
+ "Invalid attachment path: path traversal detected in attachment_id"
846
+ )
847
+ if not str(metadata_file).startswith(str(base_resolved)):
848
+ raise ValueError(
849
+ "Invalid attachment path: path traversal detected in attachment_id"
850
+ )
851
+
852
+ # Delete files if they exist
853
+ deleted = False
854
+ if attachment_file.exists():
855
+ attachment_file.unlink()
856
+ deleted = True
857
+
858
+ if metadata_file.exists():
859
+ metadata_file.unlink()
860
+ deleted = True
861
+
862
+ return deleted
863
+
615
864
 
616
865
  # Register the adapter
617
866
  AdapterRegistry.register("aitrackdown", AITrackdownAdapter)
@@ -0,0 +1,15 @@
1
+ """Asana adapter for mcp-ticketer.
2
+
3
+ This adapter provides comprehensive integration with Asana's REST API,
4
+ supporting ticket management operations including:
5
+
6
+ - CRUD operations for projects and tasks
7
+ - Hierarchical structure (Epic → Issue → Task)
8
+ - State transitions via custom fields
9
+ - User assignment and tag management
10
+ - Comment and attachment support
11
+ """
12
+
13
+ from .adapter import AsanaAdapter
14
+
15
+ __all__ = ["AsanaAdapter"]