quickcall-integrations 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -0,0 +1,411 @@
1
+ """
2
+ Authentication tools for QuickCall MCP.
3
+
4
+ Provides tools for users to connect, check status, and disconnect
5
+ their QuickCall account from the CLI.
6
+ """
7
+
8
+ import os
9
+ import logging
10
+ import webbrowser
11
+ from typing import Dict, Any
12
+
13
+ import httpx
14
+ from fastmcp import FastMCP
15
+
16
+ from mcp_server.auth import (
17
+ get_credential_store,
18
+ is_authenticated,
19
+ DeviceFlowAuth,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # QuickCall API URL - configurable for local dev
25
+ QUICKCALL_API_URL = os.getenv("QUICKCALL_API_URL", "https://api.quickcall.dev")
26
+
27
+
28
+ def create_auth_tools(mcp: FastMCP):
29
+ """Register authentication tools with the MCP server."""
30
+
31
+ @mcp.tool(tags={"auth", "quickcall"})
32
+ def connect_quickcall() -> Dict[str, Any]:
33
+ """
34
+ Connect your CLI to QuickCall.
35
+
36
+ This starts the OAuth device flow authentication:
37
+ 1. Opens your browser to quickcall.dev
38
+ 2. You sign in with Google
39
+ 3. Your CLI is linked to your account
40
+
41
+ After connecting, you can use GitHub and Slack tools
42
+ with your configured integrations from quickcall.dev.
43
+
44
+ Returns:
45
+ Authentication result with status and instructions
46
+ """
47
+ store = get_credential_store()
48
+
49
+ # Check if already authenticated
50
+ if store.is_authenticated():
51
+ status = store.get_status()
52
+ return {
53
+ "status": "already_connected",
54
+ "message": "You're already connected to QuickCall!",
55
+ "user": status.get("username") or status.get("email"),
56
+ "github_connected": status.get("github", {}).get("connected", False),
57
+ "slack_connected": status.get("slack", {}).get("connected", False),
58
+ "hint": "Use check_quickcall_status for details or disconnect_quickcall to logout.",
59
+ }
60
+
61
+ # Start device flow
62
+ auth = DeviceFlowAuth()
63
+
64
+ try:
65
+ # Initialize flow
66
+ device_code, user_code, verification_url, expires_in, interval = (
67
+ auth.init_flow()
68
+ )
69
+
70
+ # Build URL with code
71
+ auth_url = f"{verification_url}?code={user_code}"
72
+
73
+ return {
74
+ "status": "pending",
75
+ "message": "Authentication started! Complete the following steps:",
76
+ "code": user_code,
77
+ "url": auth_url,
78
+ "instructions": [
79
+ f"1. Open this URL in your browser: {auth_url}",
80
+ "2. Sign in with Google",
81
+ "3. Your CLI will be connected automatically",
82
+ ],
83
+ "expires_in_minutes": expires_in // 60,
84
+ "hint": "The browser should open automatically. If not, copy the URL above.",
85
+ "_device_code": device_code,
86
+ "_interval": interval,
87
+ }
88
+
89
+ except Exception as e:
90
+ logger.error(f"Failed to start authentication: {e}")
91
+ return {
92
+ "status": "error",
93
+ "message": f"Failed to start authentication: {e}",
94
+ "hint": "Check your internet connection and try again.",
95
+ }
96
+
97
+ @mcp.tool(tags={"auth", "quickcall"})
98
+ def check_quickcall_status() -> Dict[str, Any]:
99
+ """
100
+ Check your QuickCall connection status.
101
+
102
+ Shows:
103
+ - Whether you're connected
104
+ - Your account info
105
+ - GitHub connection status
106
+ - Slack connection status
107
+
108
+ Returns:
109
+ Current authentication and integration status
110
+ """
111
+ 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
+ status = store.get_status()
121
+
122
+ return {
123
+ "connected": True,
124
+ "user": {
125
+ "id": status.get("user_id"),
126
+ "email": status.get("email"),
127
+ "username": status.get("username"),
128
+ },
129
+ "authenticated_at": status.get("authenticated_at"),
130
+ "integrations": {
131
+ "github": {
132
+ "connected": status.get("github", {}).get("connected", False),
133
+ "username": status.get("github", {}).get("username"),
134
+ },
135
+ "slack": {
136
+ "connected": status.get("slack", {}).get("connected", False),
137
+ "team_name": status.get("slack", {}).get("team_name"),
138
+ },
139
+ },
140
+ "credentials_file": status.get("credentials_file"),
141
+ }
142
+
143
+ @mcp.tool(tags={"auth", "quickcall"})
144
+ def disconnect_quickcall() -> Dict[str, Any]:
145
+ """
146
+ Disconnect your CLI from QuickCall.
147
+
148
+ This removes your local credentials. You'll need to
149
+ run connect_quickcall again to use GitHub and Slack tools.
150
+
151
+ Note: This doesn't revoke the device from your QuickCall
152
+ account. To fully revoke access, visit quickcall.dev/settings.
153
+
154
+ Returns:
155
+ Disconnection result
156
+ """
157
+ store = get_credential_store()
158
+
159
+ if not store.is_authenticated():
160
+ return {
161
+ "status": "not_connected",
162
+ "message": "You're not connected to QuickCall.",
163
+ }
164
+
165
+ try:
166
+ # Get user info before clearing
167
+ status = store.get_status()
168
+ user = (
169
+ status.get("username") or status.get("email") or status.get("user_id")
170
+ )
171
+
172
+ # Clear credentials
173
+ store.clear()
174
+
175
+ return {
176
+ "status": "disconnected",
177
+ "message": f"Disconnected from QuickCall ({user})",
178
+ "hint": "To fully revoke access, visit quickcall.dev/settings. Use connect_quickcall to reconnect.",
179
+ }
180
+
181
+ except Exception as e:
182
+ logger.error(f"Failed to disconnect: {e}")
183
+ return {
184
+ "status": "error",
185
+ "message": f"Failed to disconnect: {e}",
186
+ }
187
+
188
+ @mcp.tool(tags={"auth", "quickcall"})
189
+ def complete_quickcall_auth(
190
+ device_code: str, timeout_seconds: int = 300
191
+ ) -> Dict[str, Any]:
192
+ """
193
+ Complete QuickCall authentication after browser sign-in.
194
+
195
+ This polls for the authentication result after you've signed
196
+ in via the browser. Usually called automatically after connect_quickcall.
197
+
198
+ Args:
199
+ device_code: The device code from connect_quickcall
200
+ timeout_seconds: How long to wait for authentication (default: 5 minutes)
201
+
202
+ Returns:
203
+ Authentication result
204
+ """
205
+ auth = DeviceFlowAuth()
206
+
207
+ try:
208
+ credentials = auth.poll_for_completion(
209
+ device_code=device_code,
210
+ timeout=timeout_seconds,
211
+ )
212
+
213
+ if credentials:
214
+ return {
215
+ "status": "success",
216
+ "message": "Successfully connected to QuickCall!",
217
+ "user_id": credentials.user_id,
218
+ "email": credentials.email,
219
+ "hint": "You can now use GitHub and Slack tools. Run check_quickcall_status to see your integrations.",
220
+ }
221
+ else:
222
+ return {
223
+ "status": "failed",
224
+ "message": "Authentication failed or timed out.",
225
+ "hint": "Try running connect_quickcall again.",
226
+ }
227
+
228
+ except Exception as e:
229
+ logger.error(f"Authentication error: {e}")
230
+ return {
231
+ "status": "error",
232
+ "message": f"Authentication error: {e}",
233
+ }
234
+
235
+ @mcp.tool(tags={"auth", "github", "quickcall"})
236
+ def connect_github(open_browser: bool = True) -> Dict[str, Any]:
237
+ """
238
+ Connect GitHub to your QuickCall account.
239
+
240
+ This opens your browser to install the QuickCall GitHub App.
241
+ After installation, you'll be able to use GitHub tools like
242
+ list_repos, create_issue, etc.
243
+
244
+ Args:
245
+ open_browser: Automatically open the install URL in browser (default: True)
246
+
247
+ Returns:
248
+ Install URL and instructions
249
+ """
250
+ store = get_credential_store()
251
+
252
+ if not store.is_authenticated():
253
+ return {
254
+ "status": "error",
255
+ "message": "Not connected to QuickCall",
256
+ "hint": "Run connect_quickcall first to authenticate.",
257
+ }
258
+
259
+ stored = store.get_stored_credentials()
260
+ if not stored:
261
+ return {
262
+ "status": "error",
263
+ "message": "No stored credentials found",
264
+ "hint": "Run connect_quickcall to authenticate.",
265
+ }
266
+
267
+ try:
268
+ with httpx.Client(timeout=30.0) as client:
269
+ response = client.get(
270
+ f"{QUICKCALL_API_URL}/api/cli/github/install-url",
271
+ headers={"Authorization": f"Bearer {stored.device_token}"},
272
+ )
273
+ response.raise_for_status()
274
+ data = response.json()
275
+
276
+ if data.get("already_connected"):
277
+ return {
278
+ "status": "already_connected",
279
+ "message": f"GitHub is already connected (username: {data.get('username')})",
280
+ "hint": "You can use GitHub tools like list_repos, create_issue, etc.",
281
+ }
282
+
283
+ install_url = data.get("install_url")
284
+
285
+ if open_browser and install_url:
286
+ try:
287
+ webbrowser.open(install_url)
288
+ except Exception as e:
289
+ logger.warning(f"Failed to open browser: {e}")
290
+
291
+ return {
292
+ "status": "pending",
293
+ "message": "Please complete GitHub App installation in your browser.",
294
+ "install_url": install_url,
295
+ "instructions": [
296
+ f"1. Open this URL: {install_url}",
297
+ "2. Select the organization/account to install",
298
+ "3. Choose which repositories to grant access",
299
+ "4. Click 'Install'",
300
+ ],
301
+ "hint": "After installation, run check_quickcall_status to verify.",
302
+ }
303
+
304
+ except httpx.HTTPStatusError as e:
305
+ if e.response.status_code == 401:
306
+ return {
307
+ "status": "error",
308
+ "message": "Session expired. Please reconnect.",
309
+ "hint": "Run disconnect_quickcall then connect_quickcall again.",
310
+ }
311
+ return {
312
+ "status": "error",
313
+ "message": f"API error: {e.response.status_code}",
314
+ }
315
+ except Exception as e:
316
+ logger.error(f"Failed to get GitHub install URL: {e}")
317
+ return {
318
+ "status": "error",
319
+ "message": f"Failed to connect GitHub: {e}",
320
+ }
321
+
322
+ @mcp.tool(tags={"auth", "slack", "quickcall"})
323
+ def connect_slack(open_browser: bool = True) -> Dict[str, Any]:
324
+ """
325
+ Connect Slack to your QuickCall account.
326
+
327
+ This opens your browser to authorize the QuickCall Slack App.
328
+ After authorization, you'll be able to use Slack tools like
329
+ list_channels, send_message, etc.
330
+
331
+ Args:
332
+ open_browser: Automatically open the install URL in browser (default: True)
333
+
334
+ Returns:
335
+ Install URL and instructions
336
+ """
337
+ store = get_credential_store()
338
+
339
+ if not store.is_authenticated():
340
+ return {
341
+ "status": "error",
342
+ "message": "Not connected to QuickCall",
343
+ "hint": "Run connect_quickcall first to authenticate.",
344
+ }
345
+
346
+ stored = store.get_stored_credentials()
347
+ if not stored:
348
+ return {
349
+ "status": "error",
350
+ "message": "No stored credentials found",
351
+ "hint": "Run connect_quickcall to authenticate.",
352
+ }
353
+
354
+ try:
355
+ with httpx.Client(timeout=30.0) as client:
356
+ response = client.get(
357
+ f"{QUICKCALL_API_URL}/api/cli/slack/install-url",
358
+ headers={"Authorization": f"Bearer {stored.device_token}"},
359
+ )
360
+ response.raise_for_status()
361
+ data = response.json()
362
+
363
+ if data.get("already_connected"):
364
+ return {
365
+ "status": "already_connected",
366
+ "message": f"Slack is already connected (workspace: {data.get('team_name')})",
367
+ "hint": "You can use Slack tools like list_channels, send_message, etc.",
368
+ }
369
+
370
+ install_url = data.get("install_url")
371
+
372
+ if open_browser and install_url:
373
+ try:
374
+ webbrowser.open(install_url)
375
+ except Exception as e:
376
+ logger.warning(f"Failed to open browser: {e}")
377
+
378
+ return {
379
+ "status": "pending",
380
+ "message": "Please complete Slack authorization in your browser.",
381
+ "install_url": install_url,
382
+ "instructions": [
383
+ f"1. Open this URL: {install_url}",
384
+ "2. Select the Slack workspace",
385
+ "3. Review permissions and click 'Allow'",
386
+ ],
387
+ "hint": "After authorization, run check_quickcall_status to verify.",
388
+ }
389
+
390
+ except httpx.HTTPStatusError as e:
391
+ if e.response.status_code == 401:
392
+ return {
393
+ "status": "error",
394
+ "message": "Session expired. Please reconnect.",
395
+ "hint": "Run disconnect_quickcall then connect_quickcall again.",
396
+ }
397
+ if e.response.status_code == 503:
398
+ return {
399
+ "status": "error",
400
+ "message": "Slack integration is not configured on the server.",
401
+ }
402
+ return {
403
+ "status": "error",
404
+ "message": f"API error: {e.response.status_code}",
405
+ }
406
+ except Exception as e:
407
+ logger.error(f"Failed to get Slack install URL: {e}")
408
+ return {
409
+ "status": "error",
410
+ "message": f"Failed to connect Slack: {e}",
411
+ }
@@ -80,17 +80,35 @@ def create_git_tools(mcp: FastMCP) -> None:
80
80
  ),
