quickcall-integrations 0.1.6__py3-none-any.whl → 0.1.8__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/__init__.py +1 -1
- mcp_server/api_clients/github_client.py +26 -4
- mcp_server/api_clients/slack_client.py +189 -9
- mcp_server/resources/__init__.py +1 -0
- mcp_server/resources/slack_resources.py +50 -0
- mcp_server/server.py +4 -0
- mcp_server/tools/auth_tools.py +87 -3
- mcp_server/tools/slack_tools.py +159 -3
- {quickcall_integrations-0.1.6.dist-info → quickcall_integrations-0.1.8.dist-info}/METADATA +99 -9
- quickcall_integrations-0.1.8.dist-info/RECORD +20 -0
- quickcall_integrations-0.1.6.dist-info/RECORD +0 -18
- {quickcall_integrations-0.1.6.dist-info → quickcall_integrations-0.1.8.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.1.6.dist-info → quickcall_integrations-0.1.8.dist-info}/entry_points.txt +0 -0
mcp_server/__init__.py
CHANGED
|
@@ -127,14 +127,36 @@ class GitHubClient:
|
|
|
127
127
|
def health_check(self) -> bool:
|
|
128
128
|
"""Check if GitHub API is accessible with the token."""
|
|
129
129
|
try:
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
# Use installation/repositories endpoint - works with GitHub App tokens
|
|
131
|
+
with httpx.Client() as client:
|
|
132
|
+
response = client.get(
|
|
133
|
+
"https://api.github.com/installation/repositories",
|
|
134
|
+
headers={
|
|
135
|
+
"Authorization": f"Bearer {self.token}",
|
|
136
|
+
"Accept": "application/vnd.github+json",
|
|
137
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
138
|
+
},
|
|
139
|
+
params={"per_page": 1},
|
|
140
|
+
)
|
|
141
|
+
return response.status_code == 200
|
|
132
142
|
except Exception:
|
|
133
143
|
return False
|
|
134
144
|
|
|
135
145
|
def get_authenticated_user(self) -> str:
|
|
136
|
-
"""
|
|
137
|
-
|
|
146
|
+
"""
|
|
147
|
+
Get the GitHub username associated with this installation.
|
|
148
|
+
|
|
149
|
+
Note: GitHub App installation tokens can't access /user endpoint.
|
|
150
|
+
We return the installation owner instead.
|
|
151
|
+
"""
|
|
152
|
+
# Try to get from first repo's owner
|
|
153
|
+
try:
|
|
154
|
+
repos = self.list_repos(limit=1)
|
|
155
|
+
if repos:
|
|
156
|
+
return repos[0].owner.login
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
return "GitHub App" # Fallback
|
|
138
160
|
|
|
139
161
|
def close(self):
|
|
140
162
|
"""Close GitHub API client."""
|
|
@@ -10,6 +10,7 @@ from typing import List, Optional, Dict, Any
|
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
from pydantic import BaseModel
|
|
13
|
+
from rapidfuzz import fuzz, process
|
|
13
14
|
|
|
14
15
|
logger = logging.getLogger(__name__)
|
|
15
16
|
|
|
@@ -51,6 +52,18 @@ class SlackMessage(BaseModel):
|
|
|
51
52
|
message: Optional[Dict[str, Any]] = None
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
class SlackChannelMessage(BaseModel):
|
|
56
|
+
"""Represents a message from channel history."""
|
|
57
|
+
|
|
58
|
+
ts: str # Message timestamp (used as ID)
|
|
59
|
+
user: Optional[str] = None
|
|
60
|
+
user_name: Optional[str] = None
|
|
61
|
+
text: str
|
|
62
|
+
thread_ts: Optional[str] = None
|
|
63
|
+
reply_count: int = 0
|
|
64
|
+
has_thread: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
54
67
|
# ============================================================================
|
|
55
68
|
# Slack Client
|
|
56
69
|
# ============================================================================
|
|
@@ -62,6 +75,16 @@ class SlackClient:
|
|
|
62
75
|
|
|
63
76
|
Provides simplified interface for Slack operations.
|
|
64
77
|
Uses bot token authentication.
|
|
78
|
+
|
|
79
|
+
Note on Caching:
|
|
80
|
+
This client caches channel list and user mappings to reduce API calls.
|
|
81
|
+
Cache is per-instance and does NOT expire automatically.
|
|
82
|
+
New channels/users won't appear until:
|
|
83
|
+
- MCP server restarts (new session)
|
|
84
|
+
- New SlackClient instance is created
|
|
85
|
+
|
|
86
|
+
TODO: Add TTL-based cache invalidation if this becomes an issue.
|
|
87
|
+
See internal-docs/issues/007-slack-api-caching.md
|
|
65
88
|
"""
|
|
66
89
|
|
|
67
90
|
BASE_URL = "https://slack.com/api"
|
|
@@ -80,6 +103,9 @@ class SlackClient:
|
|
|
80
103
|
"Authorization": f"Bearer {bot_token}",
|
|
81
104
|
"Content-Type": "application/json",
|
|
82
105
|
}
|
|
106
|
+
# Caches (per-instance, cleared on new client)
|
|
107
|
+
self._channel_cache: Optional[List["SlackChannel"]] = None
|
|
108
|
+
self._user_cache: Optional[Dict[str, str]] = None
|
|
83
109
|
|
|
84
110
|
async def _request(
|
|
85
111
|
self,
|
|
@@ -161,7 +187,7 @@ class SlackClient:
|
|
|
161
187
|
# ========================================================================
|
|
162
188
|
|
|
163
189
|
def list_channels(
|
|
164
|
-
self, include_private: bool = True, limit: int = 200
|
|
190
|
+
self, include_private: bool = True, limit: int = 200, use_cache: bool = True
|
|
165
191
|
) -> List[SlackChannel]:
|
|
166
192
|
"""
|
|
167
193
|
List Slack channels the bot has access to.
|
|
@@ -169,18 +195,30 @@ class SlackClient:
|
|
|
169
195
|
Args:
|
|
170
196
|
include_private: Whether to include private channels
|
|
171
197
|
limit: Maximum channels to return
|
|
198
|
+
use_cache: Use cached results if available (default: True)
|
|
172
199
|
|
|
173
200
|
Returns:
|
|
174
201
|
List of channels
|
|
175
202
|
"""
|
|
203
|
+
# Return cached if available
|
|
204
|
+
if use_cache and self._channel_cache is not None:
|
|
205
|
+
return (
|
|
206
|
+
self._channel_cache[:limit]
|
|
207
|
+
if limit < len(self._channel_cache)
|
|
208
|
+
else self._channel_cache
|
|
209
|
+
)
|
|
210
|
+
|
|
176
211
|
types = (
|
|
177
212
|
"public_channel,private_channel" if include_private else "public_channel"
|
|
178
213
|
)
|
|
179
214
|
|
|
215
|
+
# Always fetch 200 to ensure we get all channels for caching
|
|
216
|
+
fetch_limit = 200
|
|
217
|
+
|
|
180
218
|
data = self._request_sync(
|
|
181
219
|
"GET",
|
|
182
220
|
"conversations.list",
|
|
183
|
-
params={"types": types, "limit":
|
|
221
|
+
params={"types": types, "limit": fetch_limit, "exclude_archived": True},
|
|
184
222
|
)
|
|
185
223
|
|
|
186
224
|
channels = []
|
|
@@ -196,11 +234,13 @@ class SlackClient:
|
|
|
196
234
|
)
|
|
197
235
|
)
|
|
198
236
|
|
|
199
|
-
|
|
237
|
+
# Cache the full result
|
|
238
|
+
self._channel_cache = channels
|
|
239
|
+
return channels[:limit] if limit < len(channels) else channels
|
|
200
240
|
|
|
201
241
|
def _resolve_channel(self, channel: Optional[str] = None) -> str:
|
|
202
242
|
"""
|
|
203
|
-
Resolve channel name to channel ID.
|
|
243
|
+
Resolve channel name to channel ID with fuzzy matching.
|
|
204
244
|
|
|
205
245
|
Args:
|
|
206
246
|
channel: Channel name (with or without #) or channel ID
|
|
@@ -213,8 +253,8 @@ class SlackClient:
|
|
|
213
253
|
if not channel:
|
|
214
254
|
raise ValueError("No channel specified and no default channel configured")
|
|
215
255
|
|
|
216
|
-
# If it's already an ID (starts with C), return as-is
|
|
217
|
-
if channel.startswith("C"):
|
|
256
|
+
# If it's already an ID (starts with C or G for private), return as-is
|
|
257
|
+
if channel.startswith("C") or channel.startswith("G"):
|
|
218
258
|
return channel
|
|
219
259
|
|
|
220
260
|
# Strip # prefix if present
|
|
@@ -222,9 +262,27 @@ class SlackClient:
|
|
|
222
262
|
|
|
223
263
|
# Look up channel by name
|
|
224
264
|
channels = self.list_channels()
|
|
225
|
-
for ch in channels
|
|
226
|
-
|
|
227
|
-
|
|
265
|
+
channel_names = {ch.name.lower(): ch for ch in channels}
|
|
266
|
+
|
|
267
|
+
# First try exact match
|
|
268
|
+
if channel_name in channel_names:
|
|
269
|
+
return channel_names[channel_name].id
|
|
270
|
+
|
|
271
|
+
# Use rapidfuzz for fuzzy matching
|
|
272
|
+
# token_sort_ratio handles word reordering (e.g., "dev no sleep" = "no sleep dev")
|
|
273
|
+
match = process.extractOne(
|
|
274
|
+
channel_name,
|
|
275
|
+
list(channel_names.keys()),
|
|
276
|
+
scorer=fuzz.token_sort_ratio,
|
|
277
|
+
score_cutoff=70, # Minimum 70% match
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if match:
|
|
281
|
+
matched_name, score, _ = match
|
|
282
|
+
logger.info(
|
|
283
|
+
f"Fuzzy matched '{channel}' to '{matched_name}' (score: {score})"
|
|
284
|
+
)
|
|
285
|
+
return channel_names[matched_name].id
|
|
228
286
|
|
|
229
287
|
raise ValueError(f"Channel '{channel}' not found or bot is not a member")
|
|
230
288
|
|
|
@@ -304,6 +362,128 @@ class SlackClient:
|
|
|
304
362
|
message=data.get("message"),
|
|
305
363
|
)
|
|
306
364
|
|
|
365
|
+
# ========================================================================
|
|
366
|
+
# Message History
|
|
367
|
+
# ========================================================================
|
|
368
|
+
|
|
369
|
+
def get_channel_messages(
|
|
370
|
+
self,
|
|
371
|
+
channel: str,
|
|
372
|
+
oldest: Optional[str] = None,
|
|
373
|
+
latest: Optional[str] = None,
|
|
374
|
+
limit: int = 100,
|
|
375
|
+
) -> List[SlackChannelMessage]:
|
|
376
|
+
"""
|
|
377
|
+
Get messages from a channel.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
channel: Channel name or ID
|
|
381
|
+
oldest: Unix timestamp - only messages after this time
|
|
382
|
+
latest: Unix timestamp - only messages before this time
|
|
383
|
+
limit: Maximum messages to return (default 100)
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
List of messages (newest first)
|
|
387
|
+
"""
|
|
388
|
+
channel_id = self._resolve_channel(channel)
|
|
389
|
+
|
|
390
|
+
params = {
|
|
391
|
+
"channel": channel_id,
|
|
392
|
+
"limit": limit,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if oldest:
|
|
396
|
+
params["oldest"] = oldest
|
|
397
|
+
if latest:
|
|
398
|
+
params["latest"] = latest
|
|
399
|
+
|
|
400
|
+
data = self._request_sync("GET", "conversations.history", params=params)
|
|
401
|
+
|
|
402
|
+
# Get user info for resolving names
|
|
403
|
+
user_map = self._get_user_map()
|
|
404
|
+
|
|
405
|
+
messages = []
|
|
406
|
+
for msg in data.get("messages", []):
|
|
407
|
+
# Skip non-message types (joins, leaves, etc.)
|
|
408
|
+
if msg.get("subtype") in ["channel_join", "channel_leave", "bot_add"]:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
user_id = msg.get("user")
|
|
412
|
+
messages.append(
|
|
413
|
+
SlackChannelMessage(
|
|
414
|
+
ts=msg.get("ts", ""),
|
|
415
|
+
user=user_id,
|
|
416
|
+
user_name=user_map.get(user_id, user_id),
|
|
417
|
+
text=msg.get("text", ""),
|
|
418
|
+
thread_ts=msg.get("thread_ts"),
|
|
419
|
+
reply_count=msg.get("reply_count", 0),
|
|
420
|
+
has_thread=msg.get("reply_count", 0) > 0,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return messages
|
|
425
|
+
|
|
426
|
+
def get_thread_replies(
|
|
427
|
+
self,
|
|
428
|
+
channel: str,
|
|
429
|
+
thread_ts: str,
|
|
430
|
+
limit: int = 100,
|
|
431
|
+
) -> List[SlackChannelMessage]:
|
|
432
|
+
"""
|
|
433
|
+
Get replies in a thread.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
channel: Channel name or ID
|
|
437
|
+
thread_ts: Thread parent message timestamp
|
|
438
|
+
limit: Maximum replies to return
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
List of replies (includes parent message first)
|
|
442
|
+
"""
|
|
443
|
+
channel_id = self._resolve_channel(channel)
|
|
444
|
+
|
|
445
|
+
params = {
|
|
446
|
+
"channel": channel_id,
|
|
447
|
+
"ts": thread_ts,
|
|
448
|
+
"limit": limit,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
data = self._request_sync("GET", "conversations.replies", params=params)
|
|
452
|
+
|
|
453
|
+
user_map = self._get_user_map()
|
|
454
|
+
|
|
455
|
+
messages = []
|
|
456
|
+
for msg in data.get("messages", []):
|
|
457
|
+
user_id = msg.get("user")
|
|
458
|
+
messages.append(
|
|
459
|
+
SlackChannelMessage(
|
|
460
|
+
ts=msg.get("ts", ""),
|
|
461
|
+
user=user_id,
|
|
462
|
+
user_name=user_map.get(user_id, user_id),
|
|
463
|
+
text=msg.get("text", ""),
|
|
464
|
+
thread_ts=msg.get("thread_ts"),
|
|
465
|
+
reply_count=msg.get("reply_count", 0),
|
|
466
|
+
has_thread=False,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return messages
|
|
471
|
+
|
|
472
|
+
def _get_user_map(self) -> Dict[str, str]:
|
|
473
|
+
"""Get a mapping of user IDs to display names (cached)."""
|
|
474
|
+
# Return cached if available
|
|
475
|
+
if self._user_cache is not None:
|
|
476
|
+
return self._user_cache
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
users = self.list_users(limit=500, include_bots=True)
|
|
480
|
+
self._user_cache = {
|
|
481
|
+
u.id: u.display_name or u.real_name or u.name for u in users
|
|
482
|
+
}
|
|
483
|
+
return self._user_cache
|
|
484
|
+
except Exception:
|
|
485
|
+
return {}
|
|
486
|
+
|
|
307
487
|
# ========================================================================
|
|
308
488
|
# User Operations
|
|
309
489
|
# ========================================================================
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP Resources for QuickCall."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slack MCP Resources - Exposes Slack data for Claude's context.
|
|
3
|
+
|
|
4
|
+
Resources are automatically available in Claude's context when connected.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from mcp_server.tools.slack_tools import _get_client
|
|
11
|
+
from mcp_server.auth import get_credential_store
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_slack_resources(mcp: FastMCP) -> None:
|
|
17
|
+
"""Add Slack resources to the MCP server."""
|
|
18
|
+
|
|
19
|
+
@mcp.resource("slack://channels")
|
|
20
|
+
def get_slack_channels() -> str:
|
|
21
|
+
"""
|
|
22
|
+
List of Slack channels the bot has access to.
|
|
23
|
+
|
|
24
|
+
Use these channel names when reading/sending messages.
|
|
25
|
+
"""
|
|
26
|
+
store = get_credential_store()
|
|
27
|
+
|
|
28
|
+
if not store.is_authenticated():
|
|
29
|
+
return "Slack not connected. Run connect_quickcall first."
|
|
30
|
+
|
|
31
|
+
creds = store.get_api_credentials()
|
|
32
|
+
if not creds or not creds.slack_connected or not creds.slack_bot_token:
|
|
33
|
+
return "Slack not connected. Connect at quickcall.dev/assistant."
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
# Use shared cached client
|
|
37
|
+
client = _get_client()
|
|
38
|
+
channels = client.list_channels(include_private=True, limit=200)
|
|
39
|
+
|
|
40
|
+
# Format as readable list
|
|
41
|
+
lines = ["Available Slack Channels:", ""]
|
|
42
|
+
for ch in channels:
|
|
43
|
+
status = "member" if ch.is_member else "not member"
|
|
44
|
+
privacy = "private" if ch.is_private else "public"
|
|
45
|
+
lines.append(f"- #{ch.name} ({privacy}, {status})")
|
|
46
|
+
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.error(f"Failed to fetch Slack channels: {e}")
|
|
50
|
+
return f"Error fetching channels: {str(e)}"
|
mcp_server/server.py
CHANGED
|
@@ -23,6 +23,7 @@ from mcp_server.tools.utility_tools import create_utility_tools
|
|
|
23
23
|
from mcp_server.tools.github_tools import create_github_tools
|
|
24
24
|
from mcp_server.tools.slack_tools import create_slack_tools
|
|
25
25
|
from mcp_server.tools.auth_tools import create_auth_tools
|
|
26
|
+
from mcp_server.resources.slack_resources import create_slack_resources
|
|
26
27
|
|
|
27
28
|
# Configure logging
|
|
28
29
|
logging.basicConfig(
|
|
@@ -54,6 +55,9 @@ def create_server() -> FastMCP:
|
|
|
54
55
|
create_github_tools(mcp)
|
|
55
56
|
create_slack_tools(mcp)
|
|
56
57
|
|
|
58
|
+
# Register resources (available in Claude's context)
|
|
59
|
+
create_slack_resources(mcp)
|
|
60
|
+
|
|
57
61
|
# Log current status
|
|
58
62
|
if is_authenticated:
|
|
59
63
|
logger.info("QuickCall: authenticated")
|
mcp_server/tools/auth_tools.py
CHANGED
|
@@ -320,7 +320,7 @@ def create_auth_tools(mcp: FastMCP):
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
@mcp.tool(tags={"auth", "slack", "quickcall"})
|
|
323
|
-
def connect_slack(open_browser: bool = True) -> Dict[str, Any]:
|
|
323
|
+
def connect_slack(open_browser: bool = True, force: bool = False) -> Dict[str, Any]:
|
|
324
324
|
"""
|
|
325
325
|
Connect Slack to your QuickCall account.
|
|
326
326
|
|
|
@@ -330,6 +330,7 @@ def create_auth_tools(mcp: FastMCP):
|
|
|
330
330
|
|
|
331
331
|
Args:
|
|
332
332
|
open_browser: Automatically open the install URL in browser (default: True)
|
|
333
|
+
force: Force re-authorization even if already connected (use to get new permissions)
|
|
333
334
|
|
|
334
335
|
Returns:
|
|
335
336
|
Install URL and instructions
|
|
@@ -353,18 +354,20 @@ def create_auth_tools(mcp: FastMCP):
|
|
|
353
354
|
|
|
354
355
|
try:
|
|
355
356
|
with httpx.Client(timeout=30.0) as client:
|
|
357
|
+
params = {"force": "true"} if force else {}
|
|
356
358
|
response = client.get(
|
|
357
359
|
f"{QUICKCALL_API_URL}/api/cli/slack/install-url",
|
|
360
|
+
params=params,
|
|
358
361
|
headers={"Authorization": f"Bearer {stored.device_token}"},
|
|
359
362
|
)
|
|
360
363
|
response.raise_for_status()
|
|
361
364
|
data = response.json()
|
|
362
365
|
|
|
363
|
-
if data.get("already_connected"):
|
|
366
|
+
if data.get("already_connected") and not force:
|
|
364
367
|
return {
|
|
365
368
|
"status": "already_connected",
|
|
366
369
|
"message": f"Slack is already connected (workspace: {data.get('team_name')})",
|
|
367
|
-
"hint": "
|
|
370
|
+
"hint": "Use force=True to re-authorize with updated permissions.",
|
|
368
371
|
}
|
|
369
372
|
|
|
370
373
|
install_url = data.get("install_url")
|
|
@@ -409,3 +412,84 @@ def create_auth_tools(mcp: FastMCP):
|
|
|
409
412
|
"status": "error",
|
|
410
413
|
"message": f"Failed to connect Slack: {e}",
|
|
411
414
|
}
|
|
415
|
+
|
|
416
|
+
@mcp.tool(tags={"auth", "slack", "quickcall"})
|
|
417
|
+
def reconnect_slack(open_browser: bool = True) -> Dict[str, Any]:
|
|
418
|
+
"""
|
|
419
|
+
Reconnect Slack to get updated permissions.
|
|
420
|
+
|
|
421
|
+
Use this when the Slack app has new scopes/permissions that
|
|
422
|
+
require re-authorization. This forces a fresh OAuth flow
|
|
423
|
+
even if Slack is already connected.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
open_browser: Automatically open the OAuth URL in browser (default: True)
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
OAuth URL and instructions
|
|
430
|
+
"""
|
|
431
|
+
store = get_credential_store()
|
|
432
|
+
|
|
433
|
+
if not store.is_authenticated():
|
|
434
|
+
return {
|
|
435
|
+
"status": "error",
|
|
436
|
+
"message": "Not connected to QuickCall",
|
|
437
|
+
"hint": "Run connect_quickcall first to authenticate.",
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
stored = store.get_stored_credentials()
|
|
441
|
+
if not stored:
|
|
442
|
+
return {
|
|
443
|
+
"status": "error",
|
|
444
|
+
"message": "No stored credentials found",
|
|
445
|
+
"hint": "Run connect_quickcall to authenticate.",
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
with httpx.Client(timeout=30.0) as client:
|
|
450
|
+
# Force reconnect by passing force=true
|
|
451
|
+
response = client.get(
|
|
452
|
+
f"{QUICKCALL_API_URL}/api/cli/slack/install-url",
|
|
453
|
+
params={"force": "true"},
|
|
454
|
+
headers={"Authorization": f"Bearer {stored.device_token}"},
|
|
455
|
+
)
|
|
456
|
+
response.raise_for_status()
|
|
457
|
+
data = response.json()
|
|
458
|
+
|
|
459
|
+
install_url = data.get("install_url")
|
|
460
|
+
|
|
461
|
+
if open_browser and install_url:
|
|
462
|
+
try:
|
|
463
|
+
webbrowser.open(install_url)
|
|
464
|
+
except Exception as e:
|
|
465
|
+
logger.warning(f"Failed to open browser: {e}")
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
"status": "pending",
|
|
469
|
+
"message": "Please re-authorize Slack in your browser to get updated permissions.",
|
|
470
|
+
"install_url": install_url,
|
|
471
|
+
"instructions": [
|
|
472
|
+
f"1. Open this URL: {install_url}",
|
|
473
|
+
"2. Select your Slack workspace",
|
|
474
|
+
"3. Review the NEW permissions and click 'Allow'",
|
|
475
|
+
],
|
|
476
|
+
"hint": "This will update your Slack permissions with any new scopes.",
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
except httpx.HTTPStatusError as e:
|
|
480
|
+
if e.response.status_code == 401:
|
|
481
|
+
return {
|
|
482
|
+
"status": "error",
|
|
483
|
+
"message": "Session expired. Please reconnect.",
|
|
484
|
+
"hint": "Run disconnect_quickcall then connect_quickcall again.",
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
"status": "error",
|
|
488
|
+
"message": f"API error: {e.response.status_code}",
|
|
489
|
+
}
|
|
490
|
+
except Exception as e:
|
|
491
|
+
logger.error(f"Failed to get Slack install URL: {e}")
|
|
492
|
+
return {
|
|
493
|
+
"status": "error",
|
|
494
|
+
"message": f"Failed to reconnect Slack: {e}",
|
|
495
|
+
}
|
mcp_server/tools/slack_tools.py
CHANGED
|
@@ -6,6 +6,7 @@ Connect using connect_quickcall tool first.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from typing import Optional
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
9
10
|
import logging
|
|
10
11
|
|
|
11
12
|
from fastmcp import FastMCP
|
|
@@ -17,9 +18,14 @@ from mcp_server.api_clients.slack_client import SlackClient, SlackAPIError
|
|
|
17
18
|
|
|
18
19
|
logger = logging.getLogger(__name__)
|
|
19
20
|
|
|
21
|
+
# Module-level client cache (keyed by token hash for security)
|
|
22
|
+
_client_cache: Optional[tuple[str, SlackClient]] = None
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
def _get_client() -> SlackClient:
|
|
22
|
-
"""Get the Slack client, raising error if not configured."""
|
|
26
|
+
"""Get the Slack client, raising error if not configured. Uses cached client."""
|
|
27
|
+
global _client_cache
|
|
28
|
+
|
|
23
29
|
store = get_credential_store()
|
|
24
30
|
|
|
25
31
|
if not store.is_authenticated():
|
|
@@ -43,8 +49,15 @@ def _get_client() -> SlackClient:
|
|
|
43
49
|
"Try reconnecting Slack at quickcall.dev/assistant."
|
|
44
50
|
)
|
|
45
51
|
|
|
46
|
-
#
|
|
47
|
-
|
|
52
|
+
# Return cached client if token matches
|
|
53
|
+
token_hash = hash(creds.slack_bot_token)
|
|
54
|
+
if _client_cache and _client_cache[0] == token_hash:
|
|
55
|
+
return _client_cache[1]
|
|
56
|
+
|
|
57
|
+
# Create new client and cache it
|
|
58
|
+
client = SlackClient(bot_token=creds.slack_bot_token)
|
|
59
|
+
_client_cache = (token_hash, client)
|
|
60
|
+
return client
|
|
48
61
|
|
|
49
62
|
|
|
50
63
|
def create_slack_tools(mcp: FastMCP) -> None:
|
|
@@ -201,3 +214,146 @@ def create_slack_tools(mcp: FastMCP) -> None:
|
|
|
201
214
|
"connected": False,
|
|
202
215
|
"error": str(e),
|
|
203
216
|
}
|
|
217
|
+
|
|
218
|
+
@mcp.tool(tags={"slack", "messages", "history"})
|
|
219
|
+
def read_slack_messages(
|
|
220
|
+
channel: str = Field(
|
|
221
|
+
...,
|
|
222
|
+
description="Channel name (with or without #) or channel ID",
|
|
223
|
+
),
|
|
224
|
+
days: int = Field(
|
|
225
|
+
default=1,
|
|
226
|
+
description="Number of days to look back (default: 1)",
|
|
227
|
+
),
|
|
228
|
+
limit: int = Field(
|
|
229
|
+
default=50,
|
|
230
|
+
description="Maximum messages to return (default: 50)",
|
|
231
|
+
),
|
|
232
|
+
include_threads: bool = Field(
|
|
233
|
+
default=True,
|
|
234
|
+
description="Automatically fetch thread replies for messages with threads (default: true)",
|
|
235
|
+
),
|
|
236
|
+
) -> dict:
|
|
237
|
+
"""
|
|
238
|
+
Read messages from a Slack channel.
|
|
239
|
+
|
|
240
|
+
Returns messages from the specified channel within the date range.
|
|
241
|
+
Bot must be a member of the channel.
|
|
242
|
+
Requires QuickCall authentication with Slack connected.
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
client = _get_client()
|
|
246
|
+
|
|
247
|
+
# Calculate oldest timestamp
|
|
248
|
+
oldest_dt = datetime.now(timezone.utc) - timedelta(days=days)
|
|
249
|
+
oldest_ts = str(oldest_dt.timestamp())
|
|
250
|
+
|
|
251
|
+
messages = client.get_channel_messages(
|
|
252
|
+
channel=channel,
|
|
253
|
+
oldest=oldest_ts,
|
|
254
|
+
limit=limit,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
result_messages = []
|
|
258
|
+
for msg in messages:
|
|
259
|
+
msg_data = {
|
|
260
|
+
"ts": msg.ts,
|
|
261
|
+
"user": msg.user_name or msg.user,
|
|
262
|
+
"text": msg.text,
|
|
263
|
+
"has_thread": msg.has_thread,
|
|
264
|
+
"reply_count": msg.reply_count,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Fetch thread replies if message has a thread and include_threads is True
|
|
268
|
+
if include_threads and msg.has_thread and msg.reply_count > 0:
|
|
269
|
+
try:
|
|
270
|
+
thread_replies = client.get_thread_replies(
|
|
271
|
+
channel=channel,
|
|
272
|
+
thread_ts=msg.ts,
|
|
273
|
+
limit=50,
|
|
274
|
+
)
|
|
275
|
+
# Skip first message (it's the parent) and add replies
|
|
276
|
+
msg_data["replies"] = [
|
|
277
|
+
{
|
|
278
|
+
"ts": reply.ts,
|
|
279
|
+
"user": reply.user_name or reply.user,
|
|
280
|
+
"text": reply.text,
|
|
281
|
+
}
|
|
282
|
+
for reply in thread_replies
|
|
283
|
+
if reply.ts != msg.ts # Skip parent message
|
|
284
|
+
]
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.warning(f"Failed to fetch thread {msg.ts}: {e}")
|
|
287
|
+
msg_data["replies"] = []
|
|
288
|
+
|
|
289
|
+
result_messages.append(msg_data)
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"count": len(result_messages),
|
|
293
|
+
"channel": channel,
|
|
294
|
+
"days": days,
|
|
295
|
+
"messages": result_messages,
|
|
296
|
+
}
|
|
297
|
+
except ToolError:
|
|
298
|
+
raise
|
|
299
|
+
except SlackAPIError as e:
|
|
300
|
+
raise ToolError(str(e))
|
|
301
|
+
except ValueError as e:
|
|
302
|
+
raise ToolError(str(e))
|
|
303
|
+
except Exception as e:
|
|
304
|
+
raise ToolError(f"Failed to read Slack messages: {str(e)}")
|
|
305
|
+
|
|
306
|
+
@mcp.tool(tags={"slack", "messages", "threads"})
|
|
307
|
+
def read_slack_thread(
|
|
308
|
+
channel: str = Field(
|
|
309
|
+
...,
|
|
310
|
+
description="Channel name (with or without #) or channel ID",
|
|
311
|
+
),
|
|
312
|
+
thread_ts: str = Field(
|
|
313
|
+
...,
|
|
314
|
+
description="Thread timestamp (ts) of the parent message",
|
|
315
|
+
),
|
|
316
|
+
limit: int = Field(
|
|
317
|
+
default=50,
|
|
318
|
+
description="Maximum replies to return (default: 50)",
|
|
319
|
+
),
|
|
320
|
+
) -> dict:
|
|
321
|
+
"""
|
|
322
|
+
Read replies in a Slack thread.
|
|
323
|
+
|
|
324
|
+
Returns all replies in the specified thread.
|
|
325
|
+
Use the 'ts' from read_slack_messages to get thread_ts.
|
|
326
|
+
Bot must be a member of the channel.
|
|
327
|
+
Requires QuickCall authentication with Slack connected.
|
|
328
|
+
"""
|
|
329
|
+
try:
|
|
330
|
+
client = _get_client()
|
|
331
|
+
|
|
332
|
+
messages = client.get_thread_replies(
|
|
333
|
+
channel=channel,
|
|
334
|
+
thread_ts=thread_ts,
|
|
335
|
+
limit=limit,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
"count": len(messages),
|
|
340
|
+
"channel": channel,
|
|
341
|
+
"thread_ts": thread_ts,
|
|
342
|
+
"messages": [
|
|
343
|
+
{
|
|
344
|
+
"ts": msg.ts,
|
|
345
|
+
"user": msg.user_name or msg.user,
|
|
346
|
+
"text": msg.text,
|
|
347
|
+
"is_parent": msg.ts == thread_ts,
|
|
348
|
+
}
|
|
349
|
+
for msg in messages
|
|
350
|
+
],
|
|
351
|
+
}
|
|
352
|
+
except ToolError:
|
|
353
|
+
raise
|
|
354
|
+
except SlackAPIError as e:
|
|
355
|
+
raise ToolError(str(e))
|
|
356
|
+
except ValueError as e:
|
|
357
|
+
raise ToolError(str(e))
|
|
358
|
+
except Exception as e:
|
|
359
|
+
raise ToolError(f"Failed to read Slack thread: {str(e)}")
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quickcall-integrations
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
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
|
|
7
7
|
Requires-Dist: httpx>=0.28.0
|
|
8
8
|
Requires-Dist: pydantic>=2.11.7
|
|
9
9
|
Requires-Dist: pygithub>=2.8.1
|
|
10
|
+
Requires-Dist: rapidfuzz>=3.0.0
|
|
10
11
|
Description-Content-Type: text/markdown
|
|
11
12
|
|
|
12
13
|
<p align="center">
|
|
@@ -34,11 +35,69 @@ Description-Content-Type: text/markdown
|
|
|
34
35
|
|
|
35
36
|
---
|
|
36
37
|
|
|
38
|
+
## Capabilities
|
|
39
|
+
|
|
40
|
+
- **Get standup updates** from git history (commits, diffs, stats)
|
|
41
|
+
- **List PRs, commits, branches** from GitHub repos
|
|
42
|
+
- **Read & send Slack messages** with auto thread fetching
|
|
43
|
+
- **Fuzzy channel matching** - say "no sleep dev" and it finds "no-sleep-dev-channel"
|
|
44
|
+
- **Summarize Slack channels** - get key discussions from last N days
|
|
45
|
+
|
|
37
46
|
## Integrations
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
| Integration | Features | Auth Required |
|
|
49
|
+
|-------------|----------|---------------|
|
|
50
|
+
| **Git** | Commits, diffs, standup summaries | No |
|
|
51
|
+
| **GitHub** | Repos, PRs, commits, branches | Yes |
|
|
52
|
+
| **Slack** | Read/send messages, threads, channels | Yes |
|
|
53
|
+
|
|
54
|
+
<details>
|
|
55
|
+
<summary><strong>Available Tools (23)</strong></summary>
|
|
56
|
+
|
|
57
|
+
### Git
|
|
58
|
+
| Tool | Description |
|
|
59
|
+
|------|-------------|
|
|
60
|
+
| `get_updates` | Get git commits, diff stats, and uncommitted changes |
|
|
61
|
+
|
|
62
|
+
### GitHub
|
|
63
|
+
| Tool | Description |
|
|
64
|
+
|------|-------------|
|
|
65
|
+
| `list_repos` | List accessible repositories |
|
|
66
|
+
| `list_prs` | List pull requests (open/closed/all) |
|
|
67
|
+
| `get_pr` | Get PR details (title, description, files changed) |
|
|
68
|
+
| `list_commits` | List commits with optional filters |
|
|
69
|
+
| `get_commit` | Get commit details (message, stats, files) |
|
|
70
|
+
| `list_branches` | List repository branches |
|
|
71
|
+
| `check_github_connection` | Verify GitHub connection |
|
|
72
|
+
|
|
73
|
+
### Slack
|
|
74
|
+
| Tool | Description |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| `list_slack_channels` | List channels bot has access to |
|
|
77
|
+
| `send_slack_message` | Send message to a channel |
|
|
78
|
+
| `read_slack_messages` | Read messages with threads auto-fetched |
|
|
79
|
+
| `read_slack_thread` | Read replies in a thread |
|
|
80
|
+
| `list_slack_users` | List workspace users |
|
|
81
|
+
| `check_slack_connection` | Verify Slack connection |
|
|
82
|
+
| `reconnect_slack` | Re-authorize to get new permissions |
|
|
83
|
+
|
|
84
|
+
### Auth
|
|
85
|
+
| Tool | Description |
|
|
86
|
+
|------|-------------|
|
|
87
|
+
| `connect_quickcall` | Start device flow authentication |
|
|
88
|
+
| `check_quickcall_status` | Check connection status |
|
|
89
|
+
| `disconnect_quickcall` | Remove local credentials |
|
|
90
|
+
| `connect_github` | Install GitHub App |
|
|
91
|
+
| `connect_slack` | Authorize Slack App |
|
|
92
|
+
|
|
93
|
+
### Utility
|
|
94
|
+
| Tool | Description |
|
|
95
|
+
|------|-------------|
|
|
96
|
+
| `get_current_datetime` | Get current UTC datetime |
|
|
97
|
+
| `calculate_date_range` | Calculate date range for queries |
|
|
98
|
+
| `calculate_date_offset` | Add/subtract time from a date |
|
|
99
|
+
|
|
100
|
+
</details>
|
|
42
101
|
|
|
43
102
|
## Install
|
|
44
103
|
|
|
@@ -113,14 +172,45 @@ Credentials are stored locally in `~/.quickcall/credentials.json`.
|
|
|
113
172
|
| `/quickcall:status` | Show connection status |
|
|
114
173
|
| `/quickcall:updates` | Get git updates (default: 1 day) |
|
|
115
174
|
| `/quickcall:updates 7d` | Get updates for last 7 days |
|
|
175
|
+
| `/quickcall:slack-summary` | Summarize Slack messages (default: 1 day) |
|
|
176
|
+
| `/quickcall:slack-summary 7d` | Summarize last 7 days |
|
|
116
177
|
|
|
117
178
|
### Cursor / Other IDEs
|
|
118
179
|
|
|
119
|
-
Ask the AI naturally
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
180
|
+
Ask the AI naturally - see examples below.
|
|
181
|
+
|
|
182
|
+
## Example Prompts
|
|
183
|
+
|
|
184
|
+
### Git
|
|
185
|
+
```
|
|
186
|
+
What did I work on today?
|
|
187
|
+
Give me a standup summary for the last 3 days
|
|
188
|
+
What changes are uncommitted?
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### GitHub
|
|
192
|
+
```
|
|
193
|
+
List my repos
|
|
194
|
+
Show open PRs on [repo-name]
|
|
195
|
+
What commits were made this week?
|
|
196
|
+
Get details of PR #123
|
|
197
|
+
List branches on [repo-name]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Slack
|
|
201
|
+
```
|
|
202
|
+
Send "Build completed" to #deployments
|
|
203
|
+
What messages were posted in dev channel today?
|
|
204
|
+
Read messages from no sleep dev (fuzzy matches "no-sleep-dev-channel")
|
|
205
|
+
Summarize what was discussed in #engineering this week
|
|
206
|
+
List channels I have access to
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Combined
|
|
210
|
+
```
|
|
211
|
+
List open PRs on [repo] and send titles to #updates channel
|
|
212
|
+
What did I work on this week? Send summary to #standup
|
|
213
|
+
```
|
|
124
214
|
|
|
125
215
|
## Troubleshooting
|
|
126
216
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
mcp_server/__init__.py,sha256=UJBr5BLG_aU2S4s2fEbRBZYd7GUWDVejxBpqezNBo8Q,98
|
|
2
|
+
mcp_server/server.py,sha256=zGrrYwp7H24pJAAGAVkHDk7Y6IydOR_wo5hIL-e6_50,3001
|
|
3
|
+
mcp_server/api_clients/__init__.py,sha256=kOG5_sxIVpAx_tvf1nq_P0QCkqojAVidRE-wenLS-Wc,207
|
|
4
|
+
mcp_server/api_clients/github_client.py,sha256=KnF0hZ8ThBSmUVF9sgviMk5hrUe6GAgmQXY-EkOPwsM,14474
|
|
5
|
+
mcp_server/api_clients/slack_client.py,sha256=w3rcGghttfYw8Ird2beNo2LEYLc3rCTbUKMH4X7QQuQ,16447
|
|
6
|
+
mcp_server/auth/__init__.py,sha256=YQpDPH5itIaBuEm0AtwNCHxTX4L5dLutTximVamsItw,552
|
|
7
|
+
mcp_server/auth/credentials.py,sha256=OCPs_4DcQ1zHEBgkcPDNCHVFFO36Xe6_QBx_5Jn2xgk,9379
|
|
8
|
+
mcp_server/auth/device_flow.py,sha256=NXNWHzd-CA4dlhEVCgUhwfpe9TpMKpLSJuyFCh70xKs,8371
|
|
9
|
+
mcp_server/resources/__init__.py,sha256=JrMa3Kf-DmeCB4GwVNfmfw9OGnxF9pJJxCw9Y7u7ujQ,35
|
|
10
|
+
mcp_server/resources/slack_resources.py,sha256=b_CPxAicwkF3PsBXIat4QoLbDUHM2g_iPzgzvVpwjaw,1687
|
|
11
|
+
mcp_server/tools/__init__.py,sha256=vIR2ujAaTXm2DgpTsVNz3brI4G34p-Jeg44Qe0uvWc0,405
|
|
12
|
+
mcp_server/tools/auth_tools.py,sha256=yev9UZi-i842JPx_9IgGf7pWChEQzSXRiICsHo46s9Q,17853
|
|
13
|
+
mcp_server/tools/git_tools.py,sha256=5cZfngkP1wHNYUvGtLFcMjS7bhrFzxAC_TPz0h3CUB0,7691
|
|
14
|
+
mcp_server/tools/github_tools.py,sha256=GomR88SByAbdi4VHk1vaUNp29hwWEIY6cX1t9QoMDOU,10972
|
|
15
|
+
mcp_server/tools/slack_tools.py,sha256=-HVE_x3Z1KMeYGi1xhyppEwz5ZF-I-ZD0-Up8yBeoYE,11796
|
|
16
|
+
mcp_server/tools/utility_tools.py,sha256=1WiOpJivu6Ug9OLajm77lzsmFfBPgWHs8e1hNCEX_Aw,3359
|
|
17
|
+
quickcall_integrations-0.1.8.dist-info/METADATA,sha256=MK4ehZKL17rzpQs5Rmun3y-KN8L5FNu8c9w-Cde06wo,6576
|
|
18
|
+
quickcall_integrations-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
19
|
+
quickcall_integrations-0.1.8.dist-info/entry_points.txt,sha256=kkcunmJUzncYvQ1rOR35V2LPm2HcFTKzdI2l3n7NwiM,66
|
|
20
|
+
quickcall_integrations-0.1.8.dist-info/RECORD,,
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
mcp_server/__init__.py,sha256=wAVZ0eHQoGovs-66UH9-kRkcv37bprVEUeinyUFS_KI,98
|
|
2
|
-
mcp_server/server.py,sha256=9Ojv5DoQrCeyC6lDD3keh9BuLyNKB6mb6FUslP6z0O8,2839
|
|
3
|
-
mcp_server/api_clients/__init__.py,sha256=kOG5_sxIVpAx_tvf1nq_P0QCkqojAVidRE-wenLS-Wc,207
|
|
4
|
-
mcp_server/api_clients/github_client.py,sha256=Wj326ImYI11eFyItP5HQ4TD7aQ4nC6WAR12ZbneBiAQ,13569
|
|
5
|
-
mcp_server/api_clients/slack_client.py,sha256=Tby3vkPo-yN38Egb6Cj7MQk6Ul3DV4BOp5nVWScR4cw,10424
|
|
6
|
-
mcp_server/auth/__init__.py,sha256=YQpDPH5itIaBuEm0AtwNCHxTX4L5dLutTximVamsItw,552
|
|
7
|
-
mcp_server/auth/credentials.py,sha256=OCPs_4DcQ1zHEBgkcPDNCHVFFO36Xe6_QBx_5Jn2xgk,9379
|
|
8
|
-
mcp_server/auth/device_flow.py,sha256=NXNWHzd-CA4dlhEVCgUhwfpe9TpMKpLSJuyFCh70xKs,8371
|
|
9
|
-
mcp_server/tools/__init__.py,sha256=vIR2ujAaTXm2DgpTsVNz3brI4G34p-Jeg44Qe0uvWc0,405
|
|
10
|
-
mcp_server/tools/auth_tools.py,sha256=wuhEucxeTT08DWT1TCxoYrEa6Jy2MIpOlXBxtCeYEXQ,14586
|
|
11
|
-
mcp_server/tools/git_tools.py,sha256=5cZfngkP1wHNYUvGtLFcMjS7bhrFzxAC_TPz0h3CUB0,7691
|
|
12
|
-
mcp_server/tools/github_tools.py,sha256=GomR88SByAbdi4VHk1vaUNp29hwWEIY6cX1t9QoMDOU,10972
|
|
13
|
-
mcp_server/tools/slack_tools.py,sha256=uXCxnzLfdi5LaM3ayVS5JT7F3MAnI6C0vB7jW0tZfjY,6303
|
|
14
|
-
mcp_server/tools/utility_tools.py,sha256=1WiOpJivu6Ug9OLajm77lzsmFfBPgWHs8e1hNCEX_Aw,3359
|
|
15
|
-
quickcall_integrations-0.1.6.dist-info/METADATA,sha256=rEkZ1sJXdfDJA39eANBjJcT7kAl29WmfJgJbc813--w,3780
|
|
16
|
-
quickcall_integrations-0.1.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
17
|
-
quickcall_integrations-0.1.6.dist-info/entry_points.txt,sha256=kkcunmJUzncYvQ1rOR35V2LPm2HcFTKzdI2l3n7NwiM,66
|
|
18
|
-
quickcall_integrations-0.1.6.dist-info/RECORD,,
|
|
File without changes
|
{quickcall_integrations-0.1.6.dist-info → quickcall_integrations-0.1.8.dist-info}/entry_points.txt
RENAMED
|
File without changes
|