mcp-ticketer 0.1.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.

@@ -0,0 +1,128 @@
1
+ """Adapter registry for dynamic adapter management."""
2
+
3
+ from typing import Dict, Type, Any, Optional
4
+ from .adapter import BaseAdapter
5
+
6
+
7
+ class AdapterRegistry:
8
+ """Registry for managing ticket system adapters."""
9
+
10
+ _adapters: Dict[str, Type[BaseAdapter]] = {}
11
+ _instances: Dict[str, BaseAdapter] = {}
12
+
13
+ @classmethod
14
+ def register(cls, name: str, adapter_class: Type[BaseAdapter]) -> None:
15
+ """Register an adapter class.
16
+
17
+ Args:
18
+ name: Unique name for the adapter
19
+ adapter_class: Adapter class to register
20
+ """
21
+ if not issubclass(adapter_class, BaseAdapter):
22
+ raise TypeError(f"{adapter_class} must be a subclass of BaseAdapter")
23
+ cls._adapters[name] = adapter_class
24
+
25
+ @classmethod
26
+ def unregister(cls, name: str) -> None:
27
+ """Unregister an adapter.
28
+
29
+ Args:
30
+ name: Name of adapter to unregister
31
+ """
32
+ cls._adapters.pop(name, None)
33
+ cls._instances.pop(name, None)
34
+
35
+ @classmethod
36
+ def get_adapter(
37
+ cls,
38
+ name: str,
39
+ config: Optional[Dict[str, Any]] = None,
40
+ force_new: bool = False
41
+ ) -> BaseAdapter:
42
+ """Get or create an adapter instance.
43
+
44
+ Uses factory pattern for adapter instantiation with caching.
45
+
46
+ Args:
47
+ name: Name of the registered adapter
48
+ config: Configuration for the adapter
49
+ force_new: Force creation of new instance
50
+
51
+ Returns:
52
+ Adapter instance
53
+
54
+ Raises:
55
+ ValueError: If adapter not registered
56
+ """
57
+ if name not in cls._adapters:
58
+ available = ", ".join(cls._adapters.keys())
59
+ raise ValueError(
60
+ f"Adapter '{name}' not registered. "
61
+ f"Available adapters: {available}"
62
+ )
63
+
64
+ # Return cached instance if exists and not forcing new
65
+ if name in cls._instances and not force_new:
66
+ return cls._instances[name]
67
+
68
+ # Create new instance
69
+ adapter_class = cls._adapters[name]
70
+ config = config or {}
71
+ instance = adapter_class(config)
72
+
73
+ # Cache the instance
74
+ cls._instances[name] = instance
75
+ return instance
76
+
77
+ @classmethod
78
+ def list_adapters(cls) -> Dict[str, Type[BaseAdapter]]:
79
+ """List all registered adapters.
80
+
81
+ Returns:
82
+ Dictionary of adapter names to classes
83
+ """
84
+ return cls._adapters.copy()
85
+
86
+ @classmethod
87
+ def is_registered(cls, name: str) -> bool:
88
+ """Check if an adapter is registered.
89
+
90
+ Args:
91
+ name: Adapter name to check
92
+
93
+ Returns:
94
+ True if registered
95
+ """
96
+ return name in cls._adapters
97
+
98
+ @classmethod
99
+ async def close_all(cls) -> None:
100
+ """Close all adapter instances and clear cache."""
101
+ for instance in cls._instances.values():
102
+ await instance.close()
103
+ cls._instances.clear()
104
+
105
+ @classmethod
106
+ def clear_registry(cls) -> None:
107
+ """Clear all registrations and instances.
108
+
109
+ Useful for testing or reinitialization.
110
+ """
111
+ cls._adapters.clear()
112
+ cls._instances.clear()
113
+
114
+
115
+ def adapter_factory(
116
+ adapter_type: str,
117
+ config: Dict[str, Any]
118
+ ) -> BaseAdapter:
119
+ """Factory function for creating adapters.
120
+
121
+ Args:
122
+ adapter_type: Type of adapter to create
123
+ config: Configuration for the adapter
124
+
125
+ Returns:
126
+ Configured adapter instance
127
+ """
128
+ return AdapterRegistry.get_adapter(adapter_type, config)
@@ -0,0 +1,5 @@
1
+ """MCP server implementation for ticket management."""
2
+
3
+ from .server import MCPTicketServer
4
+
5
+ __all__ = ["MCPTicketServer"]
@@ -0,0 +1,459 @@
1
+ """MCP JSON-RPC server for ticket management."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from ..core import Task, TicketState, Priority, AdapterRegistry
9
+ from ..core.models import SearchQuery, Comment
10
+ from ..adapters import AITrackdownAdapter
11
+ from ..queue import Queue, QueueStatus, WorkerManager
12
+
13
+
14
+ class MCPTicketServer:
15
+ """MCP server for ticket operations over stdio."""
16
+
17
+ def __init__(self, adapter_type: str = "aitrackdown", config: Optional[Dict[str, Any]] = None):
18
+ """Initialize MCP server.
19
+
20
+ Args:
21
+ adapter_type: Type of adapter to use
22
+ config: Adapter configuration
23
+ """
24
+ self.adapter = AdapterRegistry.get_adapter(
25
+ adapter_type,
26
+ config or {"base_path": ".aitrackdown"}
27
+ )
28
+ self.running = False
29
+
30
+ async def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
31
+ """Handle JSON-RPC request.
32
+
33
+ Args:
34
+ request: JSON-RPC request
35
+
36
+ Returns:
37
+ JSON-RPC response
38
+ """
39
+ method = request.get("method")
40
+ params = request.get("params", {})
41
+ request_id = request.get("id")
42
+
43
+ try:
44
+ # Route to appropriate handler
45
+ if method == "ticket/create":
46
+ result = await self._handle_create(params)
47
+ elif method == "ticket/read":
48
+ result = await self._handle_read(params)
49
+ elif method == "ticket/update":
50
+ result = await self._handle_update(params)
51
+ elif method == "ticket/delete":
52
+ result = await self._handle_delete(params)
53
+ elif method == "ticket/list":
54
+ result = await self._handle_list(params)
55
+ elif method == "ticket/search":
56
+ result = await self._handle_search(params)
57
+ elif method == "ticket/transition":
58
+ result = await self._handle_transition(params)
59
+ elif method == "ticket/comment":
60
+ result = await self._handle_comment(params)
61
+ elif method == "ticket/status":
62
+ result = await self._handle_queue_status(params)
63
+ elif method == "tools/list":
64
+ result = await self._handle_tools_list()
65
+ else:
66
+ return self._error_response(
67
+ request_id,
68
+ -32601,
69
+ f"Method not found: {method}"
70
+ )
71
+
72
+ return {
73
+ "jsonrpc": "2.0",
74
+ "result": result,
75
+ "id": request_id
76
+ }
77
+
78
+ except Exception as e:
79
+ return self._error_response(
80
+ request_id,
81
+ -32603,
82
+ f"Internal error: {str(e)}"
83
+ )
84
+
85
+ def _error_response(
86
+ self,
87
+ request_id: Any,
88
+ code: int,
89
+ message: str
90
+ ) -> Dict[str, Any]:
91
+ """Create error response.
92
+
93
+ Args:
94
+ request_id: Request ID
95
+ code: Error code
96
+ message: Error message
97
+
98
+ Returns:
99
+ Error response
100
+ """
101
+ return {
102
+ "jsonrpc": "2.0",
103
+ "error": {
104
+ "code": code,
105
+ "message": message
106
+ },
107
+ "id": request_id
108
+ }
109
+
110
+ async def _handle_create(self, params: Dict[str, Any]) -> Dict[str, Any]:
111
+ """Handle ticket creation."""
112
+ # Queue the operation instead of direct execution
113
+ queue = Queue()
114
+ task_data = {
115
+ "title": params["title"],
116
+ "description": params.get("description"),
117
+ "priority": params.get("priority", "medium"),
118
+ "tags": params.get("tags", []),
119
+ "assignee": params.get("assignee"),
120
+ }
121
+
122
+ queue_id = queue.add(
123
+ ticket_data=task_data,
124
+ adapter=self.adapter.__class__.__name__.lower().replace("adapter", ""),
125
+ operation="create"
126
+ )
127
+
128
+ # Start worker if needed
129
+ manager = WorkerManager()
130
+ manager.start_if_needed()
131
+
132
+ return {
133
+ "queue_id": queue_id,
134
+ "status": "queued",
135
+ "message": f"Ticket creation queued with ID: {queue_id}"
136
+ }
137
+
138
+ async def _handle_read(self, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
139
+ """Handle ticket read."""
140
+ ticket = await self.adapter.read(params["ticket_id"])
141
+ return ticket.model_dump() if ticket else None
142
+
143
+ async def _handle_update(self, params: Dict[str, Any]) -> Dict[str, Any]:
144
+ """Handle ticket update."""
145
+ # Queue the operation
146
+ queue = Queue()
147
+ updates = params.get("updates", {})
148
+ updates["ticket_id"] = params["ticket_id"]
149
+
150
+ queue_id = queue.add(
151
+ ticket_data=updates,
152
+ adapter=self.adapter.__class__.__name__.lower().replace("adapter", ""),
153
+ operation="update"
154
+ )
155
+
156
+ # Start worker if needed
157
+ manager = WorkerManager()
158
+ manager.start_if_needed()
159
+
160
+ return {
161
+ "queue_id": queue_id,
162
+ "status": "queued",
163
+ "message": f"Ticket update queued with ID: {queue_id}"
164
+ }
165
+
166
+ async def _handle_delete(self, params: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Handle ticket deletion."""
168
+ # Queue the operation
169
+ queue = Queue()
170
+ queue_id = queue.add(
171
+ ticket_data={"ticket_id": params["ticket_id"]},
172
+ adapter=self.adapter.__class__.__name__.lower().replace("adapter", ""),
173
+ operation="delete"
174
+ )
175
+
176
+ # Start worker if needed
177
+ manager = WorkerManager()
178
+ manager.start_if_needed()
179
+
180
+ return {
181
+ "queue_id": queue_id,
182
+ "status": "queued",
183
+ "message": f"Ticket deletion queued with ID: {queue_id}"
184
+ }
185
+
186
+ async def _handle_list(self, params: Dict[str, Any]) -> List[Dict[str, Any]]:
187
+ """Handle ticket listing."""
188
+ tickets = await self.adapter.list(
189
+ limit=params.get("limit", 10),
190
+ offset=params.get("offset", 0),
191
+ filters=params.get("filters")
192
+ )
193
+ return [ticket.model_dump() for ticket in tickets]
194
+
195
+ async def _handle_search(self, params: Dict[str, Any]) -> List[Dict[str, Any]]:
196
+ """Handle ticket search."""
197
+ query = SearchQuery(**params)
198
+ tickets = await self.adapter.search(query)
199
+ return [ticket.model_dump() for ticket in tickets]
200
+
201
+ async def _handle_transition(self, params: Dict[str, Any]) -> Dict[str, Any]:
202
+ """Handle state transition."""
203
+ # Queue the operation
204
+ queue = Queue()
205
+ queue_id = queue.add(
206
+ ticket_data={
207
+ "ticket_id": params["ticket_id"],
208
+ "state": params["target_state"]
209
+ },
210
+ adapter=self.adapter.__class__.__name__.lower().replace("adapter", ""),
211
+ operation="transition"
212
+ )
213
+
214
+ # Start worker if needed
215
+ manager = WorkerManager()
216
+ manager.start_if_needed()
217
+
218
+ return {
219
+ "queue_id": queue_id,
220
+ "status": "queued",
221
+ "message": f"State transition queued with ID: {queue_id}"
222
+ }
223
+
224
+ async def _handle_comment(self, params: Dict[str, Any]) -> Dict[str, Any]:
225
+ """Handle comment operations."""
226
+ operation = params.get("operation", "add")
227
+
228
+ if operation == "add":
229
+ # Queue the comment addition
230
+ queue = Queue()
231
+ queue_id = queue.add(
232
+ ticket_data={
233
+ "ticket_id": params["ticket_id"],
234
+ "content": params["content"],
235
+ "author": params.get("author")
236
+ },
237
+ adapter=self.adapter.__class__.__name__.lower().replace("adapter", ""),
238
+ operation="comment"
239
+ )
240
+
241
+ # Start worker if needed
242
+ manager = WorkerManager()
243
+ manager.start_if_needed()
244
+
245
+ return {
246
+ "queue_id": queue_id,
247
+ "status": "queued",
248
+ "message": f"Comment addition queued with ID: {queue_id}"
249
+ }
250
+
251
+ elif operation == "list":
252
+ # Comments list is read-only, execute directly
253
+ comments = await self.adapter.get_comments(
254
+ params["ticket_id"],
255
+ limit=params.get("limit", 10),
256
+ offset=params.get("offset", 0)
257
+ )
258
+ return [comment.model_dump() for comment in comments]
259
+
260
+ else:
261
+ raise ValueError(f"Unknown comment operation: {operation}")
262
+
263
+ async def _handle_queue_status(self, params: Dict[str, Any]) -> Dict[str, Any]:
264
+ """Check status of queued operation."""
265
+ queue_id = params.get("queue_id")
266
+ if not queue_id:
267
+ raise ValueError("queue_id is required")
268
+
269
+ queue = Queue()
270
+ item = queue.get_item(queue_id)
271
+
272
+ if not item:
273
+ return {
274
+ "error": f"Queue item not found: {queue_id}"
275
+ }
276
+
277
+ response = {
278
+ "queue_id": item.id,
279
+ "status": item.status.value,
280
+ "operation": item.operation,
281
+ "created_at": item.created_at.isoformat(),
282
+ "retry_count": item.retry_count
283
+ }
284
+
285
+ if item.processed_at:
286
+ response["processed_at"] = item.processed_at.isoformat()
287
+
288
+ if item.error_message:
289
+ response["error"] = item.error_message
290
+
291
+ if item.result:
292
+ response["result"] = item.result
293
+
294
+ return response
295
+
296
+ async def _handle_tools_list(self) -> Dict[str, Any]:
297
+ """List available MCP tools."""
298
+ return {
299
+ "tools": [
300
+ {
301
+ "name": "ticket_create",
302
+ "description": "Create a new ticket",
303
+ "parameters": {
304
+ "type": "object",
305
+ "properties": {
306
+ "title": {"type": "string", "description": "Ticket title"},
307
+ "description": {"type": "string", "description": "Description"},
308
+ "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
309
+ "tags": {"type": "array", "items": {"type": "string"}},
310
+ "assignee": {"type": "string"},
311
+ },
312
+ "required": ["title"]
313
+ }
314
+ },
315
+ {
316
+ "name": "ticket_list",
317
+ "description": "List tickets",
318
+ "parameters": {
319
+ "type": "object",
320
+ "properties": {
321
+ "limit": {"type": "integer", "default": 10},
322
+ "state": {"type": "string"},
323
+ "priority": {"type": "string"},
324
+ }
325
+ }
326
+ },
327
+ {
328
+ "name": "ticket_update",
329
+ "description": "Update a ticket",
330
+ "parameters": {
331
+ "type": "object",
332
+ "properties": {
333
+ "ticket_id": {"type": "string", "description": "Ticket ID"},
334
+ "updates": {"type": "object", "description": "Fields to update"},
335
+ },
336
+ "required": ["ticket_id", "updates"]
337
+ }
338
+ },
339
+ {
340
+ "name": "ticket_transition",
341
+ "description": "Change ticket state",
342
+ "parameters": {
343
+ "type": "object",
344
+ "properties": {
345
+ "ticket_id": {"type": "string"},
346
+ "target_state": {"type": "string"},
347
+ },
348
+ "required": ["ticket_id", "target_state"]
349
+ }
350
+ },
351
+ {
352
+ "name": "ticket_search",
353
+ "description": "Search tickets",
354
+ "parameters": {
355
+ "type": "object",
356
+ "properties": {
357
+ "query": {"type": "string"},
358
+ "state": {"type": "string"},
359
+ "priority": {"type": "string"},
360
+ "limit": {"type": "integer", "default": 10},
361
+ }
362
+ }
363
+ },
364
+ {
365
+ "name": "ticket_status",
366
+ "description": "Check status of queued ticket operation",
367
+ "parameters": {
368
+ "type": "object",
369
+ "properties": {
370
+ "queue_id": {"type": "string", "description": "Queue ID returned from create/update/delete operations"},
371
+ },
372
+ "required": ["queue_id"]
373
+ }
374
+ },
375
+ ]
376
+ }
377
+
378
+ async def run(self) -> None:
379
+ """Run the MCP server, reading from stdin and writing to stdout."""
380
+ self.running = True
381
+ reader = asyncio.StreamReader()
382
+ protocol = asyncio.StreamReaderProtocol(reader)
383
+ await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin)
384
+
385
+ # Send initialization
386
+ init_message = {
387
+ "jsonrpc": "2.0",
388
+ "method": "initialized",
389
+ "params": {
390
+ "name": "mcp-ticketer",
391
+ "version": "0.1.0",
392
+ "capabilities": ["tickets", "comments", "search"]
393
+ }
394
+ }
395
+ sys.stdout.write(json.dumps(init_message) + "\n")
396
+ sys.stdout.flush()
397
+
398
+ # Main message loop
399
+ while self.running:
400
+ try:
401
+ line = await reader.readline()
402
+ if not line:
403
+ break
404
+
405
+ # Parse JSON-RPC request
406
+ request = json.loads(line.decode())
407
+
408
+ # Handle request
409
+ response = await self.handle_request(request)
410
+
411
+ # Send response
412
+ sys.stdout.write(json.dumps(response) + "\n")
413
+ sys.stdout.flush()
414
+
415
+ except json.JSONDecodeError as e:
416
+ error_response = self._error_response(
417
+ None,
418
+ -32700,
419
+ f"Parse error: {str(e)}"
420
+ )
421
+ sys.stdout.write(json.dumps(error_response) + "\n")
422
+ sys.stdout.flush()
423
+
424
+ except KeyboardInterrupt:
425
+ break
426
+
427
+ except Exception as e:
428
+ # Log error but continue running
429
+ sys.stderr.write(f"Error: {str(e)}\n")
430
+
431
+ async def stop(self) -> None:
432
+ """Stop the server."""
433
+ self.running = False
434
+ await self.adapter.close()
435
+
436
+
437
+ async def main():
438
+ """Main entry point for MCP server."""
439
+ # Load configuration
440
+ import json
441
+ from pathlib import Path
442
+
443
+ config_file = Path.home() / ".mcp-ticketer" / "config.json"
444
+ if config_file.exists():
445
+ with open(config_file, "r") as f:
446
+ config = json.load(f)
447
+ adapter_type = config.get("adapter", "aitrackdown")
448
+ adapter_config = config.get("config", {})
449
+ else:
450
+ adapter_type = "aitrackdown"
451
+ adapter_config = {"base_path": ".aitrackdown"}
452
+
453
+ # Create and run server
454
+ server = MCPTicketServer(adapter_type, adapter_config)
455
+ await server.run()
456
+
457
+
458
+ if __name__ == "__main__":
459
+ asyncio.run(main())
mcp_ticketer/py.typed ADDED
File without changes
@@ -0,0 +1,7 @@
1
+ """Async queue system for mcp-ticketer."""
2
+
3
+ from .queue import Queue, QueueItem, QueueStatus
4
+ from .worker import Worker
5
+ from .manager import WorkerManager
6
+
7
+ __all__ = ["Queue", "QueueItem", "QueueStatus", "Worker", "WorkerManager"]
@@ -0,0 +1,6 @@
1
+ """Run the queue worker as a module."""
2
+
3
+ from .run_worker import main
4
+
5
+ if __name__ == "__main__":
6
+ main()