81
81
  ) -> dict:
82
82
  """
83
- Get updates from a git repository.
83
+ Get updates from a git repository. Returns commits, diff stats, and uncommitted changes.
84
84
 
85
- Returns commits, diff stats, file changes, and actual code diff for the given period.
85
+ FORMAT OUTPUT AS A STANDUP SUMMARY:
86
+
87
+ **Summary:** One sentence of what was accomplished.
88
+
89
+ **What I worked on:**
90
+ - Bullet points of key changes (group related commits)
91
+ - Focus on features/fixes, not individual commits
92
+ - Use past tense action verbs
93
+
94
+ **In Progress:**
95
+ - Any uncommitted changes (what's being worked on now)
96
+
97
+ **Blockers:** Only mention if there are merge conflicts or issues visible.
98
+
99
+ Never display raw JSON to the user.
86
100
  """
87
101
  try:
88
102
  repo_info = _get_repo_info(path)
89
- repo_name = f"{repo_info['owner']}/{repo_info['repo']}" if repo_info['owner'] else repo_info['root']
103
+ repo_name = (
104
+ f"{repo_info['owner']}/{repo_info['repo']}"
105
+ if repo_info["owner"]
106
+ else repo_info["root"]
107
+ )
90
108
 
91
109
  result = {
92
110
  "repository": repo_name,
93
- "branch": repo_info['branch'],
111
+ "branch": repo_info["branch"],
94
112
  "period": f"Last {days} days",
95
113
  }
