quickcall-integrations 0.2.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (39) hide show
  1. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/.gitignore +1 -0
  2. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/PKG-INFO +1 -1
  3. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/api_clients/github_client.py +162 -31
  4. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/tools/github_tools.py +256 -43
  5. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/.claude-plugin/plugin.json +1 -1
  6. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/pyproject.toml +3 -1
  7. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/uv.lock +131 -1
  8. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/.claude-plugin/marketplace.json +0 -0
  9. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/.github/workflows/publish-pypi.yml +0 -0
  10. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/.pre-commit-config.yaml +0 -0
  11. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/Dockerfile +0 -0
  12. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/README.md +0 -0
  13. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/assets/logo.png +0 -0
  14. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/__init__.py +0 -0
  15. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/api_clients/__init__.py +0 -0
  16. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/api_clients/slack_client.py +0 -0
  17. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/auth/__init__.py +0 -0
  18. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/auth/credentials.py +0 -0
  19. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/auth/device_flow.py +0 -0
  20. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/resources/__init__.py +0 -0
  21. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/resources/slack_resources.py +0 -0
  22. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/server.py +0 -0
  23. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/tools/__init__.py +0 -0
  24. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/tools/auth_tools.py +0 -0
  25. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/tools/git_tools.py +0 -0
  26. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/tools/slack_tools.py +0 -0
  27. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/mcp_server/tools/utility_tools.py +0 -0
  28. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/commands/appraisal.md +0 -0
  29. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/commands/connect-github-pat.md +0 -0
  30. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/commands/connect.md +0 -0
  31. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/commands/slack-summary.md +0 -0
  32. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/commands/status.md +0 -0
  33. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/plugins/quickcall/commands/updates.md +0 -0
  34. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/requirements.txt +0 -0
  35. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/tests/README.md +0 -0
  36. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/tests/appraisal/__init__.py +0 -0
  37. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/tests/appraisal/setup_test_data.py +0 -0
  38. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/tests/test_integrations.py +0 -0
  39. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.1}/tests/test_tools.py +0 -0
@@ -107,3 +107,4 @@ dmypy.json
107
107
  # OS
108
108
  .DS_Store
109
109
  Thumbs.db
110
+ .githooks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickcall-integrations
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: MCP server with developer integrations for Claude Code and Cursor
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: fastmcp>=2.13.0
@@ -6,6 +6,7 @@ Focuses on PRs and commits for minimal implementation.
6
6
  """
7
7
 
8
8
  import logging
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
10
  from typing import List, Optional, Dict, Any
10
11
  from datetime import datetime
11
12
 
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
22
23
 
23
24
 
24
25
  class Commit(BaseModel):
25
- """Represents a GitHub commit."""
26
+ """Represents a GitHub commit (full details)."""
26
27
 
27
28
  sha: str
28
29
  message: str
@@ -31,8 +32,18 @@ class Commit(BaseModel):
31
32
  html_url: str
32
33
 
33
34
 
35
+ class CommitSummary(BaseModel):
36
+ """Represents a GitHub commit (summary - minimal fields for list operations)."""
37
+
38
+ sha: str
39
+ message_title: str # First line only
40
+ author: str
41
+ date: datetime
42
+ html_url: str
43
+
44
+
34
45
  class PullRequest(BaseModel):
35
- """Represents a GitHub pull request."""
46
+ """Represents a GitHub pull request (full details)."""
36
47
 
37
48
  number: int
38
49
  title: str
@@ -55,6 +66,18 @@ class PullRequest(BaseModel):
55
66
  reviewers: List[str] = []
56
67
 
57
68
 
69
+ class PullRequestSummary(BaseModel):
70
+ """Represents a GitHub pull request (summary - minimal fields for list operations)."""
71
+
72
+ number: int
73
+ title: str
74
+ state: str
75
+ author: str
76
+ created_at: datetime
77
+ merged_at: Optional[datetime] = None
78
+ html_url: str
79
+
80
+
58
81
  class Repository(BaseModel):
59
82
  """Represents a GitHub repository."""
60
83
 
@@ -298,7 +321,8 @@ class GitHubClient:
298
321
  repo: Optional[str] = None,
299
322
  state: str = "open",
300
323
  limit: int = 20,
301
- ) -> List[PullRequest]:
324
+ detail_level: str = "summary",
325
+ ) -> List[PullRequest] | List[PullRequestSummary]:
302
326
  """
303
327
  List pull requests.
304
328
 
@@ -307,9 +331,11 @@ class GitHubClient:
307
331
  repo: Repository name
308
332
  state: PR state: 'open', 'closed', or 'all'
309
333
  limit: Maximum PRs to return
334
+ detail_level: 'summary' for minimal fields (~200 bytes/PR),
335
+ 'full' for all fields (~2KB/PR)
310
336
 
