mcp-ticketer 0.1.16__py3-none-any.whl → 0.1.19__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.

@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.1.16"
3
+ __version__ = "0.1.19"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -40,6 +40,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
40
40
  self.tracker = None
41
41
  self.tickets_dir.mkdir(parents=True, exist_ok=True)
42
42
 
43
+ def validate_credentials(self) -> tuple[bool, str]:
44
+ """Validate that required credentials are present.
45
+
46
+ AITrackdown is file-based and doesn't require credentials.
47
+
48
+ Returns:
49
+ (is_valid, error_message) - Always returns (True, "") for AITrackdown
50
+ """
51
+ # AITrackdown is file-based and doesn't require API credentials
52
+ # Just verify the base_path is accessible
53
+ if not self.base_path:
54
+ return False, "AITrackdown base_path is required in configuration"
55
+ return True, ""
56
+
43
57
  def _get_state_mapping(self) -> Dict[TicketState, str]:
44
58
  """Map universal states to AI-Trackdown states."""
45
59
  return {
@@ -191,6 +191,20 @@ class GitHubAdapter(BaseAdapter[Task]):
191
191
  self._milestones_cache: Optional[List[Dict[str, Any]]] = None
192
192
  self._rate_limit: Dict[str, Any] = {}
193
193
 
194
+ def validate_credentials(self) -> tuple[bool, str]:
195
+ """Validate that required credentials are present.
196
+
197
+ Returns:
198
+ (is_valid, error_message) - Tuple of validation result and error message
199
+ """
200
+ if not self.token:
201
+ return False, "GITHUB_TOKEN is required but not found. Set it in .env.local or environment."
202
+ if not self.owner:
203
+ return False, "GitHub owner is required in configuration. Set GITHUB_OWNER in .env.local or configure with 'mcp-ticketer init --adapter github --github-owner <owner>'"
204
+ if not self.repo:
205
+ return False, "GitHub repo is required in configuration. Set GITHUB_REPO in .env.local or configure with 'mcp-ticketer init --adapter github --github-repo <repo>'"
206
+ return True, ""
207
+
194
208
  def _get_state_mapping(self) -> Dict[TicketState, str]:
195
209
  """Map universal states to GitHub states."""
196
210
  return {
@@ -379,6 +393,11 @@ class GitHubAdapter(BaseAdapter[Task]):
379
393
 
380
394
  async def create(self, ticket: Task) -> Task:
381
395
  """Create a new GitHub issue."""
396
+ # Validate credentials before attempting operation
397
+ is_valid, error_message = self.validate_credentials()
398
+ if not is_valid:
399
+ raise ValueError(error_message)
400
+
382
401
  # Prepare labels
383
402
  labels = ticket.tags.copy() if ticket.tags else []
384
403
 
@@ -448,6 +467,11 @@ class GitHubAdapter(BaseAdapter[Task]):
448
467
 
449
468
  async def read(self, ticket_id: str) -> Optional[Task]:
450
469
  """Read a GitHub issue by number."""
470
+ # Validate credentials before attempting operation
471
+ is_valid, error_message = self.validate_credentials()
472
+ if not is_valid:
473
+ raise ValueError(error_message)
474
+
451
475
  try:
452
476
  issue_number = int(ticket_id)
453
477
  except ValueError:
@@ -468,6 +492,11 @@ class GitHubAdapter(BaseAdapter[Task]):
468
492
 
469
493
  async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[Task]:
470
494
  """Update a GitHub issue."""
495
+ # Validate credentials before attempting operation
496
+ is_valid, error_message = self.validate_credentials()
497
+ if not is_valid:
498
+ raise ValueError(error_message)
499
+
471
500
  try:
472
501
  issue_number = int(ticket_id)
473
502
  except ValueError:
@@ -584,6 +613,11 @@ class GitHubAdapter(BaseAdapter[Task]):
584
613
 
585
614
  async def delete(self, ticket_id: str) -> bool:
586
615
  """Delete (close) a GitHub issue."""
616
+ # Validate credentials before attempting operation
617
+ is_valid, error_message = self.validate_credentials()
618
+ if not is_valid:
619
+ raise ValueError(error_message)
620
+
587
621
  try:
588
622
  issue_number = int(ticket_id)
589
623
  except ValueError:
@@ -89,6 +89,20 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
89
89
  self._issue_types_cache: Dict[str, Any] = {}
90
90
  self._custom_fields_cache: Dict[str, Any] = {}
91
91
 
92
+ def validate_credentials(self) -> tuple[bool, str]:
93
+ """Validate that required credentials are present.
94
+
95
+ Returns:
96
+ (is_valid, error_message) - Tuple of validation result and error message
97
+ """
98
+ if not self.server:
99
+ return False, "JIRA_SERVER is required but not found. Set it in .env.local or environment."
100
+ if not self.email:
101
+ return False, "JIRA_EMAIL is required but not found. Set it in .env.local or environment."
102
+ if not self.api_token:
103
+ return False, "JIRA_API_TOKEN is required but not found. Set it in .env.local or environment."
104
+ return True, ""
105
+
92
106
  def _get_state_mapping(self) -> Dict[TicketState, str]:
93
107
  """Map universal states to common JIRA workflow states."""
94
108
  return {
@@ -457,6 +471,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
457
471
 
458
472
  async def create(self, ticket: Union[Epic, Task]) -> Union[Epic, Task]:
459
473
  """Create a new JIRA issue."""
474
+ # Validate credentials before attempting operation
475
+ is_valid, error_message = self.validate_credentials()
476
+ if not is_valid:
477
+ raise ValueError(error_message)
478
+
460
479
  # Prepare issue fields
461
480
  fields = self._ticket_to_issue_fields(ticket)
462
481
 
@@ -476,6 +495,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
476
495
 
477
496
  async def read(self, ticket_id: str) -> Optional[Union[Epic, Task]]:
478
497
  """Read a JIRA issue by key."""
498
+ # Validate credentials before attempting operation
499
+ is_valid, error_message = self.validate_credentials()
500
+ if not is_valid:
501
+ raise ValueError(error_message)
502
+
479
503
  try:
480
504
  issue = await self._make_request(
481
505
  "GET",
@@ -494,6 +518,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
494
518
  updates: Dict[str, Any]
495
519
  ) -> Optional[Union[Epic, Task]]:
496
520
  """Update a JIRA issue."""
521
+ # Validate credentials before attempting operation
522
+ is_valid, error_message = self.validate_credentials()
523
+ if not is_valid:
524
+ raise ValueError(error_message)
525
+
497
526
  # Read current issue
498
527
  current = await self.read(ticket_id)
499
528
  if not current:
@@ -530,6 +559,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
530
559
 
531
560
  async def delete(self, ticket_id: str) -> bool:
532
561
  """Delete a JIRA issue."""
562
+ # Validate credentials before attempting operation
563
+ is_valid, error_message = self.validate_credentials()
564
+ if not is_valid:
565
+ raise ValueError(error_message)
566
+
533
567
  try:
534
568
  await self._make_request("DELETE", f"issue/{ticket_id}")
535
569
  return True
@@ -277,8 +277,11 @@ class LinearAdapter(BaseAdapter[Task]):
277
277
  config: Configuration with:
278
278
  - api_key: Linear API key (or LINEAR_API_KEY env var)
279
279
  - workspace: Linear workspace name (optional, for documentation)
280
- - team_key: Linear team key (required, e.g., 'BTA')
280
+ - team_key: Linear team key (e.g., 'BTA') OR
281
+ - team_id: Linear team UUID (e.g., '02d15669-7351-4451-9719-807576c16049')
281
282
  - api_url: Optional Linear API URL
283
+
284
+ Note: Either team_key or team_id is required. If both are provided, team_id takes precedence.
282
285
  """
283
286
  super().__init__(config)
284
287
 
@@ -288,18 +291,16 @@ class LinearAdapter(BaseAdapter[Task]):
288
291
  raise ValueError("Linear API key required (config.api_key or LINEAR_API_KEY env var)")
289
292
 
290
293
  self.workspace = config.get("workspace") # Optional, for documentation
291
- self.team_key = config.get("team_key")
292
- if not self.team_key:
293
- raise ValueError("Linear team_key is required in configuration")
294
- self.api_url = config.get("api_url", "https://api.linear.app/graphql")
295
294
 
296
- # Setup GraphQL client with authentication
297
- transport = HTTPXAsyncTransport(
298
- url=self.api_url,
299
- headers={"Authorization": self.api_key},
300
- timeout=30.0,
301
- )
302
- self.client = Client(transport=transport, fetch_schema_from_transport=False)
295
+ # Support both team_key (short key) and team_id (UUID)
296
+ self.team_key = config.get("team_key") # Short key like "BTA"
297
+ self.team_id_config = config.get("team_id") # UUID like "02d15669-..."
298
+
299
+ # Require at least one team identifier
300
+ if not self.team_key and not self.team_id_config:
301
+ raise ValueError("Either team_key or team_id is required in configuration")
302
+
303
+ self.api_url = config.get("api_url", "https://api.linear.app/graphql")
303
304
 
304
305
  # Caches for frequently used data
305
306
  self._team_id: Optional[str] = None
@@ -314,6 +315,22 @@ class LinearAdapter(BaseAdapter[Task]):
314
315
  self._init_lock = asyncio.Lock()
315
316
  self._initialized = False
316
317
 
318
+ def _create_client(self) -> Client:
319
+ """Create a fresh GraphQL client for each operation.
320
+
321
+ This prevents 'Transport is already connected' errors by ensuring
322
+ each operation gets its own client and transport instance.
323
+
324
+ Returns:
325
+ Client: Fresh GraphQL client instance
326
+ """
327
+ transport = HTTPXAsyncTransport(
328
+ url=self.api_url,
329
+ headers={"Authorization": self.api_key},
330
+ timeout=30.0,
331
+ )
332
+ return Client(transport=transport, fetch_schema_from_transport=False)
333
+
317
334
  async def initialize(self) -> None:
318
335
  """Initialize adapter by preloading team, states, and labels data concurrently."""
319
336
  if self._initialized:
@@ -347,9 +364,36 @@ class LinearAdapter(BaseAdapter[Task]):
347
364
  raise e
348
365
 
349
366
  async def _fetch_team_data(self) -> str:
350
- """Fetch team ID."""
367
+ """Fetch team ID.
368
+
369
+ If team_id is configured, validate it exists and return it.
370
+ If team_key is configured, fetch the team_id by key.
371
+ """
372
+ # If team_id (UUID) is provided, use it directly (preferred)
373
+ if self.team_id_config:
374
+ # Validate that this team ID exists
375
+ query = gql("""
376
+ query GetTeamById($id: String!) {
377
+ team(id: $id) {
378
+ id
379
+ name
380
+ key
381
+ }
382
+ }
383
+ """)
384
+
385
+ client = self._create_client()
386
+ async with client as session:
387
+ result = await session.execute(query, variable_values={"id": self.team_id_config})
388
+
389
+ if not result.get("team"):
390
+ raise ValueError(f"Team with ID '{self.team_id_config}' not found")
391
+
392
+ return result["team"]["id"]
393
+
394
+ # Otherwise, fetch team ID by key
351
395
  query = gql("""
352
- query GetTeam($key: String!) {
396
+ query GetTeamByKey($key: String!) {
353
397
  teams(filter: { key: { eq: $key } }) {
354
398
  nodes {
355
399
  id
@@ -360,7 +404,8 @@ class LinearAdapter(BaseAdapter[Task]):
360
404
  }
361
405
  """)
362
406
 
363
- async with self.client as session:
407
+ client = self._create_client()
408
+ async with client as session:
364
409
  result = await session.execute(query, variable_values={"key": self.team_key})
365
410
 
366
411
  if not result["teams"]["nodes"]:
@@ -384,7 +429,8 @@ class LinearAdapter(BaseAdapter[Task]):
384
429
  }
385
430
  """)
386
431
 
387
- async with self.client as session:
432
+ client = self._create_client()
433
+ async with client as session:
388
434
  result = await session.execute(query, variable_values={"teamId": team_id})
389
435
 
390
436
  workflow_states = {}
@@ -410,7 +456,8 @@ class LinearAdapter(BaseAdapter[Task]):
410
456
  }
411
457
  """)
412
458
 
413
- async with self.client as session:
459
+ client = self._create_client()
460
+ async with client as session:
414
461
  result = await session.execute(query, variable_values={"teamId": team_id})
415
462
 
416
463
  return {label["name"]: label["id"] for label in result["issueLabels"]["nodes"]}
@@ -451,7 +498,8 @@ class LinearAdapter(BaseAdapter[Task]):
451
498
  }
452
499
  """)
453
500
 
454
- async with self.client as session:
501
+ client = self._create_client()
502
+ async with client as session:
455
503
  result = await session.execute(
456
504
  search_query,
457
505
  variable_values={"name": name, "teamId": team_id}
@@ -481,7 +529,8 @@ class LinearAdapter(BaseAdapter[Task]):
481
529
  if color:
482
530
  label_input["color"] = color
483
531
 
484
- async with self.client as session:
532
+ client = self._create_client()
533
+ async with client as session:
485
534
  result = await session.execute(
486
535
  create_query,
487
536
  variable_values={"input": label_input}
@@ -510,7 +559,8 @@ class LinearAdapter(BaseAdapter[Task]):
510
559
  }
511
560
  """)
512
561
 
513
- async with self.client as session:
562
+ client = self._create_client()
563
+ async with client as session:
514
564
  result = await session.execute(query, variable_values={"email": email})
515
565
 
516
566
  if result["users"]["nodes"]:
@@ -520,6 +570,18 @@ class LinearAdapter(BaseAdapter[Task]):
520
570
 
521
571
  return None
522
572
 
573
+ def validate_credentials(self) -> tuple[bool, str]:
574
+ """Validate that required credentials are present.
575
+
576
+ Returns:
577
+ (is_valid, error_message) - Tuple of validation result and error message
578
+ """
579
+ if not self.api_key:
580
+ return False, "LINEAR_API_KEY is required but not found. Set it in .env.local or environment."
581
+ if not self.team_key and not self.team_id_config:
582
+ return False, "Either Linear team_key or team_id is required in configuration. Set it in .mcp-ticketer/config.json"
583
+ return True, ""
584
+
523
585
  def _get_state_mapping(self) -> Dict[TicketState, str]:
524
586
  """Get mapping from universal states to Linear state types.
525
587
 
@@ -711,6 +773,11 @@ class LinearAdapter(BaseAdapter[Task]):
711
773
 
712
774
  async def create(self, ticket: Task) -> Task:
713
775
  """Create a new Linear issue with full field support."""
776
+ # Validate credentials before attempting operation
777
+ is_valid, error_message = self.validate_credentials()
778
+ if not is_valid:
779
+ raise ValueError(error_message)
780
+
714
781
  team_id = await self._ensure_team_id()
715
782
  states = await self._get_workflow_states()
716
783
 
@@ -775,7 +842,8 @@ class LinearAdapter(BaseAdapter[Task]):
775
842
  }
776
843
  }
777
844
  """)
778
- async with self.client as session:
845
+ client = self._create_client()
846
+ async with client as session:
779
847
  parent_result = await session.execute(
780
848
  parent_query,
781
849
  variable_values={"identifier": ticket.parent_issue}
@@ -807,7 +875,8 @@ class LinearAdapter(BaseAdapter[Task]):
807
875
  }
808
876
  """)
809
877
 
810
- async with self.client as session:
878
+ client = self._create_client()
879
+ async with client as session:
811
880
  result = await session.execute(
812
881
  create_query,
813
882
  variable_values={"input": issue_input}
@@ -821,6 +890,11 @@ class LinearAdapter(BaseAdapter[Task]):
821
890
 
822
891
  async def read(self, ticket_id: str) -> Optional[Task]:
823
892
  """Read a Linear issue by identifier with full details."""
893
+ # Validate credentials before attempting operation
894
+ is_valid, error_message = self.validate_credentials()
895
+ if not is_valid:
896
+ raise ValueError(error_message)
897
+
824
898
  query = gql(ALL_FRAGMENTS + """
825
899
  query GetIssue($identifier: String!) {
826
900
  issue(id: $identifier) {
@@ -830,7 +904,8 @@ class LinearAdapter(BaseAdapter[Task]):
830
904
  """)
831
905
 
832
906
  try:
833
- async with self.client as session:
907
+ client = self._create_client()
908
+ async with client as session:
834
909
  result = await session.execute(
835
910
  query,
836
911
  variable_values={"identifier": ticket_id}
@@ -846,6 +921,11 @@ class LinearAdapter(BaseAdapter[Task]):
846
921
 
847
922
  async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[Task]:
848
923
  """Update a Linear issue with comprehensive field support."""
924
+ # Validate credentials before attempting operation
925
+ is_valid, error_message = self.validate_credentials()
926
+ if not is_valid:
927
+ raise ValueError(error_message)
928
+
849
929
  # First get the Linear internal ID
850
930
  query = gql("""
851
931
  query GetIssueId($identifier: String!) {
@@ -855,7 +935,8 @@ class LinearAdapter(BaseAdapter[Task]):
855
935
  }
856
936
  """)
857
937
 
858
- async with self.client as session:
938
+ client = self._create_client()
939
+ async with client as session:
859
940
  result = await session.execute(
860
941
  query,
861
942
  variable_values={"identifier": ticket_id}
@@ -931,7 +1012,8 @@ class LinearAdapter(BaseAdapter[Task]):
931
1012
  }
932
1013
  """)
933
1014
 
934
- async with self.client as session:
1015
+ client = self._create_client()
1016
+ async with client as session:
935
1017
  result = await session.execute(
936
1018
  update_query,
937
1019
  variable_values={"id": linear_id, "input": update_input}
@@ -944,6 +1026,11 @@ class LinearAdapter(BaseAdapter[Task]):
944
1026
 
945
1027
  async def delete(self, ticket_id: str) -> bool:
946
1028
  """Archive (soft delete) a Linear issue."""
1029
+ # Validate credentials before attempting operation
1030
+ is_valid, error_message = self.validate_credentials()
1031
+ if not is_valid:
1032
+ raise ValueError(error_message)
1033
+
947
1034
  # Get Linear ID
948
1035
  query = gql("""
949
1036
  query GetIssueId($identifier: String!) {
@@ -953,7 +1040,8 @@ class LinearAdapter(BaseAdapter[Task]):
953
1040
  }
954
1041
  """)
955
1042
 
956
- async with self.client as session:
1043
+ client = self._create_client()
1044
+ async with client as session:
957
1045
  result = await session.execute(
958
1046
  query,
959
1047
  variable_values={"identifier": ticket_id}
@@ -973,7 +1061,8 @@ class LinearAdapter(BaseAdapter[Task]):
973
1061
  }
974
1062
  """)
975
1063
 
976
- async with self.client as session:
1064
+ client = self._create_client()
1065
+ async with client as session:
977
1066
  result = await session.execute(
978
1067
  archive_query,
979
1068
  variable_values={"id": linear_id}
@@ -1069,7 +1158,8 @@ class LinearAdapter(BaseAdapter[Task]):
1069
1158
  }
1070
1159
  """)
1071
1160
 
1072
- async with self.client as session:
1161
+ client = self._create_client()
1162
+ async with client as session:
1073
1163
  result = await session.execute(
1074
1164
  query,
1075
1165
  variable_values={
@@ -1143,7 +1233,8 @@ class LinearAdapter(BaseAdapter[Task]):
1143
1233
  }
1144
1234
  """)
1145
1235
 
1146
- async with self.client as session:
1236
+ client = self._create_client()
1237
+ async with client as session:
1147
1238
  result = await session.execute(
1148
1239
  search_query,
1149
1240
  variable_values={
@@ -1183,7 +1274,8 @@ class LinearAdapter(BaseAdapter[Task]):
1183
1274
  }
1184
1275
  """)
1185
1276
 
1186
- async with self.client as session:
1277
+ client = self._create_client()
1278
+ async with client as session:
1187
1279
  result = await session.execute(
1188
1280
  query,
1189
1281
  variable_values={"identifier": comment.ticket_id}
@@ -1215,7 +1307,8 @@ class LinearAdapter(BaseAdapter[Task]):
1215
1307
  if comment.metadata and "parent_comment_id" in comment.metadata:
1216
1308
  comment_input["parentId"] = comment.metadata["parent_comment_id"]
1217
1309
 
1218
- async with self.client as session:
1310
+ client = self._create_client()
1311
+ async with client as session:
1219
1312
  result = await session.execute(
1220
1313
  create_comment_query,
1221
1314
  variable_values={"input": comment_input}
@@ -1260,7 +1353,8 @@ class LinearAdapter(BaseAdapter[Task]):
1260
1353
  """)
1261
1354
 
1262
1355
  try:
1263
- async with self.client as session:
1356
+ client = self._create_client()
1357
+ async with client as session:
1264
1358
  result = await session.execute(
1265
1359
  query,
1266
1360
  variable_values={
@@ -1316,7 +1410,8 @@ class LinearAdapter(BaseAdapter[Task]):
1316
1410
  if description:
1317
1411
  project_input["description"] = description
1318
1412
 
1319
- async with self.client as session:
1413
+ client = self._create_client()
1414
+ async with client as session:
1320
1415
  result = await session.execute(
1321
1416
  create_query,
1322
1417
  variable_values={"input": project_input}
@@ -1357,7 +1452,8 @@ class LinearAdapter(BaseAdapter[Task]):
1357
1452
  }
1358
1453
  """)
1359
1454
 
1360
- async with self.client as session:
1455
+ client = self._create_client()
1456
+ async with client as session:
1361
1457
  result = await session.execute(
1362
1458
  query,
1363
1459
  variable_values={"filter": cycle_filter}
@@ -1392,7 +1488,8 @@ class LinearAdapter(BaseAdapter[Task]):
1392
1488
  }
1393
1489
  """)
1394
1490
 
1395
- async with self.client as session:
1491
+ client = self._create_client()
1492
+ async with client as session:
1396
1493
  result = await session.execute(
1397
1494
  create_query,
1398
1495
  variable_values={
@@ -1470,7 +1567,8 @@ class LinearAdapter(BaseAdapter[Task]):
1470
1567
  },
1471
1568
  }
1472
1569
 
1473
- async with self.client as session:
1570
+ client = self._create_client()
1571
+ async with client as session:
1474
1572
  result = await session.execute(
1475
1573
  create_query,
1476
1574
  variable_values={"input": attachment_input}
@@ -1569,7 +1667,8 @@ class LinearAdapter(BaseAdapter[Task]):
1569
1667
  raise ValueError(f"Could not find Linear ID for issue {ticket_id}")
1570
1668
  linear_id = search_result["id"]
1571
1669
 
1572
- async with self.client as session:
1670
+ client = self._create_client()
1671
+ async with client as session:
1573
1672
  result = await session.execute(
1574
1673
  update_query,
1575
1674
  variable_values={
@@ -1616,7 +1715,8 @@ class LinearAdapter(BaseAdapter[Task]):
1616
1715
  """)
1617
1716
 
1618
1717
  try:
1619
- async with self.client as session:
1718
+ client = self._create_client()
1719
+ async with client as session:
1620
1720
  result = await session.execute(
1621
1721
  search_query,
1622
1722
  variable_values={"identifier": identifier}
@@ -1669,7 +1769,8 @@ class LinearAdapter(BaseAdapter[Task]):
1669
1769
  if "lead_id" in kwargs:
1670
1770
  project_input["leadId"] = kwargs["lead_id"]
1671
1771
 
1672
- async with self.client as session:
1772
+ client = self._create_client()
1773
+ async with client as session:
1673
1774
  result = await session.execute(
1674
1775
  create_query,
1675
1776
  variable_values={"input": project_input}
@@ -1699,7 +1800,8 @@ class LinearAdapter(BaseAdapter[Task]):
1699
1800
  """)
1700
1801
 
1701
1802
  try:
1702
- async with self.client as session:
1803
+ client = self._create_client()
1804
+ async with client as session:
1703
1805
  result = await session.execute(
1704
1806
  query,
1705
1807
  variable_values={"id": epic_id}
@@ -1748,7 +1850,8 @@ class LinearAdapter(BaseAdapter[Task]):
1748
1850
  }
1749
1851
  """)
1750
1852
 
1751
- async with self.client as session:
1853
+ client = self._create_client()
1854
+ async with client as session:
1752
1855
  result = await session.execute(
1753
1856
  query,
1754
1857
  variable_values={
@@ -1815,7 +1918,8 @@ class LinearAdapter(BaseAdapter[Task]):
1815
1918
  """)
1816
1919
 
1817
1920
  try:
1818
- async with self.client as session:
1921
+ client = self._create_client()
1922
+ async with client as session:
1819
1923
  result = await session.execute(
1820
1924
  query,
1821
1925
  variable_values={"projectId": epic_id, "first": 100}
@@ -1868,7 +1972,8 @@ class LinearAdapter(BaseAdapter[Task]):
1868
1972
  }
1869
1973
  """)
1870
1974
 
1871
- async with self.client as session:
1975
+ client = self._create_client()
1976
+ async with client as session:
1872
1977
  parent_result = await session.execute(
1873
1978
  parent_query,
1874
1979
  variable_values={"identifier": parent_id}
@@ -1933,7 +2038,8 @@ class LinearAdapter(BaseAdapter[Task]):
1933
2038
  }
1934
2039
  """)
1935
2040
 
1936
- async with self.client as session:
2041
+ client = self._create_client()
2042
+ async with client as session:
1937
2043
  result = await session.execute(
1938
2044
  create_query,
1939
2045
  variable_values={"input": issue_input}
@@ -1967,7 +2073,8 @@ class LinearAdapter(BaseAdapter[Task]):
1967
2073
  """)
1968
2074
 
1969
2075
  try:
1970
- async with self.client as session:
2076
+ client = self._create_client()
2077
+ async with client as session:
1971
2078
  result = await session.execute(
1972
2079
  query,
1973
2080
  variable_values={"identifier": issue_id}
@@ -1988,11 +2095,13 @@ class LinearAdapter(BaseAdapter[Task]):
1988
2095
  return []
1989
2096
 
1990
2097
  async def close(self) -> None:
1991
- """Close the GraphQL client connection."""
1992
- if hasattr(self.client, 'close_async'):
1993
- await self.client.close_async()
1994
- elif hasattr(self.client.transport, 'close'):
1995
- await self.client.transport.close()
2098
+ """Close the GraphQL client connection.
2099
+
2100
+ Since we create fresh clients for each operation, there's no persistent
2101
+ connection to close. Each client's transport is automatically closed when
2102
+ the async context manager exits.
2103
+ """
2104
+ pass
1996
2105
 
1997
2106
 
1998
2107
  # Register the adapter
mcp_ticketer/cli/main.py CHANGED
@@ -70,18 +70,24 @@ class AdapterType(str, Enum):
70
70
  GITHUB = "github"
71
71
 
72
72
 
73
- def load_config() -> dict:
73
+ def load_config(project_dir: Optional[Path] = None) -> dict:
74
74
  """Load configuration from file.
75
75
 
76
+ Args:
77
+ project_dir: Optional project directory to load config from
78
+
76
79
  Resolution order:
77
- 1. Project-specific config (.mcp-ticketer/config.json in cwd)
80
+ 1. Project-specific config (.mcp-ticketer/config.json in project_dir or cwd)
78
81
  2. Global config (~/.mcp-ticketer/config.json)
79
82
 
80
83
  Returns:
81
84
  Configuration dictionary
82
85
  """
86
+ # Use provided project_dir or current working directory
87
+ base_dir = project_dir or Path.cwd()
88
+
83
89
  # Check project-specific config first
84
- project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
90
+ project_config = base_dir / ".mcp-ticketer" / "config.json"
85
91
  if project_config.exists():
86
92
  try:
87
93
  with open(project_config, "r") as f:
@@ -29,6 +29,15 @@ class BaseAdapter(ABC, Generic[T]):
29
29
  """
30
30
  pass
31
31
 
32
+ @abstractmethod
33
+ def validate_credentials(self) -> tuple[bool, str]:
34
+ """Validate that required credentials are present.
35
+
36
+ Returns:
37
+ (is_valid, error_message) - Tuple of validation result and error message
38
+ """
39
+ pass
40
+
32
41
  @abstractmethod
33
42
  async def create(self, ticket: T) -> T:
34
43
  """Create a new ticket.
@@ -211,9 +211,9 @@ class ConfigValidator:
211
211
  if field not in config or not config[field]:
212
212
  return False, f"Linear config missing required field: {field}"
213
213
 
214
- # Warn if team_id is missing but don't fail
215
- if not config.get("team_id"):
216
- logger.warning("Linear config missing team_id - may be required for some operations")
214
+ # Require either team_key or team_id (team_id is preferred)
215
+ if not config.get("team_key") and not config.get("team_id"):
216
+ return False, "Linear config requires either team_key (short key like 'BTA') or team_id (UUID)"
217
217
 
218
218
  return True, None
219
219
 
@@ -4,12 +4,31 @@ import asyncio
4
4
  import json
5
5
  import sys
6
6
  from typing import Any, Dict, List, Optional
7
+ from pathlib import Path
8
+ from dotenv import load_dotenv
7
9
 
8
10
  from ..core import Task, TicketState, Priority, AdapterRegistry
9
11
  from ..core.models import SearchQuery, Comment
10
12
  from ..adapters import AITrackdownAdapter
11
13
  from ..queue import Queue, QueueStatus, WorkerManager
12
14
 
15
+ # Load environment variables early (prioritize .env.local)
16
+ # Check for .env.local first (takes precedence)
17
+ env_local_file = Path.cwd() / ".env.local"
18
+ if env_local_file.exists():
19
+ load_dotenv(env_local_file, override=True)
20
+ sys.stderr.write(f"[MCP Server] Loaded environment from: {env_local_file}\n")
21
+ else:
22
+ # Fall back to .env
23
+ env_file = Path.cwd() / ".env"
24
+ if env_file.exists():
25
+ load_dotenv(env_file, override=True)
26
+ sys.stderr.write(f"[MCP Server] Loaded environment from: {env_file}\n")
27
+ else:
28
+ # Try default dotenv loading (searches upward)
29
+ load_dotenv(override=True)
30
+ sys.stderr.write("[MCP Server] Loaded environment from default search path\n")
31
+
13
32
 
14
33
  class MCPTicketServer:
15
34
  """MCP server for ticket operations over stdio."""
@@ -32,6 +32,7 @@ class QueueItem:
32
32
  error_message: Optional[str] = None
33
33
  retry_count: int = 0
34
34
  result: Optional[Dict[str, Any]] = None
35
+ project_dir: Optional[str] = None
35
36
 
36
37
  def to_dict(self) -> dict:
37
38
  """Convert to dictionary for storage."""
@@ -54,7 +55,8 @@ class QueueItem:
54
55
  processed_at=datetime.fromisoformat(row[6]) if row[6] else None,
55
56
  error_message=row[7],
56
57
  retry_count=row[8],
57
- result=json.loads(row[9]) if row[9] else None
58
+ result=json.loads(row[9]) if row[9] else None,
59
+ project_dir=row[10] if len(row) > 10 else None
58
60
  )
59
61
 
60
62
 
@@ -109,31 +111,43 @@ class Queue:
109
111
  ON queue(adapter)
110
112
  ''')
111
113
 
114
+ # Migration: Add project_dir column if it doesn't exist
115
+ cursor = conn.execute("PRAGMA table_info(queue)")
116
+ columns = [row[1] for row in cursor.fetchall()]
117
+ if 'project_dir' not in columns:
118
+ conn.execute('ALTER TABLE queue ADD COLUMN project_dir TEXT')
119
+
112
120
  conn.commit()
113
121
 
114
122
  def add(self,
115
123
  ticket_data: Dict[str, Any],
116
124
  adapter: str,
117
- operation: str) -> str:
125
+ operation: str,
126
+ project_dir: Optional[str] = None) -> str:
118
127
  """Add item to queue.
119
128
 
120
129
  Args:
121
130
  ticket_data: The ticket data for the operation
122
131
  adapter: Name of the adapter to use
123
132
  operation: Operation to perform (create, update, delete, etc.)
133
+ project_dir: Project directory for config resolution (defaults to current directory)
124
134
 
125
135
  Returns:
126
136
  Queue ID for tracking
127
137
  """
128
138
  queue_id = f"Q-{uuid.uuid4().hex[:8].upper()}"
129
139
 
140
+ # Default to current working directory if not provided
141
+ if project_dir is None:
142
+ project_dir = str(Path.cwd())
143
+
130
144
  with self._lock:
131
145
  with sqlite3.connect(self.db_path) as conn:
132
146
  conn.execute('''
133
147
  INSERT INTO queue (
134
148
  id, ticket_data, adapter, operation,
135
- status, created_at, retry_count
136
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
149
+ status, created_at, retry_count, project_dir
150
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
137
151
  ''', (
138
152
  queue_id,
139
153
  json.dumps(ticket_data),
@@ -141,7 +155,8 @@ class Queue:
141
155
  operation,
142
156
  QueueStatus.PENDING.value,
143
157
  datetime.now().isoformat(),
144
- 0
158
+ 0,
159
+ project_dir
145
160
  ))
146
161
  conn.commit()
147
162
 
@@ -303,15 +303,26 @@ class Worker:
303
303
  Returns:
304
304
  Adapter instance
305
305
  """
306
- # Load configuration
306
+ # Load configuration from the project directory where the item was created
307
307
  from ..cli.main import load_config
308
+ from pathlib import Path
309
+ import os
310
+
311
+ # Use item's project_dir if available, otherwise use current directory
312
+ project_path = Path(item.project_dir) if item.project_dir else None
308
313
 
309
- config = load_config()
314
+ # Load environment variables from project directory's .env.local if it exists
315
+ if project_path:
316
+ env_file = project_path / ".env.local"
317
+ if env_file.exists():
318
+ logger.debug(f"Loading environment from {env_file}")
319
+ load_dotenv(env_file)
320
+
321
+ config = load_config(project_dir=project_path)
310
322
  adapters_config = config.get("adapters", {})
311
323
  adapter_config = adapters_config.get(item.adapter, {})
312
324
 
313
325
  # Add environment variables for authentication
314
- import os
315
326
  if item.adapter == "linear":
316
327
  if not adapter_config.get("api_key"):
317
328
  adapter_config["api_key"] = os.getenv("LINEAR_API_KEY")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-ticketer
3
- Version: 0.1.16
3
+ Version: 0.1.19
4
4
  Summary: Universal ticket management interface for AI agents with MCP support
5
5
  Author-email: MCP Ticketer Team <support@mcp-ticketer.io>
6
6
  Maintainer-email: MCP Ticketer Team <support@mcp-ticketer.io>
@@ -1,42 +1,42 @@
1
1
  mcp_ticketer/__init__.py,sha256=ayPQdFr6msypD06_G96a1H0bdFCT1m1wDtv8MZBpY4I,496
2
- mcp_ticketer/__version__.py,sha256=VU-W9BGb5-hfF3jvOPn-c-W8RnOMrAL8rswIa9JjVSE,1115
2
+ mcp_ticketer/__version__.py,sha256=kw2I3NwlDBDu_l0kuiTcEjSESqAT8gllqD-5y3g9No4,1115
3
3
  mcp_ticketer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  mcp_ticketer/adapters/__init__.py,sha256=K_1egvhHb5F_7yFceUx2YzPGEoc7vX-q8dMVaS4K6gw,356
5
- mcp_ticketer/adapters/aitrackdown.py,sha256=gqS_N6VGLoG5itUu17ANG5SefaAITYoW-t2xL9SrY-Y,15372
6
- mcp_ticketer/adapters/github.py,sha256=onT8NhYaf9fIw2eCOTbZSkk7q4IoM7ZADRvRl9qrUz8,43850
5
+ mcp_ticketer/adapters/aitrackdown.py,sha256=916SpzQcG6gSdQit5Ptm9AdGOsZFXpt9nNWlRjCReMY,15924
6
+ mcp_ticketer/adapters/github.py,sha256=NdUPaSlOEi4zZN_VBvAjSJANJhp1IBwdOkkF6fGbaKs,45410
7
7
  mcp_ticketer/adapters/hybrid.py,sha256=H9B-pfWmDKXO3GgzxB8undEcZTMzLz_1a6zWhj7xfR0,18556
8
- mcp_ticketer/adapters/jira.py,sha256=rd-8PseTsRyQNPjsrUJ8vJ8vfBpa6HWFBieOUyvw0Tg,28954
9
- mcp_ticketer/adapters/linear.py,sha256=BUK40EF4yNVJV5ldJPFx7Ne4FXHYbrGCUAaFy2gWC9Y,65211
8
+ mcp_ticketer/adapters/jira.py,sha256=jxoQS22wjOl1FhsYiGK-r1pLXOenUmbe5wa0ehD6xDg,30373
9
+ mcp_ticketer/adapters/linear.py,sha256=ewGTpvJmyTBJd73PPQBM6ijwVsacXZ4oRAigh_RAdAA,69315
10
10
  mcp_ticketer/cache/__init__.py,sha256=MSi3GLXancfP2-edPC9TFAJk7r0j6H5-XmpMHnkGPbI,137
11
11
  mcp_ticketer/cache/memory.py,sha256=gTzv-xF7qGfiYVUjG7lnzo0ZcqgXQajMl4NAYUcaytg,5133
12
12
  mcp_ticketer/cli/__init__.py,sha256=YeljyLtv906TqkvRuEPhmKO-Uk0CberQ9I6kx1tx2UA,88
13
13
  mcp_ticketer/cli/configure.py,sha256=etFutvc0QpaVDMOsZiiN7wKuaT98Od1Tj9W6lsEWw5A,16351
14
14
  mcp_ticketer/cli/discover.py,sha256=putWrGcctUH8K0fOMtr9MZA9VnWoXzbtoe7mXSkDxhg,13156
15
- mcp_ticketer/cli/main.py,sha256=WGYXZ0r0Iv296qrCMZLPGHUAWagRhkAgqAm-lJSnyUY,40428
15
+ mcp_ticketer/cli/main.py,sha256=T8m3mBcBqvOmW_bVU-RsSfzGY1QWZN9c91tfcOJ8KB0,40656
16
16
  mcp_ticketer/cli/mcp_configure.py,sha256=1WbBdF25OvxfAGcjxtYa9xmBOGEPQu-wh_nkefmWjMQ,9352
17
17
  mcp_ticketer/cli/migrate_config.py,sha256=iZIstnlr9vkhiW_MlnSyJOkMi4KHQqrZ6Hz1ECD_VUk,6045
18
18
  mcp_ticketer/cli/queue_commands.py,sha256=f3pEHKZ43dBHEIoCBvdfvjfMB9_WJltps9ATwTzorY0,8160
19
19
  mcp_ticketer/cli/utils.py,sha256=cdP-7GHtELAPZtqInUC24k_SAnRbIRkafIP3T4kMZDM,19509
20
20
  mcp_ticketer/core/__init__.py,sha256=qpCZveQMyqU2JvYG9MG_c6X35z_VoSmjWdXGcUZqqmA,348
21
- mcp_ticketer/core/adapter.py,sha256=6KfhceINHfDjs--RyA_rOLeM2phVD_D85S9D6s2lcuU,10075
21
+ mcp_ticketer/core/adapter.py,sha256=W87W-hEmgCxw5BkvaFlCGZtouN49aW2KHND53zgg6-c,10339
22
22
  mcp_ticketer/core/config.py,sha256=9a2bksbcFr7KXeHSPY6KoSP5Pzt54utYPCmbM-1QKmk,13932
23
23
  mcp_ticketer/core/env_discovery.py,sha256=SPoyq_y5j-3gJG5gYNVjCIIrbdzimOdDbTYySmQWZOA,17536
24
24
  mcp_ticketer/core/http_client.py,sha256=RM9CEMNcuRb-FxhAijmM_FeBMgxgh1OII9HIPBdJue0,13855
25
25
  mcp_ticketer/core/mappers.py,sha256=8I4jcqDqoQEdWlteDMpVeVF3Wo0fDCkmFPRr8oNv8gA,16933
26
26
  mcp_ticketer/core/models.py,sha256=GhuTitY6t_QlqfEUvWT6Q2zvY7qAtx_SQyCMMn8iYkk,6473
27
- mcp_ticketer/core/project_config.py,sha256=VVSeCwuESuemL-iC4fqbPrJxR4i5k5fhUpujnY7MCZA,22389
27
+ mcp_ticketer/core/project_config.py,sha256=dc_sGE6ds_WYBdwY2s6onWP07umTQ_TZBRmIL_ZrMpI,22446
28
28
  mcp_ticketer/core/registry.py,sha256=fwje0fnjp0YKPZ0SrVWk82SMNLs7CD0JlHQmx7SigNo,3537
29
29
  mcp_ticketer/mcp/__init__.py,sha256=Bvzof9vBu6VwcXcIZK8RgKv6ycRV9tDlO-9TUmd8zqQ,122
30
- mcp_ticketer/mcp/server.py,sha256=TDuU8ChZC2QYSRo0uGHkVReblTf--hriOjxo-pSAF_Y,34068
30
+ mcp_ticketer/mcp/server.py,sha256=CC1iaeugUbiVrNvNgOgm2mRb4AW-5e0X2ygLjH8I6mM,34835
31
31
  mcp_ticketer/queue/__init__.py,sha256=xHBoUwor8ZdO8bIHc7nP25EsAp5Si5Co4g_8ybb7fes,230
32
32
  mcp_ticketer/queue/__main__.py,sha256=kQd6iOCKbbFqpRdbIRavuI4_G7-oE898JE4a0yLEYPE,108
33
33
  mcp_ticketer/queue/manager.py,sha256=79AH9oUxdBXH3lmJ3kIlFf2GQkWHL6XB6u5JqVWPq60,7571
34
- mcp_ticketer/queue/queue.py,sha256=z4aivQCtsH5_OUr2OfXSfnFKzugTahNnwHw0LS3ZhZc,11549
34
+ mcp_ticketer/queue/queue.py,sha256=mXCUwlayqEHDB6IN8RvxSQpVdKcrlSww40VS_4i9lj8,12292
35
35
  mcp_ticketer/queue/run_worker.py,sha256=HFoykfDpOoz8OUxWbQ2Fka_UlGrYwjPVZ-DEimGFH9o,802
36
- mcp_ticketer/queue/worker.py,sha256=cVjHR_kfnGKAkiUg0HuXCnbKeKNBBEuj0XZHgIuIn4k,14017
37
- mcp_ticketer-0.1.16.dist-info/licenses/LICENSE,sha256=KOVrunjtILSzY-2N8Lqa3-Q8dMaZIG4LrlLTr9UqL08,1073
38
- mcp_ticketer-0.1.16.dist-info/METADATA,sha256=2Fa_rzfQvRRW-6e52xz_umAJlztAjQn1DMSQaTzcTEE,11211
39
- mcp_ticketer-0.1.16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
40
- mcp_ticketer-0.1.16.dist-info/entry_points.txt,sha256=o1IxVhnHnBNG7FZzbFq-Whcs1Djbofs0qMjiUYBLx2s,60
41
- mcp_ticketer-0.1.16.dist-info/top_level.txt,sha256=WnAG4SOT1Vm9tIwl70AbGG_nA217YyV3aWFhxLH2rxw,13
42
- mcp_ticketer-0.1.16.dist-info/RECORD,,
36
+ mcp_ticketer/queue/worker.py,sha256=h0Y3l51Ld5zpWd-HoQ3sPlgcGRJM1kiId3aKSqJSars,14588
37
+ mcp_ticketer-0.1.19.dist-info/licenses/LICENSE,sha256=KOVrunjtILSzY-2N8Lqa3-Q8dMaZIG4LrlLTr9UqL08,1073
38
+ mcp_ticketer-0.1.19.dist-info/METADATA,sha256=1ZNJdi1dFE0as6QGBewuoYdIAtkjvptEslgraOZfR_U,11211
39
+ mcp_ticketer-0.1.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
40
+ mcp_ticketer-0.1.19.dist-info/entry_points.txt,sha256=o1IxVhnHnBNG7FZzbFq-Whcs1Djbofs0qMjiUYBLx2s,60
41
+ mcp_ticketer-0.1.19.dist-info/top_level.txt,sha256=WnAG4SOT1Vm9tIwl70AbGG_nA217YyV3aWFhxLH2rxw,13
42
+ mcp_ticketer-0.1.19.dist-info/RECORD,,