quickcall-integrations 0.1.8__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_server/api_clients/github_client.py +220 -62
- mcp_server/auth/__init__.py +8 -0
- mcp_server/auth/credentials.py +350 -42
- mcp_server/tools/auth_tools.py +194 -27
- mcp_server/tools/git_tools.py +174 -0
- mcp_server/tools/github_tools.py +215 -48
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.2.0.dist-info}/METADATA +71 -61
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.2.0.dist-info}/RECORD +10 -10
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.2.0.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.2.0.dist-info}/entry_points.txt +0 -0
mcp_server/tools/auth_tools.py
CHANGED
|
@@ -3,6 +3,8 @@ Authentication tools for QuickCall MCP.
|
|
|
3
3
|
|
|
4
4
|
Provides tools for users to connect, check status, and disconnect
|
|
5
5
|
their QuickCall account from the CLI.
|
|
6
|
+
|
|
7
|
+
Also provides GitHub PAT authentication for users who can't install the GitHub App.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
import os
|
|
@@ -11,11 +13,12 @@ import webbrowser
|
|
|
11
13
|
from typing import Dict, Any
|
|
12
14
|
|
|
13
15
|
import httpx
|
|
16
|
+
from github import Github, Auth, GithubException
|
|
14
17
|
from fastmcp import FastMCP
|
|
18
|
+
from pydantic import Field
|
|
15
19
|
|
|
16
20
|
from mcp_server.auth import (
|
|
17
21
|
get_credential_store,
|
|
18
|
-
is_authenticated,
|
|
19
22
|
DeviceFlowAuth,
|
|
20
23
|
)
|
|
21
24
|
|
|
@@ -100,46 +103,75 @@ def create_auth_tools(mcp: FastMCP):
|
|
|
100
103
|
Check your QuickCall connection status.
|
|
101
104
|
|
|
102
105
|
Shows:
|
|
103
|
-
- Whether you're connected
|
|
106
|
+
- Whether you're connected to QuickCall
|
|
104
107
|
- Your account info
|
|
105
|
-
- GitHub connection status
|
|
108
|
+
- GitHub connection status (via App or PAT)
|
|
106
109
|
- Slack connection status
|
|
107
110
|
|
|
108
111
|
Returns:
|
|
109
112
|
Current authentication and integration status
|
|
110
113
|
"""
|
|
111
114
|
store = get_credential_store()
|
|
112
|
-
|
|
113
|
-
if not store.is_authenticated():
|
|
114
|
-
return {
|
|
115
|
-
"connected": False,
|
|
116
|
-
"message": "Not connected to QuickCall",
|
|
117
|
-
"hint": "Use connect_quickcall to authenticate.",
|
|
118
|
-
}
|
|
119
|
-
|
|
120
115
|
status = store.get_status()
|
|
121
116
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
"
|
|
117
|
+
# Build result with both QuickCall and PAT status
|
|
118
|
+
result = {
|
|
119
|
+
"quickcall_connected": status.get("quickcall_authenticated", False),
|
|
120
|
+
"credentials_file": status.get("credentials_file"),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# QuickCall user info
|
|
124
|
+
if status.get("quickcall_authenticated"):
|
|
125
|
+
result["user"] = {
|
|
125
126
|
"id": status.get("user_id"),
|
|
126
127
|
"email": status.get("email"),
|
|
127
128
|
"username": status.get("username"),
|
|
128
|
-
}
|
|
129
|
-
"authenticated_at"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
129
|
+
}
|
|
130
|
+
result["authenticated_at"] = status.get("authenticated_at")
|
|
131
|
+
|
|
132
|
+
# GitHub status (can be via App or PAT)
|
|
133
|
+
github_status = status.get("github", {})
|
|
134
|
+
result["github"] = {
|
|
135
|
+
"connected": github_status.get("connected", False),
|
|
136
|
+
"mode": github_status.get("mode"), # "github_app" or "pat"
|
|
137
|
+
"username": github_status.get("username"),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# PAT-specific info if configured
|
|
141
|
+
github_pat = status.get("github_pat", {})
|
|
142
|
+
if github_pat.get("configured"):
|
|
143
|
+
result["github_pat"] = {
|
|
144
|
+
"configured": True,
|
|
145
|
+
"username": github_pat.get("username"),
|
|
146
|
+
"configured_at": github_pat.get("configured_at"),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Slack status (requires QuickCall)
|
|
150
|
+
slack_status = status.get("slack", {})
|
|
151
|
+
result["slack"] = {
|
|
152
|
+
"connected": slack_status.get("connected", False),
|
|
153
|
+
"team_name": slack_status.get("team_name"),
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
# Add helpful message based on status
|
|
157
|
+
if not status.get("quickcall_authenticated") and not github_pat.get(
|
|
158
|
+
"configured"
|
|
159
|
+
):
|
|
160
|
+
result["message"] = "Not connected to QuickCall or GitHub"
|
|
161
|
+
result["hint"] = (
|
|
162
|
+
"Use connect_quickcall for full access, or connect_github_via_pat for GitHub only."
|
|
163
|
+
)
|
|
164
|
+
elif not status.get("quickcall_authenticated") and github_pat.get("configured"):
|
|
165
|
+
result["message"] = "GitHub connected via PAT (QuickCall not connected)"
|
|
166
|
+
result["hint"] = "Use connect_quickcall to also access Slack tools."
|
|
167
|
+
elif status.get("quickcall_authenticated"):
|
|
168
|
+
result["message"] = "Connected to QuickCall"
|
|
169
|
+
|
|
170
|
+
# Legacy compatibility
|
|
171
|
+
result["connected"] = status.get("quickcall_authenticated", False)
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
|
|
143
175
|
@mcp.tool(tags={"auth", "quickcall"})
|
|
144
176
|
def disconnect_quickcall() -> Dict[str, Any]:
|
|
145
177
|
"""
|
|
@@ -493,3 +525,138 @@ def create_auth_tools(mcp: FastMCP):
|
|
|
493
525
|
"status": "error",
|
|
494
526
|
"message": f"Failed to reconnect Slack: {e}",
|
|
495
527
|
}
|
|
528
|
+
|
|
529
|
+
# ========================================================================
|
|
530
|
+
# GitHub PAT Authentication (alternative to QuickCall GitHub App)
|
|
531
|
+
# ========================================================================
|
|
532
|
+
|
|
533
|
+
@mcp.tool(tags={"auth", "github"})
|
|
534
|
+
def connect_github_via_pat(
|
|
535
|
+
token: str = Field(
|
|
536
|
+
...,
|
|
537
|
+
description="GitHub Personal Access Token (ghp_xxx or github_pat_xxx)",
|
|
538
|
+
),
|
|
539
|
+
) -> Dict[str, Any]:
|
|
540
|
+
"""
|
|
541
|
+
Connect GitHub using a Personal Access Token (PAT).
|
|
542
|
+
|
|
543
|
+
Use this if your organization can't install the QuickCall GitHub App.
|
|
544
|
+
This is an alternative to the standard connect_github flow.
|
|
545
|
+
|
|
546
|
+
This command:
|
|
547
|
+
1. Validates your PAT by calling GitHub API
|
|
548
|
+
2. Auto-detects your GitHub username
|
|
549
|
+
3. Stores the PAT securely in ~/.quickcall/credentials.json
|
|
550
|
+
|
|
551
|
+
After connecting, you can use GitHub tools like list_repos, list_prs, etc.
|
|
552
|
+
|
|
553
|
+
Create a PAT at: https://github.com/settings/tokens
|
|
554
|
+
Required scopes:
|
|
555
|
+
- repo (full access to private repos)
|
|
556
|
+
- OR public_repo (public repos only)
|
|
557
|
+
|
|
558
|
+
Note: PAT mode works independently of QuickCall. You don't need
|
|
559
|
+
to run connect_quickcall first. However, Slack tools still require
|
|
560
|
+
QuickCall authentication.
|
|
561
|
+
"""
|
|
562
|
+
store = get_credential_store()
|
|
563
|
+
|
|
564
|
+
# Check if already configured via stored PAT
|
|
565
|
+
if store.has_github_pat():
|
|
566
|
+
pat_creds = store.get_github_pat_credentials()
|
|
567
|
+
return {
|
|
568
|
+
"status": "already_connected",
|
|
569
|
+
"message": f"GitHub PAT is already configured (username: {pat_creds.username})",
|
|
570
|
+
"configured_at": pat_creds.configured_at,
|
|
571
|
+
"hint": "Use disconnect_github_pat to remove it, then connect again with a new token.",
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
# Validate token format
|
|
575
|
+
if not token.startswith(("ghp_", "github_pat_")):
|
|
576
|
+
return {
|
|
577
|
+
"status": "error",
|
|
578
|
+
"message": "Invalid token format. GitHub PATs start with 'ghp_' or 'github_pat_'",
|
|
579
|
+
"hint": "Create a new token at https://github.com/settings/tokens",
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
# Validate token by calling GitHub API
|
|
583
|
+
try:
|
|
584
|
+
auth = Auth.Token(token)
|
|
585
|
+
gh = Github(auth=auth)
|
|
586
|
+
user = gh.get_user()
|
|
587
|
+
username = user.login
|
|
588
|
+
gh.close()
|
|
589
|
+
except GithubException as e:
|
|
590
|
+
if e.status == 401:
|
|
591
|
+
return {
|
|
592
|
+
"status": "error",
|
|
593
|
+
"message": "Invalid or expired token.",
|
|
594
|
+
"hint": "Check your token at https://github.com/settings/tokens",
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
"status": "error",
|
|
598
|
+
"message": f"GitHub API error: {e.data.get('message', str(e))}",
|
|
599
|
+
}
|
|
600
|
+
except Exception as e:
|
|
601
|
+
logger.error(f"Failed to validate GitHub PAT: {e}")
|
|
602
|
+
return {
|
|
603
|
+
"status": "error",
|
|
604
|
+
"message": f"Failed to validate token: {e}",
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
# Store the PAT
|
|
608
|
+
try:
|
|
609
|
+
store.save_github_pat(token=token, username=username)
|
|
610
|
+
except Exception as e:
|
|
611
|
+
logger.error(f"Failed to save GitHub PAT: {e}")
|
|
612
|
+
return {
|
|
613
|
+
"status": "error",
|
|
614
|
+
"message": f"Failed to save token: {e}",
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
"status": "success",
|
|
619
|
+
"message": f"Successfully connected GitHub as {username}!",
|
|
620
|
+
"username": username,
|
|
621
|
+
"mode": "pat",
|
|
622
|
+
"hint": "You can now use GitHub tools. Run check_github_connection to verify.",
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
@mcp.tool(tags={"auth", "github"})
|
|
626
|
+
def disconnect_github_pat() -> Dict[str, Any]:
|
|
627
|
+
"""
|
|
628
|
+
Disconnect GitHub PAT authentication.
|
|
629
|
+
|
|
630
|
+
This removes only the stored PAT. If you also have QuickCall
|
|
631
|
+
connected, that connection remains intact.
|
|
632
|
+
|
|
633
|
+
After disconnecting:
|
|
634
|
+
- If you have QuickCall GitHub App connected, it will be used instead
|
|
635
|
+
- Otherwise, GitHub tools will be unavailable until you reconnect
|
|
636
|
+
"""
|
|
637
|
+
store = get_credential_store()
|
|
638
|
+
|
|
639
|
+
if not store.has_github_pat():
|
|
640
|
+
return {
|
|
641
|
+
"status": "not_connected",
|
|
642
|
+
"message": "No GitHub PAT is configured.",
|
|
643
|
+
"hint": "Use connect_github_via_pat to set up PAT authentication.",
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
pat_creds = store.get_github_pat_credentials()
|
|
648
|
+
username = pat_creds.username if pat_creds else "unknown"
|
|
649
|
+
|
|
650
|
+
store.clear_github_pat()
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
"status": "disconnected",
|
|
654
|
+
"message": f"Disconnected GitHub PAT ({username})",
|
|
655
|
+
"hint": "Use connect_github_via_pat to reconnect, or connect_github to use the QuickCall GitHub App instead.",
|
|
656
|
+
}
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logger.error(f"Failed to disconnect GitHub PAT: {e}")
|
|
659
|
+
return {
|
|
660
|
+
"status": "error",
|
|
661
|
+
"message": f"Failed to disconnect: {e}",
|
|
662
|
+
}
|
mcp_server/tools/git_tools.py
CHANGED
|
@@ -4,6 +4,7 @@ Git Tools - Simple tools for viewing repository changes.
|
|
|
4
4
|
|
|
5
5
|
from typing import Optional, List
|
|
6
6
|
import subprocess
|
|
7
|
+
import re
|
|
7
8
|
|
|
8
9
|
from fastmcp import FastMCP
|
|
9
10
|
from fastmcp.exceptions import ToolError
|
|
@@ -230,3 +231,176 @@ def create_git_tools(mcp: FastMCP) -> None:
|
|
|
230
231
|
raise
|
|
231
232
|
except Exception as e:
|
|
232
233
|
raise ToolError(f"Failed to get updates: {str(e)}")
|
|
234
|
+
|
|
235
|
+
@mcp.tool(tags={"git", "appraisal"})
|
|
236
|
+
def get_local_contributions(
|
|
237
|
+
path: str = Field(
|
|
238
|
+
...,
|
|
239
|
+
description="Path to git repository.",
|
|
240
|
+
),
|
|
241
|
+
days: int = Field(
|
|
242
|
+
default=180,
|
|
243
|
+
description="Number of days to look back (default: 180 for 6 months)",
|
|
244
|
+
),
|
|
245
|
+
author: Optional[str] = Field(
|
|
246
|
+
default=None,
|
|
247
|
+
description="Filter by author name/email. If not specified, uses git config user.email",
|
|
248
|
+
),
|
|
249
|
+
) -> dict:
|
|
250
|
+
"""
|
|
251
|
+
Get contributions from local git history for appraisals.
|
|
252
|
+
|
|
253
|
+
Returns commits with:
|
|
254
|
+
- commit message (for Claude to categorize: feat, fix, chore, etc.)
|
|
255
|
+
- PR number (extracted from merge commits if available)
|
|
256
|
+
- stats (additions, deletions)
|
|
257
|
+
- date range
|
|
258
|
+
|
|
259
|
+
Claude should analyze commit messages to categorize contributions.
|
|
260
|
+
This tool does NOT do any categorization - it returns raw data.
|
|
261
|
+
|
|
262
|
+
Use this for appraisals when:
|
|
263
|
+
- GitHub App is not connected
|
|
264
|
+
- You want local repo contributions only
|
|
265
|
+
- Testing appraisal features on cloned repos
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
repo_info = _get_repo_info(path)
|
|
269
|
+
repo_name = (
|
|
270
|
+
f"{repo_info['owner']}/{repo_info['repo']}"
|
|
271
|
+
if repo_info["owner"]
|
|
272
|
+
else repo_info["root"]
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Get author from git config if not specified
|
|
276
|
+
if not author:
|
|
277
|
+
try:
|
|
278
|
+
author = _run_git(["config", "user.email"], path)
|
|
279
|
+
except ToolError:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
since_date = f"{days} days ago"
|
|
283
|
+
|
|
284
|
+
# Get all commits by author
|
|
285
|
+
log_format = "--pretty=format:%H|%an|%ae|%ad|%s|%b---COMMIT_END---"
|
|
286
|
+
log_args = [
|
|
287
|
+
"log",
|
|
288
|
+
log_format,
|
|
289
|
+
"--date=iso",
|
|
290
|
+
f"--since={since_date}",
|
|
291
|
+
]
|
|
292
|
+
if author:
|
|
293
|
+
log_args.extend(["--author", author])
|
|
294
|
+
|
|
295
|
+
log_output = _run_git(log_args, path)
|
|
296
|
+
|
|
297
|
+
commits = []
|
|
298
|
+
merge_commits = []
|
|
299
|
+
|
|
300
|
+
# Parse commits
|
|
301
|
+
for commit_block in log_output.split("---COMMIT_END---"):
|
|
302
|
+
if not commit_block.strip():
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
lines = commit_block.strip().split("|", 5)
|
|
306
|
+
if len(lines) < 5:
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
sha = lines[0]
|
|
310
|
+
author_name = lines[1]
|
|
311
|
+
author_email = lines[2]
|
|
312
|
+
date = lines[3]
|
|
313
|
+
subject = lines[4]
|
|
314
|
+
body = lines[5] if len(lines) > 5 else ""
|
|
315
|
+
|
|
316
|
+
# Extract PR number from merge commit message
|
|
317
|
+
pr_number = None
|
|
318
|
+
merge_match = re.search(r"Merge pull request #(\d+)", subject)
|
|
319
|
+
if merge_match:
|
|
320
|
+
pr_number = int(merge_match.group(1))
|
|
321
|
+
else:
|
|
322
|
+
# Also check for GitHub's squash merge format
|
|
323
|
+
squash_match = re.search(r"\(#(\d+)\)$", subject)
|
|
324
|
+
if squash_match:
|
|
325
|
+
pr_number = int(squash_match.group(1))
|
|
326
|
+
|
|
327
|
+
commit_data = {
|
|
328
|
+
"sha": sha[:7],
|
|
329
|
+
"full_sha": sha,
|
|
330
|
+
"author": author_name,
|
|
331
|
+
"email": author_email,
|
|
332
|
+
"date": date,
|
|
333
|
+
"message": subject,
|
|
334
|
+
"body": body.strip()[:500] if body else "",
|
|
335
|
+
"pr_number": pr_number,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
commits.append(commit_data)
|
|
339
|
+
|
|
340
|
+
if pr_number:
|
|
341
|
+
merge_commits.append(commit_data)
|
|
342
|
+
|
|
343
|
+
# Get stats for commits if we have any
|
|
344
|
+
stats = {"total_additions": 0, "total_deletions": 0, "files_changed": set()}
|
|
345
|
+
|
|
346
|
+
if commits:
|
|
347
|
+
# Get numstat for all commits by this author
|
|
348
|
+
numstat_args = [
|
|
349
|
+
"log",
|
|
350
|
+
"--numstat",
|
|
351
|
+
"--pretty=format:",
|
|
352
|
+
f"--since={since_date}",
|
|
353
|
+
]
|
|
354
|
+
if author:
|
|
355
|
+
numstat_args.extend(["--author", author])
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
numstat_output = _run_git(numstat_args, path)
|
|
359
|
+
for line in numstat_output.split("\n"):
|
|
360
|
+
if not line.strip():
|
|
361
|
+
continue
|
|
362
|
+
parts = line.split("\t")
|
|
363
|
+
if len(parts) >= 3:
|
|
364
|
+
adds = int(parts[0]) if parts[0] != "-" else 0
|
|
365
|
+
dels = int(parts[1]) if parts[1] != "-" else 0
|
|
366
|
+
stats["total_additions"] += adds
|
|
367
|
+
stats["total_deletions"] += dels
|
|
368
|
+
stats["files_changed"].add(parts[2])
|
|
369
|
+
except ToolError:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
# Calculate date range
|
|
373
|
+
if commits:
|
|
374
|
+
dates = [c["date"] for c in commits]
|
|
375
|
+
earliest = min(dates)
|
|
376
|
+
latest = max(dates)
|
|
377
|
+
else:
|
|
378
|
+
earliest = latest = None
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
"repository": repo_name,
|
|
382
|
+
"branch": repo_info["branch"],
|
|
383
|
+
"author": author,
|
|
384
|
+
"period_days": days,
|
|
385
|
+
"date_range": {
|
|
386
|
+
"earliest": earliest,
|
|
387
|
+
"latest": latest,
|
|
388
|
+
},
|
|
389
|
+
"summary": {
|
|
390
|
+
"total_commits": len(commits),
|
|
391
|
+
"merge_commits": len(merge_commits),
|
|
392
|
+
"unique_prs": len(
|
|
393
|
+
set(c["pr_number"] for c in merge_commits if c["pr_number"])
|
|
394
|
+
),
|
|
395
|
+
"total_additions": stats["total_additions"],
|
|
396
|
+
"total_deletions": stats["total_deletions"],
|
|
397
|
+
"files_touched": len(stats["files_changed"]),
|
|
398
|
+
},
|
|
399
|
+
"commits": commits,
|
|
400
|
+
"pr_commits": merge_commits,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
except ToolError:
|
|
404
|
+
raise
|
|
405
|
+
except Exception as e:
|
|
406
|
+
raise ToolError(f"Failed to get contributions: {str(e)}")
|