311
337
  Returns:
312
- List of pull requests
338
+ List of pull requests (summary or full based on detail_level)
313
339
  """
314
340
  gh_repo = self._get_repo(owner, repo)
315
341
  prs = []
@@ -319,7 +345,10 @@ class GitHubClient:
319
345
  for i, pr in enumerate(pulls):
320
346
  if i >= limit:
321
347
  break
322
- prs.append(self._convert_pr(pr))
348
+ if detail_level == "full":
349
+ prs.append(self._convert_pr(pr))
350
+ else:
351
+ prs.append(self._convert_pr_summary(pr))
323
352
  except IndexError:
324
353
  # Empty repo or no PRs - return empty list
325
354
  pass
@@ -353,7 +382,7 @@ class GitHubClient:
353
382
  raise
354
383
 
355
384
  def _convert_pr(self, pr) -> PullRequest:
356
- """Convert PyGithub PullRequest to Pydantic model."""
385
+ """Convert PyGithub PullRequest to Pydantic model (full details)."""
357
386
  return PullRequest(
358
387
  number=pr.number,
359
388
  title=pr.title,
@@ -376,6 +405,18 @@ class GitHubClient:
376
405
  reviewers=[r.login for r in pr.requested_reviewers],
377
406
  )
378
407
 
408
+ def _convert_pr_summary(self, pr) -> PullRequestSummary:
409
+ """Convert PyGithub PullRequest to summary model (minimal fields)."""
410
+ return PullRequestSummary(
411
+ number=pr.number,
412
+ title=pr.title,
413
+ state=pr.state,
414
+ author=pr.user.login if pr.user else "unknown",
415
+ created_at=pr.created_at,
416
+ merged_at=pr.merged_at,
417
+ html_url=pr.html_url,
418
+ )
419
+
379
420
  # ========================================================================
380
421
  # Commit Operations
381
422
  # ========================================================================
@@ -388,7 +429,8 @@ class GitHubClient:
388
429
  author: Optional[str] = None,
389
430
  since: Optional[str] = None,
390
431
  limit: int = 20,
391
- ) -> List[Commit]:
432
+ detail_level: str = "summary",
433
+ ) -> List[Commit] | List[CommitSummary]:
392
434
  """
393
435
  List commits.
394
436
 
@@ -399,9 +441,10 @@ class GitHubClient:
399
441
  author: Filter by author username
400
442
  since: ISO datetime - only commits after this date
401
443
  limit: Maximum commits to return
444
+ detail_level: 'summary' for minimal fields, 'full' for all fields
402
445
 
403
446
  Returns:
404
- List of commits
447
+ List of commits (summary or full based on detail_level)
405
448
  """
406
449
  gh_repo = self._get_repo(owner, repo)
407
450
 
@@ -427,15 +470,28 @@ class GitHubClient:
427
470
  if author and author.lower() != commit_author.lower():
428
471
  continue
429
472
 
430
- commits.append(
431
- Commit(
432
- sha=commit.sha,
433
- message=commit.commit.message,
434
- author=commit_author,
435
- date=commit.commit.author.date,
436
- html_url=commit.html_url,
473
+ if detail_level == "full":
474
+ commits.append(
475
+ Commit(
476
+ sha=commit.sha,
477
+ message=commit.commit.message,
478
+ author=commit_author,
479
+ date=commit.commit.author.date,
480
+ html_url=commit.html_url,
481
+ )
482
+ )
483
+ else:
484
+ # Summary: just first line of message
485
+ message_title = commit.commit.message.split("\n")[0][:100]
486
+ commits.append(
487
+ CommitSummary(
488
+ sha=commit.sha[:7], # Short SHA for summary
489
+ message_title=message_title,
490
+ author=commit_author,
491
+ date=commit.commit.author.date,
492
+ html_url=commit.html_url,
493
+ )
437
494
  )
438
- )
439
495
 
440
496
  return commits
441
497
 
@@ -533,6 +589,7 @@ class GitHubClient:
533
589
  org: Optional[str] = None,
534
590
  repo: Optional[str] = None,
535
591
  limit: int = 100,
592
+ detail_level: str = "summary",
536
593
  ) -> List[Dict[str, Any]]:
537
594
  """
538
595
  Search for merged pull requests using GitHub Search API.
@@ -545,10 +602,11 @@ class GitHubClient:
545
602
  org: GitHub org to search within
546
603
  repo: Specific repo in "owner/repo" format (overrides org if specified)
547
604
  limit: Maximum PRs to return (max 100 per page)
605
+ detail_level: 'summary' for minimal fields, 'full' for all fields
548
606
 
549
607
  Returns:
