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 CHANGED
@@ -3,4 +3,4 @@ MCP Server for QuickCall
3
3
  GitHub integration tools for AI assistant
4
4
  """
5
5
 
6
- __version__ = "0.3.4"
6
+ __version__ = "0.1.8"
@@ -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
- self.gh.get_user().login
131
- return True
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
- """Get the username of the authenticated user."""
137
- return self.gh.get_user().login
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": limit, "exclude_archived": True},
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
- return channels
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
- if ch.name.lower() == channel_name:
227
- return ch.id
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")
@@ -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": "You can use Slack tools like list_channels, send_message, etc.",
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
+ }
@@ -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
- # Create client with fresh token
47
- return SlackClient(bot_token=creds.slack_bot_token)
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.6
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
- - **Git** - commits, diffs, code changes (always available)
40
- - **GitHub** - repos, PRs, commits, branches (requires QuickCall account)
41
- - **Slack** - send messages, list channels/users (requires QuickCall account)
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
- - "What did I work on today?"
121
- - "Show me my open PRs"
122
- - "List my GitHub repos"
123
- - "Send a message to #general on Slack"
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,,