quickcall-integrations 0.1.3__py3-none-any.whl → 0.1.5__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,253 @@
1
+ """
2
+ OAuth Device Flow Authentication for QuickCall MCP.
3
+
4
+ Implements RFC 8628 device authorization flow:
5
+ 1. CLI calls init to get device_code + user_code
6
+ 2. User visits quickcall.dev/cli/setup?code={user_code}
7
+ 3. User signs in with Google (Clerk)
8
+ 4. CLI polls until complete, receives device_token
9
+ 5. Device token stored locally for future API calls
10
+ """
11
+
12
+ import os
13
+ import time
14
+ import logging
15
+ import webbrowser
16
+ from typing import Optional, Tuple
17
+ from datetime import datetime, timezone
18
+
19
+ import httpx
20
+
21
+ from mcp_server.auth.credentials import (
22
+ CredentialStore,
23
+ StoredCredentials,
24
+ get_credential_store,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # QuickCall URLs - configurable via environment for local testing
30
+ # Set QUICKCALL_API_URL=http://localhost:8000 for local backend
31
+ # Set QUICKCALL_WEB_URL=http://localhost:3000 for local frontend
32
+ QUICKCALL_API_URL = os.getenv("QUICKCALL_API_URL", "https://api.quickcall.dev")
33
+ QUICKCALL_WEB_URL = os.getenv("QUICKCALL_WEB_URL", "https://quickcall.dev")
34
+
35
+
36
+ class DeviceFlowAuth:
37
+ """
38
+ Handles OAuth device flow authentication.
39
+
40
+ Usage:
41
+ auth = DeviceFlowAuth()
42
+
43
+ # Start flow (opens browser)
44
+ success = auth.authenticate()
45
+
46
+ if success:
47
+ print("Authenticated!")
48
+ # Credentials are now stored in ~/.quickcall/credentials.json
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ api_url: Optional[str] = None,
54
+ web_url: Optional[str] = None,
55
+ credential_store: Optional[CredentialStore] = None,
56
+ ):
57
+ """
58
+ Initialize device flow authentication.
59
+
60
+ Args:
61
+ api_url: QuickCall API URL (defaults to production)
62
+ web_url: QuickCall web URL (defaults to production)
63
+ credential_store: Credential store instance (defaults to global)
64
+ """
65
+ self.api_url = api_url or QUICKCALL_API_URL
66
+ self.web_url = web_url or QUICKCALL_WEB_URL
67
+ self.credential_store = credential_store or get_credential_store()
68
+
69
+ def init_flow(self) -> Tuple[str, str, str, int, int]:
70
+ """
71
+ Initialize device authorization flow.
72
+
73
+ Returns:
74
+ Tuple of (device_code, user_code, verification_url, expires_in, interval)
75
+
76
+ Raises:
77
+ Exception if initialization fails
78
+ """
79
+ with httpx.Client(timeout=30.0) as client:
80
+ response = client.post(f"{self.api_url}/api/device/init")
81
+ response.raise_for_status()
82
+ data = response.json()
83
+
84
+ return (
85
+ data["device_code"],
86
+ data["user_code"],
87
+ data["verification_url"],
88
+ data["expires_in"],
89
+ data["interval"],
90
+ )
91
+
92
+ def poll_for_completion(
93
+ self,
94
+ device_code: str,
95
+ interval: int = 5,
96
+ timeout: int = 900,
97
+ on_poll: Optional[callable] = None,
98
+ ) -> Optional[StoredCredentials]:
99
+ """
100
+ Poll for device authorization completion.
101
+
102
+ Args:
103
+ device_code: Device code from init_flow
104
+ interval: Polling interval in seconds
105
+ timeout: Maximum time to wait in seconds
106
+ on_poll: Optional callback called on each poll (for progress indication)
107
+
108
+ Returns:
109
+ StoredCredentials if successful, None if expired/cancelled
110
+ """
111
+ start_time = time.time()
112
+
113
+ with httpx.Client(timeout=30.0) as client:
114
+ while time.time() - start_time < timeout:
115
+ if on_poll:
116
+ on_poll()
117
+
118
+ try:
119
+ response = client.get(
120
+ f"{self.api_url}/api/device/status",
121
+ params={"device_code": device_code},
122
+ )
123
+ response.raise_for_status()
124
+ data = response.json()
125
+
126
+ status = data["status"]
127
+
128
+ if status == "complete":
129
+ # Success! Save credentials
130
+ credentials = StoredCredentials(
131
+ device_token=data["device_token"],
132
+ user_id=data["user_id"],
133
+ authenticated_at=datetime.now(timezone.utc)
134
+ .isoformat()
135
+ .replace("+00:00", "Z"),
136
+ )
137
+ self.credential_store.save(credentials)
138
+ return credentials
139
+
140
+ elif status == "expired":
141
+ logger.warning("Authorization code expired")
142
+ return None
143
+
144
+ elif status == "revoked":
145
+ logger.warning("Authorization was revoked")
146
+ return None
147
+
148
+ # Status is "pending", continue polling
149
+ time.sleep(interval)
150
+
151
+ except httpx.HTTPStatusError as e:
152
+ if e.response.status_code == 404:
153
+ logger.error("Device code not found")
154
+ return None
155
+ raise
156
+
157
+ logger.warning("Polling timeout exceeded")
158
+ return None
159
+
160
+ def authenticate(
161
+ self,
162
+ open_browser: bool = True,
163
+ print_instructions: bool = True,
164
+ ) -> bool:
165
+ """
166
+ Run the full device flow authentication.
167
+
168
+ This is the main entry point for CLI authentication:
169
+ 1. Initializes the flow
170
+ 2. Opens browser (or prints URL)
171
+ 3. Polls until complete
172
+ 4. Saves credentials
173
+
174
+ Args:
175
+ open_browser: Whether to automatically open browser
176
+ print_instructions: Whether to print user instructions
177
+
178
+ Returns:
179
+ True if authentication successful, False otherwise
180
+ """
181
+ try:
182
+ # Initialize flow
183
+ device_code, user_code, verification_url, expires_in, interval = (
184
+ self.init_flow()
185
+ )
186
+
187
+ # Build URL with code
188
+ auth_url = f"{verification_url}?code={user_code}"
189
+
190
+ if print_instructions:
191
+ print("\n" + "=" * 50)
192
+ print("QuickCall Authentication")
193
+ print("=" * 50)
194
+ print(f"\nYour code: {user_code}")
195
+ print(f"\nVisit: {auth_url}")
196
+ print("\nSign in with Google to connect your CLI.")
197
+ print(f"This code expires in {expires_in // 60} minutes.")
198
+ print("=" * 50 + "\n")
199
+
200
+ # Open browser
201
+ if open_browser:
202
+ try:
203
+ webbrowser.open(auth_url)
204
+ if print_instructions:
205
+ print("Browser opened. Waiting for authorization...")
206
+ except Exception as e:
207
+ logger.warning(f"Could not open browser: {e}")
208
+ if print_instructions:
209
+ print("Could not open browser. Please visit the URL manually.")
210
+
211
+ # Poll for completion
212
+ def on_poll():
213
+ if print_instructions:
214
+ print(".", end="", flush=True)
215
+
216
+ credentials = self.poll_for_completion(
217
+ device_code=device_code,
218
+ interval=interval,
219
+ on_poll=on_poll,
220
+ )
221
+
222
+ if credentials:
223
+ if print_instructions:
224
+ print("\n\nAuthentication successful!")
225
+ print(f"Connected as: {credentials.email or credentials.user_id}")
226
+ return True
227
+ else:
228
+ if print_instructions:
229
+ print("\n\nAuthentication failed or timed out.")
230
+ return False
231
+
232
+ except Exception as e:
233
+ logger.error(f"Authentication error: {e}")
234
+ if print_instructions:
235
+ print(f"\nAuthentication error: {e}")
236
+ return False
237
+
238
+ def disconnect(self) -> bool:
239
+ """
240
+ Disconnect the current device (logout).
241
+
242
+ This clears local credentials. The device token remains valid
243
+ on the server until explicitly revoked from the web UI.
244
+
245
+ Returns:
246
+ True if cleared successfully
247
+ """
248
+ try:
249
+ self.credential_store.clear()
250
+ return True
251
+ except Exception as e:
252
+ logger.error(f"Failed to disconnect: {e}")
253
+ return False
mcp_server/server.py CHANGED
@@ -1,24 +1,76 @@
1
1
  """
2
2
  QuickCall Integrations MCP Server
3
3
 
4
- Git tools for developers - view commits, diffs, and changes.
4
+ Developer integrations for Claude Code and Cursor:
5
+ - Local git tools (always available)
6
+ - GitHub API tools (requires QuickCall authentication + GitHub connected)
7
+ - Slack tools (requires QuickCall authentication + Slack connected)
8
+
9
+ Authentication:
10
+ - Run connect_quickcall to authenticate via OAuth
11
+ - Credentials stored locally in ~/.quickcall/credentials.json
12
+ - GitHub and Slack tokens fetched from quickcall.dev API
5
13
  """
6
14
 
7
15
  import os
16
+ import logging
8
17
 
9
18
  from fastmcp import FastMCP
10
19
 
20
+ from mcp_server.auth import get_credential_store
11
21
  from mcp_server.tools.git_tools import create_git_tools
12
22
  from mcp_server.tools.utility_tools import create_utility_tools
23
+ from mcp_server.tools.github_tools import create_github_tools
24
+ from mcp_server.tools.slack_tools import create_slack_tools
25
+ from mcp_server.tools.auth_tools import create_auth_tools
26
+
27
+ # Configure logging
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
31
+ )
32
+ logger = logging.getLogger(__name__)
13
33
 
14
34
 
15
35
  def create_server() -> FastMCP:
16
- """Create and configure the MCP server."""
36
+ """Create and configure the MCP server with graceful degradation."""
17
37
  mcp = FastMCP("quickcall-integrations")
18
38
 
39
+ # Check authentication status
40
+ store = get_credential_store()
41
+ is_authenticated = store.is_authenticated()
42
+
43
+ # Always register local git tools (no credentials needed)
19
44
  create_git_tools(mcp)
20
45
  create_utility_tools(mcp)
21
46
 
47
+ # Register authentication tools (always available)
48
+ create_auth_tools(mcp)
49
+ logger.info(
50
+ "Auth tools: enabled (connect_quickcall, check_quickcall_status, disconnect_quickcall)"
51
+ )
52
+
53
+ # Register GitHub and Slack tools (check credentials at runtime)
54
+ create_github_tools(mcp)
55
+ create_slack_tools(mcp)
56
+
57
+ # Log current status
58
+ if is_authenticated:
59
+ logger.info("QuickCall: authenticated")
60
+ creds = store.get_api_credentials()
61
+ if creds:
62
+ logger.info(
63
+ f"GitHub: {'connected' if creds.github_connected else 'not connected'}"
64
+ )
65
+ logger.info(
66
+ f"Slack: {'connected' if creds.slack_connected else 'not connected'}"
67
+ )
68
+ else:
69
+ logger.info("QuickCall: not authenticated")
70
+ logger.info(
71
+ "Run connect_quickcall to authenticate and enable GitHub/Slack tools"
72
+ )
73
+
22
74
  return mcp
23
75
 
24
76
 
@@ -1 +1,13 @@
1
1
  """MCP tools for external integrations"""
2
+
3
+ from mcp_server.tools.git_tools import create_git_tools
4
+ from mcp_server.tools.utility_tools import create_utility_tools
5
+ from mcp_server.tools.github_tools import create_github_tools
6
+ from mcp_server.tools.slack_tools import create_slack_tools
7
+
8
+ __all__ = [
9
+ "create_git_tools",
10
+ "create_utility_tools",
11
+ "create_github_tools",
12
+ "create_slack_tools",
13
+ ]