550
- List of merged PR dicts with: number, title, body, merged_at,
551
- labels, repo, owner, html_url
608
+ List of merged PR dicts. Summary includes: number, title, merged_at,
609
+ repo, owner, html_url, author. Full adds: body, labels.
552
610
  """
553
611
  # Build search query
554
612
  query_parts = ["is:pr", "is:merged"]
@@ -596,19 +654,35 @@ class GitHubClient:
596
654
  repo_owner = repo_url_parts[-2] if len(repo_url_parts) >= 2 else ""
597
655
  repo_name = repo_url_parts[-1] if len(repo_url_parts) >= 1 else ""
598
656
 
599
- prs.append(
600
- {
601
- "number": item["number"],
602
- "title": item["title"],
603
- "body": item.get("body") or "",
604
- "merged_at": item.get("pull_request", {}).get("merged_at"),
605
- "html_url": item["html_url"],
606
- "labels": [label["name"] for label in item.get("labels", [])],
607
- "repo": repo_name,
608
- "owner": repo_owner,
609
- "author": item.get("user", {}).get("login", "unknown"),
610
- }
611
- )
657
+ if detail_level == "full":
658
+ prs.append(
659
+ {
660
+ "number": item["number"],
661
+ "title": item["title"],
662
+ "body": item.get("body") or "",
663
+ "merged_at": item.get("pull_request", {}).get("merged_at"),
664
+ "html_url": item["html_url"],
665
+ "labels": [
666
+ label["name"] for label in item.get("labels", [])
667
+ ],
668
+ "repo": repo_name,
669
+ "owner": repo_owner,
670
+ "author": item.get("user", {}).get("login", "unknown"),
671
+ }
672
+ )
673
+ else:
674
+ # Summary: skip body and labels
675
+ prs.append(
676
+ {
677
+ "number": item["number"],
678
+ "title": item["title"],
679
+ "merged_at": item.get("pull_request", {}).get("merged_at"),
680
+ "html_url": item["html_url"],
681
+ "repo": repo_name,
682
+ "owner": repo_owner,
683
+ "author": item.get("user", {}).get("login", "unknown"),
684
+ }
685
+ )
612
686
 
613
687
  return prs
614
688
 
@@ -618,3 +692,60 @@ class GitHubClient:
618
692
  except Exception as e:
619
693
  logger.error(f"Failed to search PRs: {e}")
620
694
  raise
695
+
696
+ def fetch_prs_parallel(
697
+ self,
698
+ pr_refs: List[Dict[str, Any]],
699
+ max_workers: int = 10,
700
+ ) -> List[Dict[str, Any]]:
701
+ """
702
+ Fetch full PR details for multiple PRs in parallel.
703
+
704
+ Args:
705
+ pr_refs: List of dicts with 'owner', 'repo', 'number' keys
706
+ max_workers: Max concurrent requests (default: 10)
707
+
708
+ Returns:
709
+ List of full PR details with stats (additions, deletions, files)
710
+ """
711
+ results = []
712
+ errors = []
713
+
714
+ def fetch_single_pr(pr_ref: Dict[str, Any]) -> Dict[str, Any] | None:
715
+ try:
716
+ owner = pr_ref["owner"]
717
+ repo = pr_ref["repo"]
718
+ number = pr_ref["number"]
719
+ pr = self.get_pr(number, owner=owner, repo=repo)
720
+ if pr:
721
+ pr_dict = pr.model_dump()
722
+ # Add owner/repo for context
723
+ pr_dict["owner"] = owner
724
+ pr_dict["repo"] = repo
725
+ return pr_dict
726
+ return None
727
+ except Exception as e:
728
+ logger.warning(f"Failed to fetch PR {pr_ref}: {e}")
729
+ return None
730
+
731
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
732
+ futures = {
733
+ executor.submit(fetch_single_pr, pr_ref): pr_ref for pr_ref in pr_refs
734
+ }
735
+
736
+ for future in as_completed(futures):
737
+ pr_ref = futures[future]
738
+ try:
739
+ result = future.result()
740
+ if result:
741
+ results.append(result)
742
+ else:
743
+ errors.append(pr_ref)
744
+ except Exception as e:
745
+ logger.error(f"Error fetching {pr_ref}: {e}")
746
+ errors.append(pr_ref)
747
+
748
+ if errors:
749
+ logger.warning(f"Failed to fetch {len(errors)} PRs: {errors[:5]}...")
750
+
751
+ return results
@@ -11,7 +11,7 @@ PAT fallback is useful for:
11
11
  - Testing and development
12
12
  """
13
13
 
14
- from typing import Optional, Tuple
14
+ from typing import List, Optional, Tuple
15
15
  import logging
16
16
 
17
17
  from fastmcp import FastMCP
@@ -154,19 +154,34 @@ def create_github_tools(mcp: FastMCP) -> None:
154
154
  default=20,
