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
@@ -0,0 +1,60 @@
1
+ """Main entry point for MCP server module invocation.
2
+
3
+ This module enables running the MCP server via:
4
+ python -m mcp_ticketer.mcp.server [project_path]
5
+
6
+ This is the preferred invocation method for MCP configurations as it:
7
+ - Works reliably across installation methods (pipx, pip, uv)
8
+ - Doesn't depend on binary path detection
9
+ - Follows the proven mcp-vector-search pattern
10
+ """
11
+
12
+ import asyncio
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .server import main
17
+
18
+
19
+ def run_server() -> None:
20
+ """Run the MCP server with optional project path argument.
21
+
22
+ Usage:
23
+ python -m mcp_ticketer.mcp.server
24
+ python -m mcp_ticketer.mcp.server /path/to/project
25
+
26
+ Arguments:
27
+ project_path (optional): Path to project directory for context
28
+
29
+ """
30
+ # Check for project path argument
31
+ if len(sys.argv) > 1:
32
+ project_path = Path(sys.argv[1])
33
+
34
+ # Validate project path exists
35
+ if not project_path.exists():
36
+ sys.stderr.write(f"Error: Project path does not exist: {project_path}\n")
37
+ sys.exit(1)
38
+
39
+ # Change to project directory for context
40
+ try:
41
+ import os
42
+
43
+ os.chdir(project_path)
44
+ sys.stderr.write(f"[MCP Server] Working directory: {project_path}\n")
45
+ except OSError as e:
46
+ sys.stderr.write(f"Warning: Could not change to project directory: {e}\n")
47
+
48
+ # Run the async main function
49
+ try:
50
+ asyncio.run(main())
51
+ except KeyboardInterrupt:
52
+ sys.stderr.write("\n[MCP Server] Interrupted by user\n")
53
+ sys.exit(0)
54
+ except Exception as e:
55
+ sys.stderr.write(f"[MCP Server] Fatal error: {e}\n")
56
+ sys.exit(1)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ run_server()
@@ -0,0 +1,25 @@
1
+ """MCP Server package for mcp-ticketer.
2
+
3
+ This package provides the FastMCP server implementation for ticket management
4
+ operations via the Model Context Protocol (MCP).
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from .main import MCPTicketServer, main
11
+
12
+ __all__ = ["main", "MCPTicketServer"]
13
+
14
+
15
+ def __getattr__(name: str) -> Any:
16
+ """Lazy import to avoid premature module loading."""
17
+ if name == "main":
18
+ from .main import main
19
+
20
+ return main
21
+ if name == "MCPTicketServer":
22
+ from .main import MCPTicketServer
23
+
24
+ return MCPTicketServer
25
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,60 @@
1
+ """Main entry point for MCP server module invocation.
2
+
3
+ This module enables running the MCP server via:
4
+ python -m mcp_ticketer.mcp.server [project_path]
5
+
6
+ This is the preferred invocation method for MCP configurations as it:
7
+ - Works reliably across installation methods (pipx, pip, uv)
8
+ - Doesn't depend on binary path detection
9
+ - Follows the proven mcp-vector-search pattern
10
+ """
11
+
12
+ import asyncio
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .main import main
17
+
18
+
19
+ def run_server() -> None:
20
+ """Run the MCP server with optional project path argument.
21
+
22
+ Usage:
23
+ python -m mcp_ticketer.mcp.server
24
+ python -m mcp_ticketer.mcp.server /path/to/project
25
+
26
+ Arguments:
27
+ project_path (optional): Path to project directory for context
28
+
29
+ """
30
+ # Check for project path argument
31
+ if len(sys.argv) > 1:
32
+ project_path = Path(sys.argv[1])
33
+
34
+ # Validate project path exists
35
+ if not project_path.exists():
36
+ sys.stderr.write(f"Error: Project path does not exist: {project_path}\n")
37
+ sys.exit(1)
38
+
39
+ # Change to project directory for context
40
+ try:
41
+ import os
42
+
43
+ os.chdir(project_path)
44
+ sys.stderr.write(f"[MCP Server] Working directory: {project_path}\n")
45
+ except OSError as e:
46
+ sys.stderr.write(f"Warning: Could not change to project directory: {e}\n")
47
+
48
+ # Run the async main function
49
+ try:
50
+ asyncio.run(main())
51
+ except KeyboardInterrupt:
52
+ sys.stderr.write("\n[MCP Server] Interrupted by user\n")
53
+ sys.exit(0)
54
+ except Exception as e:
55
+ sys.stderr.write(f"[MCP Server] Fatal error: {e}\n")
56
+ sys.exit(1)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ run_server()
@@ -1,6 +1,6 @@
1
1
  """Data Transfer Objects for MCP requests and responses."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  from pydantic import BaseModel, Field