96
114
 
@@ -109,18 +127,25 @@ def create_git_tools(mcp: FastMCP) -> None:
109
127
  continue
110
128
  parts = line.split("|", 3)
111
129
  if len(parts) >= 4:
112
- commits.append({
113
- "sha": parts[0][:7],
114
- "author": parts[1],
115
- "date": parts[2],
116
- "message": parts[3],
117
- })
130
+ commits.append(
131
+ {
132
+ "sha": parts[0][:7],
133
+ "author": parts[1],
134
+ "date": parts[2],
135
+ "message": parts[3],
136
+ }
137
+ )
118
138
 
119
139
  result["commits"] = commits
120
140
  result["commit_count"] = len(commits)
121
141
 
122
142
  if not commits:
123
- result["diff"] = {"files_changed": 0, "additions": 0, "deletions": 0, "patch": ""}
143
+ result["diff"] = {
144
+ "files_changed": 0,
145
+ "additions": 0,
146
+ "deletions": 0,
147
+ "patch": "",
148
+ }
124
149
  return result
125
150
 
126
151
  # Get total diff between oldest and newest commit
@@ -129,7 +154,9 @@ def create_git_tools(mcp: FastMCP) -> None:
129
154
 
130
155
  try:
131
156
  # Get stats
132
- numstat = _run_git(["diff", "--numstat", f"{oldest_sha}^", newest_sha], path)
157
+ numstat = _run_git(
158
+ ["diff", "--numstat", f"{oldest_sha}^", newest_sha], path
159
+ )
133
160
 
