quickcall-integrations 0.1.3__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
+ }
@@ -100,11 +100,15 @@ def create_git_tools(mcp: FastMCP) -> None:
100
100
  """
101
101
  try:
102
102
  repo_info = _get_repo_info(path)
103
- 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
+ )
104
108
 
105
109
  result = {
106
110
  "repository": repo_name,
107
- "branch": repo_info['branch'],
111
+ "branch": repo_info["branch"],
108
112
  "period": f"Last {days} days",
109
113
  }
110
114
 
@@ -123,18 +127,25 @@ def create_git_tools(mcp: FastMCP) -> None:
123
127
  continue
124
128
  parts = line.split("|", 3)
125
129
  if len(parts) >= 4:
126
- commits.append({
127
- "sha": parts[0][:7],
128
- "author": parts[1],
129
- "date": parts[2],
130
- "message": parts[3],
131
- })
130
+ commits.append(
131
+ {
132
+ "sha": parts[0][:7],
133
+ "author": parts[1],
134
+ "date": parts[2],
135
+ "message": parts[3],
136
+ }
137
+ )
132
138
 
133
139
  result["commits"] = commits
134
140
  result["commit_count"] = len(commits)
135
141
 
136
142
  if not commits:
137
- 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
+ }
138
149
  return result
139
150
 
140
151
  # Get total diff between oldest and newest commit
@@ -143,7 +154,9 @@ def create_git_tools(mcp: FastMCP) -> None:
143
154
 
144
155
  try:
145
156
  # Get stats
146
- 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
+ )
147
160
 
148
161
  files = []
149
162
  total_add = 0
@@ -156,11 +169,13 @@ def create_git_tools(mcp: FastMCP) -> None:
156
169
  if len(parts) >= 3:
157
170
  adds = int(parts[0]) if parts[0] != "-" else 0
158
171
  dels = int(parts[1]) if parts[1] != "-" else 0
159
- files.append({
160
- "file": parts[2],
161
- "additions": adds,
162
- "deletions": dels,
163
- })
172
+ files.append(
173
+ {
174
+ "file": parts[2],
175
+ "additions": adds,
176
+ "deletions": dels,
177
+ }
178
+ )
164
179
  total_add += adds
165
180
  total_del += dels
166
181
 
@@ -169,7 +184,9 @@ def create_git_tools(mcp: FastMCP) -> None:
169
184
 
170
185
  # Truncate if too large
171
186
  if len(diff_patch) > 50000:
172
- 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
+ )
173
190
 
174
191
  result["diff"] = {
175
192
  "files_changed": len(files),
@@ -179,7 +196,12 @@ def create_git_tools(mcp: FastMCP) -> None:
179
196
  "patch": diff_patch,
180
197
  }
181
198
  except ToolError:
182
- 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
+ }
183
205
 
184
206
  # Uncommitted changes
185
207
  staged = _run_git(["diff", "--cached", "--name-only"], path)
@@ -192,7 +214,9 @@ def create_git_tools(mcp: FastMCP) -> None:
192
214
  # Get uncommitted diff patch too
193
215
  uncommitted_patch = _run_git(["diff", "HEAD"], path)
194
216
  if len(uncommitted_patch) > 20000:
195
- uncommitted_patch = uncommitted_patch[:20000] + "\n\n... (truncated)"
217
+ uncommitted_patch = (
218
+ uncommitted_patch[:20000] + "\n\n... (truncated)"
219
+ )
196
220
 
197
221
  result["uncommitted"] = {
198
222
  "staged": staged_list,