6
6
 
@@ -10,32 +10,32 @@ class CreateTicketRequest(BaseModel):
10
10
  """Request to create a ticket."""
11
11
 
12
12
  title: str = Field(..., min_length=1, description="Ticket title")
13
- description: Optional[str] = Field(None, description="Ticket description")
13
+ description: str | None = Field(None, description="Ticket description")
14
14
  priority: str = Field("medium", description="Ticket priority")
15
15
  tags: list[str] = Field(default_factory=list, description="Ticket tags")
16
- assignee: Optional[str] = Field(None, description="Ticket assignee")
16
+ assignee: str | None = Field(None, description="Ticket assignee")
17
17
 
18
18
 
19
19
  class CreateEpicRequest(BaseModel):
20
20
  """Request to create an epic."""
21
21
 
22
22
  title: str = Field(..., min_length=1, description="Epic title")
23
- description: Optional[str] = Field(None, description="Epic description")
23
+ description: str | None = Field(None, description="Epic description")
24
24
  child_issues: list[str] = Field(default_factory=list, description="Child issue IDs")
25
- target_date: Optional[str] = Field(None, description="Target completion date")
26
- lead_id: Optional[str] = Field(None, description="Epic lead/owner ID")
25
+ target_date: str | None = Field(None, description="Target completion date")
26
+ lead_id: str | None = Field(None, description="Epic lead/owner ID")
27
27
 
28
28
 
29
29
  class CreateIssueRequest(BaseModel):
30
30
  """Request to create an issue."""
31
31
 
32
32
  title: str = Field(..., min_length=1, description="Issue title")
33
- description: Optional[str] = Field(None, description="Issue description")
34
- epic_id: Optional[str] = Field(None, description="Parent epic ID")
33
+ description: str | None = Field(None, description="Issue description")
34
+ epic_id: str | None = Field(None, description="Parent epic ID")
35
35
  priority: str = Field("medium", description="Issue priority")
36
- assignee: Optional[str] = Field(None, description="Issue assignee")
36
+ assignee: str | None = Field(None, description="Issue assignee")
37
37
  tags: list[str] = Field(default_factory=list, description="Issue tags")