155
155
  description="Maximum number of PRs to return (default: 20)",
156
156
  ),
157
+ detail_level: str = Field(
158
+ default="summary",
159
+ description="'summary' for minimal fields (~200 bytes/PR: number, title, state, author, merged_at, html_url), "
160
+ "'full' for all fields (~2KB/PR). Use 'summary' for large result sets, 'full' for detailed analysis.",
161
+ ),
157
162
  ) -> dict:
158
163
  """
159
164
  List pull requests for a GitHub repository.
160
165
 
161
166
  Returns PRs sorted by last updated.
162
167
  Requires QuickCall authentication with GitHub connected.
168
+
169
+ Use detail_level='summary' (default) to avoid context overflow with large result sets.
170
+ Use get_pr(number) to get full details for specific PRs when needed.
163
171
  """
164
172
  try:
165
173
  client = _get_client()
166
- prs = client.list_prs(owner=owner, repo=repo, state=state, limit=limit)
174
+ prs = client.list_prs(
175
+ owner=owner,
176
+ repo=repo,
177
+ state=state,
178
+ limit=limit,
179
+ detail_level=detail_level,
180
+ )
167
181
 
168
182
  return {
169
183
  "count": len(prs),
184
+ "detail_level": detail_level,
170
185
  "prs": [pr.model_dump() for pr in prs],
171
186
  }
172
187
  except ToolError:
@@ -180,40 +195,60 @@ def create_github_tools(mcp: FastMCP) -> None:
180
195
  raise ToolError(f"Failed to list pull requests: {str(e)}")
181
196
 
182
197
  @mcp.tool(tags={"github", "prs"})
183
- def get_pr(
184
- pr_number: int = Field(..., description="Pull request number", gt=0),
185
- owner: Optional[str] = Field(
186
- default=None,
187
- description="Repository owner. Uses your GitHub username if not specified.",
188
- ),
189
- repo: Optional[str] = Field(
190
- default=None,
191
- description="Repository name. Required.",
198
+ def get_prs(
199
+ pr_refs: List[dict] = Field(
200
+ ...,
201
+ description="List of PR references. Each item should have 'owner', 'repo', and 'number' keys. "
202
+ "Example: [{'owner': 'org', 'repo': 'myrepo', 'number': 123}, ...]",
192
203
  ),
193
204
  ) -> dict:
194
205
  """
195
- Get detailed information about a specific pull request.
206
+ Get detailed information about one or more pull requests.
207
+
208
+ Works for single or multiple PRs - fetches in parallel when multiple.
209
+ Each PR ref needs owner, repo, and number.
196
210
 
197
- Includes title, description, status, files changed, and review status.
211
+ Returns full PR details including additions, deletions, and files changed.
198
212
  Requires QuickCall authentication with GitHub connected.
199
213
  """
200
214
  try:
201
215
  client = _get_client()
202
- pr = client.get_pr(pr_number, owner=owner, repo=repo)
203
216
 
204
- if not pr:
205
- raise ToolError(f"Pull request #{pr_number} not found")
217
+ # Validate input
218
+ validated_refs = []
219
+ for ref in pr_refs:
220
+ if not isinstance(ref, dict):
221
+ raise ToolError(f"Invalid PR ref (must be dict): {ref}")
222
+ if "number" not in ref:
223
+ raise ToolError(f"Missing 'number' in PR ref: {ref}")
224
+ if "owner" not in ref or "repo" not in ref:
225
+ raise ToolError(
226
+ f"Missing 'owner' or 'repo' in PR ref: {ref}. "
227
+ "Each ref must have owner, repo, and number."
228
+ )
229
+ validated_refs.append(
230
+ {
231
+ "owner": ref["owner"],
232
+ "repo": ref["repo"],
233
+ "number": int(ref["number"]),
234
+ }
235
+ )
236
+
237
+ if not validated_refs:
238
+ return {"count": 0, "prs": []}
206
239
 
207
- return {"pr": pr.model_dump()}
240
+ # Fetch all PRs in parallel
241
+ prs = client.fetch_prs_parallel(validated_refs, max_workers=10)
242
+
243
+ return {
244
+ "count": len(prs),
245
+ "requested": len(validated_refs),
246
+ "prs": prs,
247
+ }
208
248
  except ToolError:
209
249
  raise
210
- except ValueError as e:
211
- raise ToolError(
212
- f"Repository not specified: {str(e)}. "
213
- f"Please provide both owner and repo parameters."
214
- )
215
250
  except Exception as e:
216
- raise ToolError(f"Failed to get pull request #{pr_number}: {str(e)}")
251
+ raise ToolError(f"Failed to fetch PRs: {str(e)}")
217
252
 
218
253
  @mcp.tool(tags={"github", "commits"})
219
254
  def list_commits(
@@ -241,12 +276,20 @@ def create_github_tools(mcp: FastMCP) -> None:
241
276
  default=20,
242
277
  description="Maximum number of commits to return (default: 20)",
243
278
  ),
279
+ detail_level: str = Field(
280
+ default="summary",
281
+ description="'summary' for minimal fields (short sha, message title, author, date, url), "
282
+ "'full' for all fields including full commit message. Use 'summary' for large result sets.",
283
+ ),
244
284
  ) -> dict:
245
285
  """
246
286
  List commits for a GitHub repository.
247
287
 
248
288
  Returns commits sorted by date (newest first).
249
289
  Requires QuickCall authentication with GitHub connected.
290
+
291
+ Use detail_level='summary' (default) to avoid context overflow with large result sets.
292
+ Use get_commit(sha) to get full details for specific commits when needed.
250
293
  """
251
294
  try:
252
295
  client = _get_client()
@@ -257,10 +300,12 @@ def create_github_tools(mcp: FastMCP) -> None:
257
300
  author=author,
258
301
  since=since,
259
302
  limit=limit,
303
+ detail_level=detail_level,
260
304
  )