134
161
  files = []
135
162
  total_add = 0
@@ -142,11 +169,13 @@ def create_git_tools(mcp: FastMCP) -> None:
142
169
  if len(parts) >= 3:
143
170
  adds = int(parts[0]) if parts[0] != "-" else 0
144
171
  dels = int(parts[1]) if parts[1] != "-" else 0
145
- files.append({
146
- "file": parts[2],
147
- "additions": adds,
148
- "deletions": dels,
149
- })
172
+ files.append(
173
+ {
174
+ "file": parts[2],
175
+ "additions": adds,
176
+ "deletions": dels,
177
+ }
178
+ )
150
179
  total_add += adds
151
180
  total_del += dels
152
181
 
@@ -155,7 +184,9 @@ def create_git_tools(mcp: FastMCP) -> None:
155
184
 
156
185
  # Truncate if too large
157
186
  if len(diff_patch) > 50000:
158
- diff_patch = diff_patch[:50000] + "\n\n... (truncated, diff too large)"
187
+ diff_patch = (
188
+ diff_patch[:50000] + "\n\n... (truncated, diff too large)"
189
+ )
159
190
 
160
191
  result["diff"] = {
161
192
  "files_changed": len(files),
@@ -165,7 +196,12 @@ def create_git_tools(mcp: FastMCP) -> None:
165
196
  "patch": diff_patch,
166
197
  }
167
198
  except ToolError:
168
- result["diff"] = {"files_changed": 0, "additions": 0, "deletions": 0, "patch": ""}
199
+ result["diff"] = {
200
+ "files_changed": 0,
201
+ "additions": 0,
202
+ "deletions": 0,
203
+ "patch": "",
204
+ }
169
205
 
170
206
  # Uncommitted changes
171
207
  staged = _run_git(["diff", "--cached", "--name-only"], path)
@@ -178,7 +214,9 @@ def create_git_tools(mcp: FastMCP) -> None:
178
214
  # Get uncommitted diff patch too
179
215
  uncommitted_patch = _run_git(["diff", "HEAD"], path)
180
216
  if len(uncommitted_patch) > 20000:
181
- uncommitted_patch = uncommitted_patch[:20000] + "\n\n... (truncated)"
217
+ uncommitted_patch = (
218
+ uncommitted_patch[:20000] + "\n\n... (truncated)"
219
+ )
182
220
 
183
221
  result["uncommitted"] = {
184
222
  "staged": staged_list,