Skip to content

Commit 41531e8

Browse files
author
cacapouh
committed
feat: add attachment support tools
- Add messages_with_attachments tool to search for file attachments in channels - Add get_attachment_details tool to get file metadata (thumbnails, dimensions, etc.) - Add get_attachment_content tool to retrieve text file content for RAG/AI - Add files:read scope to documentation - Add SlackToken() and ProvideHTTPClient() methods to ApiProvider Based on work started in korotovsky#130 by @FlorianOtel Closes korotovsky#88
1 parent a3d3fab commit 41531e8

File tree

4 files changed

+168
-3
lines changed

4 files changed

+168
-3
lines changed

docs/01-authentication-setup.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ Instead of using browser-based tokens (`xoxc`/`xoxd`), you can use a User OAuth
5656
- `mpim:write` - Start group direct messages with people on a user’s behalf (new since `v1.1.18`)
5757
- `users:read` - View people in a workspace.
5858
- `chat:write` - Send messages on a user’s behalf. (new since `v1.1.18`)
59-
- `search:read` - Search a workspace’s content. (new since `v1.1.18`)
59+
- `search:read` - Search a workspace's content. (new since `v1.1.18`)
60+
- `files:read` - View files shared in channels and conversations.
6061

6162
3. Install the app to your workspace
6263
4. Copy the "User OAuth Token" (starts with `xoxp-`)
@@ -84,7 +85,8 @@ To create the app from a manifest with permissions preconfigured, use the follow
8485
"mpim:write",
8586
"users:read",
8687
"chat:write",
87-
"search:read"
88+
"search:read",
89+
"files:read"
8890
]
8991
}
9092
},
@@ -101,7 +103,7 @@ To create the app from a manifest with permissions preconfigured, use the follow
101103
You can also use a Bot token instead of a User token:
102104

103105
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app
104-
2. Under "OAuth & Permissions", add Bot Token Scopes (same as User scopes above, except `search:read`)
106+
2. Under "OAuth & Permissions", add Bot Token Scopes (same as User scopes above, except `search:read`). Include `files:read` for attachment support.
105107
3. Install the app to your workspace
106108
4. Copy the "Bot User OAuth Token" (starts with `xoxb-`)
107109
5. **Important**: Bot must be invited to channels for access

pkg/handler/attachments.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io"
9+
"net/http"
810
"strings"
911