261
305
 
262
306
  return {
263
307
  "count": len(commits),
308
+ "detail_level": detail_level,
264
309
  "commits": [commit.model_dump() for commit in commits],
265
310
  }
266
311
  except ToolError:
@@ -348,7 +393,7 @@ def create_github_tools(mcp: FastMCP) -> None:
348
393
  except Exception as e:
349
394
  raise ToolError(f"Failed to list branches: {str(e)}")
350
395
 
351
- @mcp.tool(tags={"github", "prs", "appraisal"})
396
+ @mcp.tool(tags={"github", "prs"})
352
397
  def search_merged_prs(
353
398
  author: Optional[str] = Field(
354
399
  default=None,
@@ -370,28 +415,20 @@ def create_github_tools(mcp: FastMCP) -> None:
370
415
  default=100,
371
416
  description="Maximum PRs to return (default: 100)",
372
417
  ),
418
+ detail_level: str = Field(
419
+ default="summary",
420
+ description="'summary' for minimal fields (number, title, merged_at, repo, owner, html_url, author), "
421
+ "'full' adds body and labels. Use 'summary' for large result sets.",
422
+ ),
373
423
  ) -> dict:
374
424
  """
375
425
  Search for merged pull requests by author within a time period.
376
426
 
377
- USE FOR APPRAISALS: This tool is ideal for gathering contribution data
378
- for performance reviews. Returns basic PR info - use get_pr for full
379
- details (additions, deletions, files) on specific PRs.
380
-
381
- Claude should analyze the returned PRs to:
382
-
383
- 1. CATEGORIZE by type (look at PR title/labels):
384
- - Features: "feat:", "add:", "implement", "new", "create"
385
- - Enhancements: "improve:", "update:", "perf:", "optimize", "enhance"
386
- - Bug fixes: "fix:", "bugfix:", "hotfix:", "resolve", "patch"
387
- - Chores: "chore:", "docs:", "test:", "ci:", "refactor:", "bump"
388
-
389
- 2. IDENTIFY top PRs worth highlighting (call get_pr for detailed metrics)
390
-
391
- 3. SUMMARIZE for appraisal with accomplishments grouped by category
427
+ NOTE: For appraisals/performance reviews, use prepare_appraisal_data instead!
428
+ It fetches all PRs with full stats in parallel and avoids context overflow.
392
429
 
393
- Returns: number, title, body, merged_at, labels, repo, owner, html_url, author.
394
- For full stats (additions, deletions, files), call get_pr on specific PRs.
430
+ This tool returns basic PR info without stats (additions, deletions).
431
+ Use detail_level='summary' (default) for large result sets.
395
432
 
396
433
  Requires QuickCall authentication with GitHub connected.
397
434
  """
@@ -399,9 +436,11 @@ def create_github_tools(mcp: FastMCP) -> None:
399
436
  client = _get_client()
400
437
 
401
438
  # Calculate since_date from days
402
- from datetime import datetime, timedelta
439
+ from datetime import datetime, timedelta, timezone
403
440
 
404
- since_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
441
+ since_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
442
+ "%Y-%m-%d"
443
+ )
405
444
 
406
445
  # Use authenticated user if author not specified
407
446
  if not author:
@@ -415,10 +454,12 @@ def create_github_tools(mcp: FastMCP) -> None:
415
454
  org=org,
416
455
  repo=repo,
417
456
  limit=limit,
457
+ detail_level=detail_level,
418
458
  )
419
459
 