38
- estimated_hours: Optional[float] = Field(
38
+ estimated_hours: float | None = Field(
39
39
  None, description="Estimated hours to complete"
40
40
  )
41
41
 
@@ -45,11 +45,11 @@ class CreateTaskRequest(BaseModel):
45
45
 
46
46
  title: str = Field(..., min_length=1, description="Task title")
47
47
  parent_id: str = Field(..., description="Parent issue ID")
48
- description: Optional[str] = Field(None, description="Task description")
48
+ description: str | None = Field(None, description="Task description")
49
49
  priority: str = Field("medium", description="Task priority")
50
- assignee: Optional[str] = Field(None, description="Task assignee")
50
+ assignee: str | None = Field(None, description="Task assignee")
51
51
  tags: list[str] = Field(default_factory=list, description="Task tags")
52
- estimated_hours: Optional[float] = Field(
52
+ estimated_hours: float | None = Field(
53
53
  None, description="Estimated hours to complete"
54
54
  )
55
55
 
@@ -77,11 +77,11 @@ class TransitionRequest(BaseModel):
77
77
  class SearchRequest(BaseModel):
78
78
  """Request to search tickets."""
79
79
 
80
- query: Optional[str] = Field(None, description="Search query text")
81
- state: Optional[str] = Field(None, description="Filter by ticket state")
82
- priority: Optional[str] = Field(None, description="Filter by priority")
83
- assignee: Optional[str] = Field(None, description="Filter by assignee")
84
- tags: Optional[list[str]] = Field(None, description="Filter by tags")
80
+ query: str | None = Field(None, description="Search query text")
81
+ state: str | None = Field(None, description="Filter by ticket state")
82
+ priority: str | None = Field(None, description="Filter by priority")
83
+ assignee: str | None = Field(None, description="Filter by assignee")
84
+ tags: list[str] | None = Field(None, description="Filter by tags")
85
85
  limit: int = Field(10, description="Maximum number of results")
86
86
 
87
87
 
@@ -90,7 +90,7 @@ class ListRequest(BaseModel):
90
90
 
91
91
  limit: int = Field(10, description="Maximum number of tickets to return")
92
92
  offset: int = Field(0, description="Number of tickets to skip")
93
- filters: Optional[dict[str, Any]] = Field(None, description="Additional filters")
93
+ filters: dict[str, Any] | None = Field(None, description="Additional filters")
94
94
 
95
95
 
96
96
  class DeleteTicketRequest(BaseModel):
@@ -104,8 +104,8 @@ class CommentRequest(BaseModel):
104
104
 
105
105
  operation: str = Field("add", description="Operation: 'add' or 'list'")
106
106
  ticket_id: str = Field(..., description="Ticket ID")
107
- content: Optional[str] = Field(None, description="Comment content (for add)")
108
- author: Optional[str] = Field(None, description="Comment author (for add)")
107
+ content: str | None = Field(None, description="Comment content (for add)")
108
+ author: str | None = Field(None, description="Comment author (for add)")
109
109
  limit: int = Field(10, description="Max comments to return (for list)")
110
110
  offset: int = Field(0, description="Number of comments to skip (for list)")
111
111
 
@@ -115,12 +115,12 @@ class CreatePRRequest(BaseModel):
115
115
 
116
116
  ticket_id: str = Field(..., description="Ticket ID")
117
117
  base_branch: str = Field("main", description="Base branch")
118
- head_branch: Optional[str] = Field(None, description="Head branch")
119
- title: Optional[str] = Field(None, description="PR title")
120
- body: Optional[str] = Field(None, description="PR body")
118
+ head_branch: str | None = Field(None, description="Head branch")
119
+ title: str | None = Field(None, description="PR title")
120
+ body: str | None = Field(None, description="PR body")
121
121
  draft: bool = Field(False, description="Create as draft PR")
122
- github_owner: Optional[str] = Field(None, description="GitHub owner (for Linear)")
123
- github_repo: Optional[str] = Field(None, description="GitHub repo (for Linear)")
122
+ github_owner: str | None = Field(None, description="GitHub owner (for Linear)")
123
+ github_repo: str | None = Field(None, description="GitHub repo (for Linear)")
124
124
 
125
125
 
126
126
  class LinkPRRequest(BaseModel):
@@ -152,7 +152,7 @@ class IssueTasksRequest(BaseModel):
152
152
  class HierarchyTreeRequest(BaseModel):
153
153
  """Request to get hierarchy tree."""
154
154
 
155
- epic_id: Optional[str] = Field(None, description="Specific epic ID (optional)")
155
+ epic_id: str | None = Field(None, description="Specific epic ID (optional)")
156
156
  max_depth: int = Field(3, description="Maximum depth of tree")
157
157
  limit: int = Field(10, description="Max epics to return (if no epic_id)")
158
158
 
@@ -173,8 +173,8 @@ class SearchHierarchyRequest(BaseModel):
173
173
  """Request to search with hierarchy context."""
174
174
 
175
175
  query: str = Field("", description="Search query")
176
- state: Optional[str] = Field(None, description="Filter by state")
177
- priority: Optional[str] = Field(None, description="Filter by priority")
176
+ state: str | None = Field(None, description="Filter by state")
177
+ priority: str | None = Field(None, description="Filter by priority")
178
178
  include_children: bool = Field(True, description="Include child tickets")
179
179
  include_parents: bool = Field(True, description="Include parent tickets")
180
180
  limit: int = Field(50, description="Maximum number of results")
@@ -184,9 +184,9 @@ class AttachRequest(BaseModel):
184
184
  """Request to attach file to ticket."""
185
185
 
186
186
  ticket_id: str = Field(..., description="Ticket ID")
187
- file_path: Optional[str] = Field(None, description="File path to attach")
188
- file_content: Optional[str] = Field(None, description="File content (base64)")
189
- file_name: Optional[str] = Field(None, description="File name")
187
+ file_path: str | None = Field(None, description="File path to attach")
188
+ file_content: str | None = Field(None, description="File content (base64)")
189
+ file_name: str | None = Field(None, description="File name")
190
190
 
191
191
 
192
192
  class ListAttachmentsRequest(BaseModel):
@@ -4,15 +4,15 @@ import asyncio
4
4
  import json
5
5
  import sys
6
6
  from pathlib import Path
7
- from typing import Any, Optional
7
+ from typing import Any
8
8
 
9
9
  from dotenv import load_dotenv
10
10
 
11
11
  # Import adapters module to trigger registration
12
12
  import mcp_ticketer.adapters # noqa: F401
13
13
 
14
- from ..core import AdapterRegistry
15
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
14
+ from ...core import AdapterRegistry
15
+ from ...core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
16
16
  from .constants import (
17
17
  DEFAULT_BASE_PATH,
18
18
  DEFAULT_LIMIT,
@@ -48,29 +48,12 @@ from .dto import (
48
48
  )
49
49
  from .response_builder import ResponseBuilder
50
50
 
51
- # Load environment variables early (prioritize .env.local)
52
- # Check for .env.local first (takes precedence)
53
- env_local_file = Path.cwd() / ".env.local"
54
- if env_local_file.exists():
55
- load_dotenv(env_local_file, override=True)
56
- sys.stderr.write(f"[MCP Server] Loaded environment from: {env_local_file}\n")
57
- else:
58
- # Fall back to .env
59
- env_file = Path.cwd() / ".env"
60
- if env_file.exists():
61
- load_dotenv(env_file, override=True)
62
- sys.stderr.write(f"[MCP Server] Loaded environment from: {env_file}\n")
63
- else:
64
- # Try default dotenv loading (searches upward)
65
- load_dotenv(override=True)
66
- sys.stderr.write("[MCP Server] Loaded environment from default search path\n")
67
-
68
51
 
69
52
  class MCPTicketServer:
70
53
  """MCP server for ticket operations over stdio - synchronous implementation."""
71
54
 
72
55
  def __init__(
73
- self, adapter_type: str = "aitrackdown", config: Optional[dict[str, Any]] = None
56
+ self, adapter_type: str = "aitrackdown", config: dict[str, Any] | None = None
74
57
  ):
75
58
  """Initialize MCP server.
76
59
 
@@ -861,6 +844,44 @@ class MCPTicketServer:
861
844
  "required": ["title"],
862
845
  },
863
846
  },
847
+ {
848
+ "name": "ticket_comment",
849
+ "description": "Add or list comments on a ticket",
850
+ "inputSchema": {
851
+ "type": "object",
852
+ "properties": {
853
+ "operation": {
854
+ "type": "string",
855
+ "enum": ["add", "list"],
856
+ "description": "Operation to perform: 'add' to create a comment, 'list' to retrieve comments",
857
+ "default": "add",
858
+ },
859
+ "ticket_id": {
860
+ "type": "string",
861
+ "description": "Ticket ID to comment on",
862
+ },
863
+ "content": {
864
+ "type": "string",
865
+ "description": "Comment content (required for 'add' operation)",
866
+ },
867
+ "author": {
868
+ "type": "string",
869
+ "description": "Comment author (optional for 'add' operation)",
870
+ },
871
+ "limit": {
872
+ "type": "integer",
873
+ "default": 10,
874
+ "description": "Maximum number of comments to return (for 'list' operation)",
875
+ },
876
+ "offset": {
877
+ "type": "integer",
878
+ "default": 0,
879
+ "description": "Number of comments to skip (for 'list' operation)",
880
+ },
881
+ },
882
+ "required": ["ticket_id"],
883
+ },
884
+ },
864
885
  ]
865
886
  }
866
887
 
@@ -913,6 +934,8 @@ class MCPTicketServer:
913
934
  result = await self._handle_transition(arguments)
914
935
  elif tool_name == "ticket_search":
915
936
  result = await self._handle_search(arguments)
937
+ elif tool_name == "ticket_comment":
938
+ result = await self._handle_comment(arguments)
916
939
  # PR integration
917
940
  elif tool_name == "ticket_create_pr":
918
941
  result = await self._handle_create_pr(arguments)
@@ -1010,8 +1033,8 @@ class MCPTicketServer:
1010
1033
  await self.adapter.close()
1011
1034
 
1012
1035
 
1013
- async def main():
1014
- """Main entry point for MCP server - kept for backward compatibility.
1036
+ async def main() -> None:
1037
+ """Run main entry point for MCP server - kept for backward compatibility.
1015
1038
 
1016
1039
  This function is maintained in case it's being called directly,
1017
1040
  but the preferred way is now through the CLI: `mcp-ticketer mcp`
@@ -1023,62 +1046,94 @@ async def main():
1023
1046
  # Load configuration
1024
1047
  import json
1025
1048
  import logging
1026
- from pathlib import Path
1027
1049
 
1028
1050
  logger = logging.getLogger(__name__)
1029
1051
 
1052
+ # Load environment variables AFTER working directory has been set by __main__.py
1053
+ # This ensures we load .env files from the target project directory, not from where the command is executed
1054
+ env_local_file = Path.cwd() / ".env.local"
1055
+ if env_local_file.exists():
1056
+ load_dotenv(env_local_file, override=True)
1057
+ sys.stderr.write(f"[MCP Server] Loaded environment from: {env_local_file}\n")
1058
+ logger.debug(f"Loaded environment from: {env_local_file}")
1059
+ else:
1060
+ # Fall back to .env
1061
+ env_file = Path.cwd() / ".env"
1062
+ if env_file.exists():
1063
+ load_dotenv(env_file, override=True)
1064
+ sys.stderr.write(f"[MCP Server] Loaded environment from: {env_file}\n")
1065
+ logger.debug(f"Loaded environment from: {env_file}")
1066
+ else:
1067
+ # Try default dotenv loading (searches upward)
1068
+ load_dotenv(override=True)
1069
+ sys.stderr.write(
1070
+ "[MCP Server] Loaded environment from default search path\n"
1071
+ )
1072
+ logger.debug("Loaded environment from default search path")
1073
+
1030
1074
  # Initialize defaults
1031
1075
  adapter_type = "aitrackdown"
1032
1076
  adapter_config = {"base_path": DEFAULT_BASE_PATH}
1033
1077
 
1034
- # Priority 1: Check .env files (highest priority for MCP)
1035
- env_config = _load_env_configuration()
1036
- if env_config and env_config.get("adapter_type"):
1037
- adapter_type = env_config["adapter_type"]
1038
- adapter_config = env_config["adapter_config"]
1039
- logger.info(f"Using adapter from .env files: {adapter_type}")
1040
- logger.info(f"Built adapter config from .env: {list(adapter_config.keys())}")
1041
- else:
1042
- # Priority 2: Check project-local config file
1043
- config_file = Path.cwd() / ".mcp-ticketer" / "config.json"
1044
- if config_file.exists():
1045
- # Validate config is within project
1046
- try:
1047
- if not config_file.resolve().is_relative_to(Path.cwd().resolve()):
1048
- logger.error(
1049
- f"Security violation: Config file {config_file} "
1050
- "is not within project directory"
1051
- )
1052
- raise ValueError(
1053
- f"Security violation: Config file {config_file} "
1054
- "is not within project directory"
1055
- )
1056
- except (ValueError, RuntimeError):
1057
- # is_relative_to may raise ValueError in some cases
1058
- pass
1078
+ # Priority 1: Check project-local config file (highest priority)
1079
+ config_file = Path.cwd() / ".mcp-ticketer" / "config.json"
1080
+ config_loaded = False
1059
1081
 
1060
- try:
1061
- with open(config_file) as f:
1062
- config = json.load(f)
1063
- adapter_type = config.get("default_adapter", "aitrackdown")
1064
- # Get adapter-specific config
1065
- adapters_config = config.get("adapters", {})
1066
- adapter_config = adapters_config.get(adapter_type, {})
1067
- # Fallback to legacy config format
1068
- if not adapter_config and "config" in config:
1069
- adapter_config = config["config"]
1070
- logger.info(
1071
- f"Loaded MCP configuration from project-local: {config_file}"
1072
- )
1073
- except (OSError, json.JSONDecodeError) as e:
1074
- logger.warning(f"Could not load project config: {e}, using defaults")
1075
- adapter_type = "aitrackdown"
1076
- adapter_config = {"base_path": DEFAULT_BASE_PATH}
1077
- else:
1078
- # Priority 3: Default to aitrackdown
1079
- logger.info("No configuration found, defaulting to aitrackdown adapter")
1080
- adapter_type = "aitrackdown"
1081
- adapter_config = {"base_path": DEFAULT_BASE_PATH}
1082
+ if config_file.exists():
1083
+ # Validate config is within project
1084
+ try:
1085
+ if not config_file.resolve().is_relative_to(Path.cwd().resolve()):
1086
+ logger.error(
1087
+ f"Security violation: Config file {config_file} "
1088
+ "is not within project directory"
1089
+ )
1090
+ raise ValueError(
1091
+ f"Security violation: Config file {config_file} "
1092
+ "is not within project directory"
1093
+ )
1094
+ except (ValueError, RuntimeError):
1095
+ # is_relative_to may raise ValueError in some cases
1096
+ pass
1097
+
1098
+ try:
1099
+ with open(config_file) as f:
1100
+ config = json.load(f)
1101
+ adapter_type = config.get("default_adapter", "aitrackdown")
1102
+ # Get adapter-specific config
1103
+ adapters_config = config.get("adapters", {})
1104
+ adapter_config = adapters_config.get(adapter_type, {})
1105
+ # Fallback to legacy config format
1106
+ if not adapter_config and "config" in config:
1107
+ adapter_config = config["config"]
1108
+ config_loaded = True
1109
+ logger.info(
1110
+ f"Loaded MCP configuration from project-local: {config_file}"
1111
+ )
1112
+ sys.stderr.write(
1113
+ f"[MCP Server] Using adapter from config: {adapter_type}\n"
1114
+ )
1115
+ except (OSError, json.JSONDecodeError) as e:
1116
+ logger.warning(f"Could not load project config: {e}, will try .env files")
1117
+
1118
+ # Priority 2: Check .env files (only if no config file found)
1119
+ if not config_loaded:
1120
+ env_config = _load_env_configuration()
1121
+ if env_config and env_config.get("adapter_type"):
1122
+ adapter_type = env_config["adapter_type"]
1123
+ adapter_config = env_config["adapter_config"]
1124
+ config_loaded = True
1125
+ logger.info(f"Using adapter from .env files: {adapter_type}")
1126
+ logger.info(
1127
+ f"Built adapter config from .env: {list(adapter_config.keys())}"
1128
+ )
1129
+ sys.stderr.write(f"[MCP Server] Using adapter from .env: {adapter_type}\n")
1130
+
1131
+ # Priority 3: Default to aitrackdown
1132
+ if not config_loaded:
1133
+ logger.info("No configuration found, defaulting to aitrackdown adapter")
1134
+ sys.stderr.write("[MCP Server] No config found, using default: aitrackdown\n")
1135
+ adapter_type = "aitrackdown"
1136
+ adapter_config = {"base_path": DEFAULT_BASE_PATH}
1082
1137
 
1083
1138
  # Log final configuration for debugging
1084
1139
  logger.info(f"Starting MCP server with adapter: {adapter_type}")
@@ -1089,7 +1144,7 @@ async def main():
1089
1144
  await server.run()
1090
1145
 
1091
1146
 
1092
- def _load_env_configuration() -> Optional[dict[str, Any]]:
1147
+ def _load_env_configuration() -> dict[str, Any] | None:
1093
1148
  """Load adapter configuration from .env files.
1094
1149
 
1095
1150
  Checks .env.local first (highest priority), then .env.
@@ -1098,8 +1153,6 @@ def _load_env_configuration() -> Optional[dict[str, Any]]:
1098
1153
  Dictionary with 'adapter_type' and 'adapter_config' keys, or None if no config found
1099
1154
 
1100
1155
  """
1101
- from pathlib import Path
1102
-
1103
1156
  # Check for .env files in order of preference
1104
1157
  env_files = [".env.local", ".env"]
1105
1158
  env_vars = {}
@@ -1,6 +1,6 @@
1
1
  """Response builder utility for consistent MCP responses."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  from .constants import JSONRPC_VERSION, STATUS_COMPLETED
6
6
 
@@ -36,7 +36,7 @@ class ResponseBuilder:
36
36
  request_id: Any,
37
37
  code: int,
38
38
  message: str,
39
- data: Optional[dict[str, Any]] = None,
39
+ data: dict[str, Any] | None = None,
40
40
  ) -> dict[str, Any]:
41
41
  """Build error response.
42
42