1012
"github.com/gocarina/gocsv"
@@ -246,3 +248,135 @@ func (ah *AttachmentsHandler) GetAttachmentDetailsHandler(ctx context.Context, r
246248

247249
return mcp.NewToolResultText(string(jsonBytes)), nil
248250
}
251+
252+
// maxTextFileSize is the maximum size for text file content retrieval (1MB)
253+
const maxTextFileSize = 1 * 1024 * 1024
254+
255+
// textMimeTypes are MIME types considered as text for content retrieval
256+
var textMimeTypes = map[string]bool{
257+
"text/plain": true,
258+
"text/html": true,
259+
"text/css": true,
260+
"text/csv": true,
261+
"text/markdown": true,
262+
"text/xml": true,
263+
"application/json": true,
264+
"application/xml": true,
265+
"application/javascript": true,
266+
"application/x-yaml": true,
267+
"application/x-sh": true,
268+
}
269+
270+
// isTextFile checks if the file is a text file based on MIME type or file extension
271+
func isTextFile(mimeType, fileType string) bool {
272+
if textMimeTypes[mimeType] {
273+
return true
274+
}
275+
// Check common text file extensions
276+
textExtensions := []string{"txt", "md", "json", "xml", "yaml", "yml", "csv", "log", "sh", "py", "go", "js", "ts", "html", "css", "sql"}
277+
for _, ext := range textExtensions {
278+
if fileType == ext {
279+
return true
280+
}
281+
}
282+
return false
283+
}
284+
285+
// GetAttachmentContentHandler retrieves the content of a text file attachment
286+
func (ah *AttachmentsHandler) GetAttachmentContentHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
287+
ah.logger.Debug("GetAttachmentContentHandler called", zap.Any("params", request.Params))
288+
289+
if authenticated, err := auth.IsAuthenticated(ctx, ah.apiProvider.ServerTransport(), ah.logger); !authenticated {
290+
ah.logger.Error("Authentication failed", zap.Error(err))
291+
return nil, err
292+
}
293+
294+
fileID := request.GetString("file_id", "")
295+
if fileID == "" {
296+
return nil, errors.New("file_id is required")
297+
}
298+
299+
// Get file info first
300+
file, _, _, err := ah.apiProvider.Slack().GetFileInfoContext(ctx, fileID, 1, 1)
301+
if err != nil {
302+
ah.logger.Error("Failed to get file info", zap.String("file_id", fileID), zap.Error(err))
303+
return nil, err
304+
}
305+
306+
// Check if it's a text file
307+
if !isTextFile(file.Mimetype, file.Filetype) {
308+
return nil, fmt.Errorf("file %q is not a text file (type: %s). Use get_attachment_details for binary files", file.Name, file.Mimetype)
309+
}
310+
311+
// Check file size
312+
if file.Size > maxTextFileSize {
313+
return nil, fmt.Errorf("file %q is too large (%d bytes). Maximum size for content retrieval is %d bytes", file.Name, file.Size, maxTextFileSize)
314+
}
315+
316+
// Download the file content
317+
content, err := ah.downloadFileContent(ctx, file.URLPrivate)
318+
if err != nil {
319+
ah.logger.Error("Failed to download file content", zap.String("file_id", fileID), zap.Error(err))
320+
return nil, err
321+
}
322+
323+
// Build response with metadata and content
324+
response := map[string]interface{}{
325+
"id": file.ID,
326+
"name": file.Name,
327+
"title": file.Title,
328+
"mimeType": file.Mimetype,
329+
"fileType": file.Filetype,
330+
"size": file.Size,
331+
"content": content,
332+
}
333+
334+
jsonBytes, err := json.MarshalIndent(response, "", " ")
335+
if err != nil {
336+
ah.logger.Error("Failed to marshal response to JSON", zap.Error(err))
337+
return nil, err
338+
}
339+
340+
return mcp.NewToolResultText(string(jsonBytes)), nil
341+
}
342+
343+
// downloadFileContent downloads the content of a file from Slack
344+
func (ah *AttachmentsHandler) downloadFileContent(ctx context.Context, url string) (string, error) {
345+
// Get HTTP client from provider
346+
httpClient := ah.apiProvider.ProvideHTTPClient()
347+
348+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
349+
if err != nil {
350+
return "", fmt.Errorf("failed to create request: %w", err)
351+
}
352+
353+
// Add Authorization header for authenticated file download
354+
// This is required for bot tokens (xoxb-) and user tokens (xoxp-)
355+
token := ah.apiProvider.SlackToken()
356+
if token != "" {
357+
req.Header.Set("Authorization", "Bearer "+token)
358+
}
359+
360+
resp, err := httpClient.Do(req)
361+
if err != nil {
362+
return "", fmt.Errorf("failed to download file: %w", err)
363+
}
364+
defer resp.Body.Close()
365+
366+
if resp.StatusCode != http.StatusOK {
367+
return "", fmt.Errorf("failed to download file: HTTP %d", resp.StatusCode)
368+
}
369+
370+
// Read with size limit
371+
limitedReader := io.LimitReader(resp.Body, maxTextFileSize+1)
372+
body, err := io.ReadAll(limitedReader)
373+
if err != nil {
374+
return "", fmt.Errorf("failed to read file content: %w", err)
375+
}
376+
377+
if len(body) > maxTextFileSize {
378+
return "", fmt.Errorf("file content exceeds maximum size of %d bytes", maxTextFileSize)
379+
}
380+
381+
return string(body), nil
382+
}

pkg/provider/api.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"io/ioutil"
8+
"net/http"
89
"os"
910
"path/filepath"
1011
"strings"
@@ -774,6 +775,24 @@ func (ap *ApiProvider) IsBotToken() bool {
774775
return ok && client != nil && client.IsBotToken()
775776
}
776777

778+
// ProvideHTTPClient returns the HTTP client used for Slack API requests
779+
func (ap *ApiProvider) ProvideHTTPClient() *http.Client {
780+
client, ok := ap.client.(*MCPSlackClient)
781+
if !ok || client == nil || client.authProvider == nil {
782+
return http.DefaultClient
783+
}
784+
return transport.ProvideHTTPClient(client.authProvider.Cookies(), ap.logger)
785+
}
786+
787+
// SlackToken returns the Slack API token for authenticated requests
788+
func (ap *ApiProvider) SlackToken() string {
789+
client, ok := ap.client.(*MCPSlackClient)
790+
if !ok || client == nil || client.authProvider == nil {
791+
return ""
792+
}
793+
return client.authProvider.SlackToken()
794+
}
795+
777796
func mapChannel(
778797
id, name, nameNormalized, topic, purpose, user string,
779798
members []string,

pkg/server/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer
177177
),
178178
), attachmentsHandler.GetAttachmentDetailsHandler)
179179

180+
s.AddTool(mcp.NewTool("get_attachment_content",
181+
mcp.WithDescription("Get the content of a text file attachment. Only works for text files (txt, json, md, yaml, csv, etc.) under 1MB. Returns the file content directly for RAG/AI processing."),
182+
mcp.WithTitleAnnotation("Get Attachment Content"),
183+
mcp.WithReadOnlyHintAnnotation(true),
184+
mcp.WithString("file_id",
185+
mcp.Required(),
186+
mcp.Description("The ID of the file attachment to get content for (format: Fxxxxxxxxxx)."),
187+
),
188+
), attachmentsHandler.GetAttachmentContentHandler)
189+
180190
s.AddTool(mcp.NewTool("channels_list",
181191
mcp.WithDescription("Get list of channels"),
182192
mcp.WithTitleAnnotation("List Channels"),

0 commit comments

Comments
 (0)