420
460
  return {
421
461
  "count": len(prs),
462
+ "detail_level": detail_level,
422
463
  "period": f"Last {days} days",
423
464
  "author": author,
424
465
  "org": org,
@@ -430,6 +471,178 @@ def create_github_tools(mcp: FastMCP) -> None:
430
471
  except Exception as e:
431
472
  raise ToolError(f"Failed to search merged PRs: {str(e)}")
432
473
 
474
+ @mcp.tool(tags={"github", "prs", "appraisal"})
475
+ def prepare_appraisal_data(
476
+ author: Optional[str] = Field(
477
+ default=None,
478
+ description="GitHub username. Defaults to authenticated user.",
479
+ ),
480
+ days: int = Field(
481
+ default=180,
482
+ description="Number of days to look back (default: 180 for ~6 months)",
483
+ ),
484
+ org: Optional[str] = Field(
485
+ default=None,
486
+ description="GitHub org to search within.",
487
+ ),
488
+ repo: Optional[str] = Field(
489
+ default=None,
490
+ description="Specific repo in 'owner/repo' format.",
491
+ ),
492
+ ) -> dict:
493
+ """
494
+ Prepare appraisal data by fetching ALL merged PRs with full details.
495
+
496
+ USE THIS TOOL FOR APPRAISALS AND PERFORMANCE REVIEWS!
497
+
498
+ This is the recommended tool for gathering contribution data because it:
499
+ 1. Fetches ALL merged PRs with full stats (additions, deletions) in PARALLEL
500
+ 2. Dumps everything to a local file (avoids context overflow)
501
+ 3. Returns just PR titles for you to review
502
+ 4. Then use get_appraisal_pr_details(file_path, pr_numbers) for selected PRs
503
+
504
+ DO NOT use search_merged_prs for appraisals - it doesn't include stats
505
+ and causes context overflow with large result sets.
506
+ """
507
+ import json
508
+ import tempfile
509
+ from datetime import datetime, timedelta, timezone
510
+
511
+ try:
512
+ client = _get_client()
513
+
514
+ since_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
515
+ "%Y-%m-%d"
516
+ )
517
+
518
+ # Use authenticated user if author not specified
519
+ if not author:
520
+ creds = get_credential_store().get_api_credentials()
521
+ if creds and creds.github_username:
522
+ author = creds.github_username
523
+
524
+ # Step 1: Get list of merged PRs
525
+ pr_list = client.search_merged_prs(
526
+ author=author,
527
+ since_date=since_date,
528
+ org=org,
529
+ repo=repo,
530
+ limit=100,
531
+ detail_level="full", # Get body/labels from search
532
+ )
533
+
534
+ if not pr_list:
535
+ return {
536
+ "count": 0,
537
+ "message": "No merged PRs found for the specified criteria",
538
+ "author": author,
539
+ "period": f"Last {days} days",
540
+ }
541
+
542
+ # Step 2: Prepare refs for parallel fetch
543
+ pr_refs = [
544
+ {"owner": pr["owner"], "repo": pr["repo"], "number": pr["number"]}
545
+ for pr in pr_list
546
+ ]
547
+
548
+ # Step 3: Fetch full details in parallel
549
+ full_prs = client.fetch_prs_parallel(pr_refs, max_workers=10)
550
+
551
+ # Step 4: Merge search data with full PR data
552
+ # (search has body/labels, full PR has additions/deletions/files)
553
+ pr_lookup = {(pr["owner"], pr["repo"], pr["number"]): pr for pr in pr_list}
554
+ for pr in full_prs:
555
+ key = (pr["owner"], pr["repo"], pr["number"])
556
+ if key in pr_lookup:
557
+ # Add labels from search (not in PyGithub response for some reason)
558
+ search_pr = pr_lookup[key]
559
+ if "labels" in search_pr:
560
+ pr["labels"] = search_pr["labels"]
561
+
562
+ # Step 5: Dump to file
563
+ dump_data = {
564
+ "author": author,
565
+ "period": f"Last {days} days",
566
+ "org": org,
567
+ "repo": repo,
568
+ "fetched_at": datetime.now(timezone.utc).isoformat(),
569
+ "count": len(full_prs),
570
+ "prs": full_prs,
571
+ }
572
+
573
+ # Create temp file that persists
574
+ fd, file_path = tempfile.mkstemp(suffix=".json", prefix="appraisal_")
575
+ with open(file_path, "w") as f:
576
+ json.dump(dump_data, f, indent=2, default=str)
577
+
578
+ # Step 6: Return file path + just titles for Claude to scan
579
+ pr_titles = [
580
+ {
581
+ "number": pr["number"],
582
+ "title": pr["title"],
583
+ "repo": f"{pr['owner']}/{pr['repo']}",
584
+ }
585
+ for pr in full_prs
586
+ ]
587
+
588
+ return {
589
+ "file_path": file_path,
590
+ "count": len(full_prs),
591
+ "author": author,
592
+ "period": f"Last {days} days",
593
+ "pr_titles": pr_titles,
594
+ "next_step": "Review titles above, then call "
595
+ "get_appraisal_pr_details(file_path, pr_numbers) for full details on selected PRs.",
596
+ }
597
+
598
+ except ToolError:
599
+ raise
600
+ except Exception as e:
601
+ raise ToolError(f"Failed to prepare appraisal data: {str(e)}")
602
+
603
+ @mcp.tool(tags={"github", "prs", "appraisal"})
604
+ def get_appraisal_pr_details(
605
+ file_path: str = Field(
606
+ ...,
607
+ description="Path to the appraisal data file from prepare_appraisal_data",
608
+ ),
609
+ pr_numbers: List[int] = Field(
610
+ ..., description="List of PR numbers to get full details for"
611
+ ),
612
+ ) -> dict:
613
+ """
614
+ Get full PR details from the appraisal data dump.
615
+
616
+ This reads from the local file created by prepare_appraisal_data.
617
+ NO API CALLS are made - all data comes from the cached dump.
618
+
619
+ Use this after prepare_appraisal_data to get full details for specific PRs
620
+ that Claude has identified as important for the appraisal.
621
+ """
622
+ import json
623
+
624
+ try:
625
+ with open(file_path) as f:
626
+ data = json.load(f)
627
+
628
+ pr_numbers_set = set(pr_numbers)
629
+ selected_prs = [
630
+ pr for pr in data.get("prs", []) if pr["number"] in pr_numbers_set
631
+ ]
632
+
633
+ return {
634
+ "count": len(selected_prs),
635
+ "requested": len(pr_numbers),
636
+ "prs": selected_prs,
637
+ }
638
+
639
+ except FileNotFoundError:
640
+ raise ToolError(f"Appraisal data file not found: {file_path}")
641
+ except json.JSONDecodeError:
642
+ raise ToolError(f"Invalid JSON in appraisal data file: {file_path}")
643
+ except Exception as e:
644
+ raise ToolError(f"Failed to read appraisal data: {str(e)}")
645
+
433
646
  @mcp.tool(tags={"github", "status"})
434
647
  def check_github_connection() -> dict:
435
648
  """
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "quickcall",
3
3
  "description": "Integrate quickcall into dev workflows - eliminate interruptions for developers. Ask about your work, get instant answers. No more context switching.",
4
- "version": "0.5.2",
4
+ "version": "0.6.1",
5
5
  "author": {
6
6
  "name": "Sagar Sarkale"
7
7
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quickcall-integrations"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "MCP server with developer integrations for Claude Code and Cursor"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -27,4 +27,6 @@ dev = [
27
27
  "black>=25.12.0",
28
28
  "mcp>=1.9.0",
29
29
  "rich>=13.0.0",
30
+ "ruff>=0.14.11",
31
+ "twine>=6.2.0",
30
32
  ]
@@ -571,6 +571,18 @@ wheels = [
571
571
  { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
572
572
  ]
573
573
 
574
+ [[package]]
575
+ name = "id"
576
+ version = "1.5.0"
577
+ source = { registry = "https://pypi.org/simple" }
578
+ dependencies = [
579
+ { name = "requests" },
580
+ ]
581
+ sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" }
582
+ wheels = [
583
+ { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" },
584
+ ]
585
+
574
586
  [[package]]
575
587
  name = "idna"
576
588
  version = "3.11"
@@ -835,6 +847,39 @@ wheels = [
835
847
  { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
836
848
  ]
837
849
 
850
+ [[package]]
851
+ name = "nh3"
852
+ version = "0.3.2"
853
+ source = { registry = "https://pypi.org/simple" }
854
+ sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" }
855
+ wheels = [
856
+ { url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839, upload-time = "2025-10-30T11:17:09.956Z" },
857
+ { url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183, upload-time = "2025-10-30T11:17:11.99Z" },
858
+ { url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127, upload-time = "2025-10-30T11:17:13.192Z" },
859
+ { url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131, upload-time = "2025-10-30T11:17:14.677Z" },
860
+ { url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783, upload-time = "2025-10-30T11:17:15.861Z" },
861
+ { url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732, upload-time = "2025-10-30T11:17:17.155Z" },
862
+ { url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997, upload-time = "2025-10-30T11:17:18.77Z" },
863
+ { url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364, upload-time = "2025-10-30T11:17:20.286Z" },
864
+ { url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982, upload-time = "2025-10-30T11:17:21.384Z" },
865
+ { url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126, upload-time = "2025-10-30T11:17:22.755Z" },
866
+ { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" },
867
+ { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" },
868
+ { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" },
869
+ { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" },
870
+ { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" },
871
+ { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" },
872
+ { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" },
873
+ { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" },
874
+ { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" },
875
+ { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
876
+ { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
877
+ { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
878
+ { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
879
+ { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
880
+ { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
881
+ ]
882
+
838
883
  [[package]]
839
884
  name = "openapi-pydantic"
840
885
  version = "0.5.1"
@@ -1414,7 +1459,7 @@ wheels = [
1414
1459
 
1415
1460
  [[package]]
1416
1461
  name = "quickcall-integrations"
1417
- version = "0.1.9"
1462
+ version = "0.3.0"
1418
1463
  source = { editable = "." }
1419
1464
  dependencies = [
1420
1465
  { name = "fastmcp" },
@@ -1429,6 +1474,8 @@ dev = [
1429
1474
  { name = "black" },
1430
1475
  { name = "mcp" },
1431
1476
  { name = "rich" },
1477
+ { name = "ruff" },
1478
+ { name = "twine" },
1432
1479
  ]
1433
1480
 
1434
1481
  [package.metadata]
@@ -1445,6 +1492,8 @@ dev = [
1445
1492
  { name = "black", specifier = ">=25.12.0" },
1446
1493
  { name = "mcp", specifier = ">=1.9.0" },
1447
1494
  { name = "rich", specifier = ">=13.0.0" },
1495
+ { name = "ruff", specifier = ">=0.14.11" },
1496
+ { name = "twine", specifier = ">=6.2.0" },
1448
1497
  ]
1449
1498
 
1450
1499
  [[package]]
@@ -1537,6 +1586,20 @@ wheels = [
1537
1586
  { url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" },
1538
1587
  ]
1539
1588
 
1589
+ [[package]]
1590
+ name = "readme-renderer"
1591
+ version = "44.0"
1592
+ source = { registry = "https://pypi.org/simple" }
1593
+ dependencies = [
1594
+ { name = "docutils" },
1595
+ { name = "nh3" },
1596
+ { name = "pygments" },
1597
+ ]
1598
+ sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" }
1599
+ wheels = [
1600
+ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" },
1601
+ ]
1602
+
1540
1603
  [[package]]
1541
1604
  name = "redis"
1542
1605
  version = "7.1.0"
@@ -1578,6 +1641,27 @@ wheels = [
1578
1641
  { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
1579
1642
  ]
1580
1643
 
1644
+ [[package]]
1645
+ name = "requests-toolbelt"
1646
+ version = "1.0.0"
1647
+ source = { registry = "https://pypi.org/simple" }
1648
+ dependencies = [
1649
+ { name = "requests" },
1650
+ ]
1651
+ sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
1652
+ wheels = [
1653
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
1654
+ ]
1655
+
1656
+ [[package]]
1657
+ name = "rfc3986"
1658
+ version = "2.0.0"
1659
+ source = { registry = "https://pypi.org/simple" }
1660
+ sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" }
1661
+ wheels = [
1662
+ { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" },
1663
+ ]
1664
+
1581
1665
  [[package]]
1582
1666
  name = "rich"
1583
1667
  version = "14.2.0"
@@ -1726,6 +1810,32 @@ wheels = [
1726
1810
  { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
1727
1811
  ]
1728
1812
 
1813
+ [[package]]
1814
+ name = "ruff"
1815
+ version = "0.14.11"
1816
+ source = { registry = "https://pypi.org/simple" }
1817
+ sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" }
1818
+ wheels = [
1819
+ { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" },
1820
+ { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" },
1821
+ { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" },
1822
+ { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" },
1823
+ { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" },
1824
+ { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" },
1825
+ { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" },
1826
+ { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" },
1827
+ { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" },
1828
+ { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" },
1829
+ { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" },
1830
+ { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" },
1831
+ { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
1832
+ { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
1833
+ { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
1834
+ { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
1835
+ { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
1836
+ { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
1837
+ ]
1838
+
1729
1839
  [[package]]
1730
1840
  name = "secretstorage"
1731
1841
  version = "3.5.0"
@@ -1832,6 +1942,26 @@ wheels = [
1832
1942
  { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
1833
1943
  ]
1834
1944
 
1945
+ [[package]]
1946
+ name = "twine"
1947
+ version = "6.2.0"
1948
+ source = { registry = "https://pypi.org/simple" }
1949
+ dependencies = [
1950
+ { name = "id" },
1951
+ { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
1952
+ { name = "packaging" },
1953
+ { name = "readme-renderer" },
1954
+ { name = "requests" },
1955
+ { name = "requests-toolbelt" },
1956
+ { name = "rfc3986" },
1957
+ { name = "rich" },
1958
+ { name = "urllib3" },
1959
+ ]
1960
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" }
1961
+ wheels = [
1962
+ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" },
1963
+ ]
1964
+
1835
1965
  [[package]]
1836
1966
  name = "typer"
1837
1967
  version = "0.21.0"