diff --git a/.gitignore b/.gitignore index 13c6c35..6e6fa0b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .channels_cache_v2.json /extension.dxt/server/slack-mcp-server-* /extension.dxt/server/index.js +/build/slack-mcp-server /build/slack-mcp-server-* /build/slack-mcp-server.dxt /npm/slack-mcp-server-*/bin/slack-mcp-server-* diff --git a/README.md b/README.md index c34405a..2b29000 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Search messages in a public channel, private channel, or direct message (DM, or - `filter_in_im_or_mpim` (string, optional): Filter messages in a direct message (DM) or multi-person direct message (MPIM) conversation by its ID or name. Example: `D1234567890` or `@username_dm`. If not provided, all DMs and MPIMs will be searched. - `filter_users_with` (string, optional): Filter messages with a specific user by their ID or display name in threads and DMs. Example: `U1234567890` or `@username`. If not provided, all threads and DMs will be searched. - `filter_users_from` (string, optional): Filter messages from a specific user by their ID or display name. Example: `U1234567890` or `@username`. If not provided, all users will be searched. + - `filter_mentions_user` (string, optional): Filter messages that mention a specific user. Example: `U1234567890` or `@username`. Use `@me` to find messages mentioning you. Perfect for daily summaries! - `filter_date_before` (string, optional): Filter messages sent before a specific date in format `YYYY-MM-DD`. Example: `2023-10-01`, `July`, `Yesterday` or `Today`. If not provided, all dates will be searched. - `filter_date_after` (string, optional): Filter messages sent after a specific date in format `YYYY-MM-DD`. Example: `2023-10-01`, `July`, `Yesterday` or `Today`. If not provided, all dates will be searched. - `filter_date_on` (string, optional): Filter messages sent on a specific date in format `YYYY-MM-DD`. Example: `2023-10-01`, `July`, `Yesterday` or `Today`. If not provided, all dates will be searched. @@ -74,7 +75,15 @@ Search messages in a public channel, private channel, or direct message (DM, or - `cursor` (string, default: ""): Cursor for pagination. Use the value of the last row and column in the response as next_cursor field returned from the previous request. - `limit` (number, default: 20): The maximum number of items to return. Must be an integer between 1 and 100. -### 5. channels_list: +### 5. users_conversations: +Get list of all conversations (channels, DMs, group DMs) that you are a member of +- **Parameters:** None required +- **Returns:** CSV with all your conversations including: + - Conversation ID, name, type (channel/private/DM/group DM) + - Member count, topic, purpose + - Perfect for getting a complete list of your workspace context! + +### 6. channels_list: Get list of channels - **Parameters:** - `channel_types` (string, required): Comma-separated channel types. Allowed values: `mpim`, `im`, `public_channel`, `private_channel`. Example: `public_channel,private_channel,im` @@ -82,6 +91,37 @@ Get list of channels - `limit` (number, default: 100): The maximum number of items to return. Must be an integer between 1 and 1000 (maximum 999). - `cursor` (string, optional): Cursor for pagination. Use the value of the last row and column in the response as next_cursor field returned from the previous request. +### 7. canvases_create: +Create a new canvas with markdown content +- **Parameters:** + - `title` (string, optional): Title of the canvas. If not provided, canvas will be created untitled. + - `content` (string, required): Markdown content for the canvas. Supports headings, lists, code blocks, tables, links, mentions (@user, #channel), and emojis. +- **Returns:** `canvas_id` that can be used for further operations + +### 8. canvases_edit: +Edit an existing canvas by adding, replacing, or deleting content +- **Parameters:** + - `canvas_id` (string, required): ID of the canvas to edit (e.g., F1234567890) + - `operation` (string, default: "insert_at_end"): Type of edit operation. Allowed values: `insert_at_start`, `insert_at_end`, `insert_before`, `insert_after`, `replace`, `delete` + - `content` (string, required): Markdown content to add or use for replacement. Supports headings, lists, code blocks, tables, links, mentions, and emojis. + - `section_id` (string, optional): Section ID for targeted operations (required for: `insert_before`, `insert_after`, `delete`). Use `canvases_sections_lookup` to find section IDs. + +### 9. canvases_sections_lookup: +Look up section IDs in a canvas for targeted edits +- **Parameters:** + - `canvas_id` (string, required): ID of the canvas to search for sections (e.g., F1234567890) + - `contains_text` (string, optional): Filter sections by text content +- **Returns:** Array of section objects with IDs that can be used with `canvases_edit` + +### 10. canvases_read: +Read canvas metadata and full content +- **Parameters:** + - `canvas_id` (string, required): ID of the canvas to read (e.g., F1234567890) +- **Returns:** Canvas metadata and full markdown content including: + - **Metadata**: title, timestamps, URLs, user info, permissions + - **Content**: Full markdown content downloaded from Slack (via `url_private_download`) + - **Preview**: Text preview if available + ## Resources The Slack MCP Server exposes two special directory resources for easy access to workspace metadata: diff --git a/docs/01-authentication-setup.md b/docs/01-authentication-setup.md index 9fccb68..b6a2756 100644 --- a/docs/01-authentication-setup.md +++ b/docs/01-authentication-setup.md @@ -41,8 +41,11 @@ Instead of using browser-based tokens (`xoxc`/`xoxd`), you can use a User OAuth - `mpim:read` - View basic information about group direct messages - `mpim:write` - Start group direct messages with people on a user’s behalf (new since `v1.1.18`) - `users:read` - View people in a workspace. - - `chat:write` - Send messages on a user’s behalf. (new since `v1.1.18`) - - `search:read` - Search a workspace’s content. (new since `v1.1.18`) + - `chat:write` - Send messages on a user's behalf. (new since `v1.1.18`) + - `search:read` - Search a workspace's content. (new since `v1.1.18`) + - `canvases:write` - Create and edit canvases. (new since `v1.2.0`) + - `canvases:read` - Read canvas content. (new since `v1.2.0`) + - `files:read` - View files in a workspace. (required for reading canvas content via files.info) 3. Install the app to your workspace 4. Copy the "User OAuth Token" (starts with `xoxp-`) @@ -69,7 +72,10 @@ To create the app from a manifest with permissions preconfigured, use the follow "mpim:write", "users:read", "chat:write", - "search:read" + "search:read", + "canvases:write", + "canvases:read", + "files:read" ] } }, diff --git a/pkg/handler/canvases.go b/pkg/handler/canvases.go new file mode 100644 index 0000000..03bdb62 --- /dev/null +++ b/pkg/handler/canvases.go @@ -0,0 +1,381 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/korotovsky/slack-mcp-server/pkg/provider" + "github.com/korotovsky/slack-mcp-server/pkg/server/auth" + "github.com/mark3labs/mcp-go/mcp" + "github.com/slack-go/slack" + "go.uber.org/zap" +) + +type CanvasesHandler struct { + apiProvider *provider.ApiProvider + logger *zap.Logger +} + +func NewCanvasesHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *CanvasesHandler { + return &CanvasesHandler{ + apiProvider: apiProvider, + logger: logger, + } +} + +// CanvasesCreateHandler creates a new canvas +func (ch *CanvasesHandler) CanvasesCreateHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("CanvasesCreateHandler called", zap.Any("params", request.Params.Arguments)) + + // authentication + if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated { + ch.logger.Error("Authentication failed for canvases_create", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // provider readiness + if ready, err := ch.apiProvider.IsReady(); !ready { + ch.logger.Error("API provider not ready", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // Parse parameters + title := request.GetString("title", "") + content := request.GetString("content", "") + + if content == "" { + return mcp.NewToolResultError("content parameter is required"), nil + } + + // Create document content + documentContent := slack.DocumentContent{ + Type: "markdown", + Markdown: content, + } + + // Create canvas + canvasID, err := ch.apiProvider.Slack().CreateCanvasContext(ctx, title, documentContent) + if err != nil { + ch.logger.Error("Failed to create canvas", + zap.String("title", title), + zap.Error(err), + ) + return mcp.NewToolResultError(fmt.Sprintf("Failed to create canvas: %v", err)), nil + } + + ch.logger.Info("Canvas created successfully", + zap.String("canvas_id", canvasID), + zap.String("title", title), + ) + + // Return result + result := map[string]interface{}{ + "canvas_id": canvasID, + "title": title, + "message": "Canvas created successfully", + } + + resultJSON, _ := json.Marshal(result) + return mcp.NewToolResultText(string(resultJSON)), nil +} + +// CanvasesEditHandler edits an existing canvas +func (ch *CanvasesHandler) CanvasesEditHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("CanvasesEditHandler called", zap.Any("params", request.Params.Arguments)) + + // authentication + if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated { + ch.logger.Error("Authentication failed for canvases_edit", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // provider readiness + if ready, err := ch.apiProvider.IsReady(); !ready { + ch.logger.Error("API provider not ready", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // Parse parameters + canvasID := request.GetString("canvas_id", "") + operation := request.GetString("operation", "") + content := request.GetString("content", "") + sectionID := request.GetString("section_id", "") + + if canvasID == "" { + return mcp.NewToolResultError("canvas_id parameter is required"), nil + } + + if operation == "" { + operation = "insert_at_end" // default operation + } + + if content == "" { + return mcp.NewToolResultError("content parameter is required"), nil + } + + // Validate operation + validOperations := map[string]bool{ + "insert_at_start": true, + "insert_at_end": true, + "insert_before": true, + "insert_after": true, + "replace": true, + "delete": true, + } + + if !validOperations[operation] { + return mcp.NewToolResultError(fmt.Sprintf("Invalid operation: %s. Must be one of: insert_at_start, insert_at_end, insert_before, insert_after, replace, delete", operation)), nil + } + + // For operations that require section_id + if (operation == "insert_before" || operation == "insert_after" || operation == "delete") && sectionID == "" { + return mcp.NewToolResultError(fmt.Sprintf("section_id is required for operation: %s", operation)), nil + } + + // Create document content + documentContent := slack.DocumentContent{ + Type: "markdown", + Markdown: content, + } + + // Create canvas change + change := slack.CanvasChange{ + Operation: operation, + DocumentContent: documentContent, + } + + if sectionID != "" { + change.SectionID = sectionID + } + + // Create edit params + params := slack.EditCanvasParams{ + CanvasID: canvasID, + Changes: []slack.CanvasChange{change}, + } + + // Edit canvas + err := ch.apiProvider.Slack().EditCanvasContext(ctx, params) + if err != nil { + ch.logger.Error("Failed to edit canvas", + zap.String("canvas_id", canvasID), + zap.String("operation", operation), + zap.Error(err), + ) + return mcp.NewToolResultError(fmt.Sprintf("Failed to edit canvas: %v", err)), nil + } + + ch.logger.Info("Canvas edited successfully", + zap.String("canvas_id", canvasID), + zap.String("operation", operation), + ) + + // Return result + result := map[string]interface{}{ + "canvas_id": canvasID, + "operation": operation, + "message": "Canvas edited successfully", + } + + resultJSON, _ := json.Marshal(result) + return mcp.NewToolResultText(string(resultJSON)), nil +} + +// CanvasesSectionsLookupHandler looks up sections in a canvas +func (ch *CanvasesHandler) CanvasesSectionsLookupHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("CanvasesSectionsLookupHandler called", zap.Any("params", request.Params.Arguments)) + + // authentication + if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated { + ch.logger.Error("Authentication failed for canvases_sections_lookup", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // provider readiness + if ready, err := ch.apiProvider.IsReady(); !ready { + ch.logger.Error("API provider not ready", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // Parse parameters + canvasID := request.GetString("canvas_id", "") + containsText := request.GetString("contains_text", "") + + if canvasID == "" { + return mcp.NewToolResultError("canvas_id parameter is required"), nil + } + + // Create lookup params + params := slack.LookupCanvasSectionsParams{ + CanvasID: canvasID, + Criteria: slack.LookupCanvasSectionsCriteria{}, + } + + if containsText != "" { + params.Criteria.ContainsText = containsText + } + + // Lookup sections + sections, err := ch.apiProvider.Slack().LookupCanvasSectionsContext(ctx, params) + if err != nil { + ch.logger.Error("Failed to lookup canvas sections", + zap.String("canvas_id", canvasID), + zap.Error(err), + ) + return mcp.NewToolResultError(fmt.Sprintf("Failed to lookup canvas sections: %v", err)), nil + } + + ch.logger.Info("Canvas sections lookup completed", + zap.String("canvas_id", canvasID), + zap.Int("sections_found", len(sections)), + ) + + // Convert sections to simple format + type Section struct { + ID string `json:"id"` + } + + simpleSections := make([]Section, len(sections)) + for i, section := range sections { + simpleSections[i] = Section{ID: section.ID} + } + + // Return result + result := map[string]interface{}{ + "canvas_id": canvasID, + "sections": simpleSections, + "count": len(sections), + } + + resultJSON, _ := json.Marshal(result) + return mcp.NewToolResultText(string(resultJSON)), nil +} + +// CanvasesReadHandler reads canvas content +func (ch *CanvasesHandler) CanvasesReadHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("CanvasesReadHandler called", zap.Any("params", request.Params.Arguments)) + + // authentication + if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated { + ch.logger.Error("Authentication failed for canvases_read", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // provider readiness + if ready, err := ch.apiProvider.IsReady(); !ready { + ch.logger.Error("API provider not ready", zap.Error(err)) + return mcp.NewToolResultError(err.Error()), nil + } + + // Parse parameters + canvasID := request.GetString("canvas_id", "") + + if canvasID == "" { + return mcp.NewToolResultError("canvas_id parameter is required"), nil + } + + // Get file info (canvases are files in Slack) + file, _, _, err := ch.apiProvider.Slack().GetFileInfoContext(ctx, canvasID, 0, 0) + if err != nil { + ch.logger.Error("Failed to read canvas", + zap.String("canvas_id", canvasID), + zap.Error(err), + ) + return mcp.NewToolResultError(fmt.Sprintf("Failed to read canvas: %v", err)), nil + } + + ch.logger.Info("Canvas read successfully", + zap.String("canvas_id", canvasID), + zap.String("title", file.Title), + ) + + // Return result with canvas metadata and content + result := map[string]interface{}{ + "canvas_id": canvasID, + "title": file.Title, + "name": file.Name, + "created": file.Created, + "timestamp": file.Timestamp, + "mimetype": file.Mimetype, + "filetype": file.Filetype, + "pretty_type": file.PrettyType, + "size": file.Size, + "url": file.URLPrivate, + "permalink": file.Permalink, + "user": file.User, + "is_public": file.IsPublic, + "is_external": file.IsExternal, + "editable": file.Editable, + } + + // Add preview/content if available + if file.Preview != "" { + result["preview"] = file.Preview + } + if file.PreviewHighlight != "" { + result["preview_highlight"] = file.PreviewHighlight + } + + // Download full canvas content if URLPrivateDownload is available + if file.URLPrivateDownload != "" { + content, err := ch.downloadCanvasContent(ctx, file.URLPrivateDownload) + if err != nil { + ch.logger.Warn("Failed to download canvas content", + zap.String("canvas_id", canvasID), + zap.Error(err), + ) + result["content_error"] = fmt.Sprintf("Failed to download content: %v", err) + } else { + result["content"] = content + ch.logger.Debug("Canvas content downloaded", + zap.String("canvas_id", canvasID), + zap.Int("content_length", len(content)), + ) + } + } + + resultJSON, _ := json.Marshal(result) + return mcp.NewToolResultText(string(resultJSON)), nil +} + +// downloadCanvasContent downloads the full canvas markdown content +func (ch *CanvasesHandler) downloadCanvasContent(ctx context.Context, downloadURL string) (string, error) { + // Get client - it already has cookies configured for xoxc/xoxd or will use token for xoxp + slackClient := ch.apiProvider.Slack().(*provider.MCPSlackClient) + token := slackClient.Token() + httpClient := slackClient.HTTPClient() + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // For OAuth tokens (xoxp), add Bearer authentication header + // For browser tokens (xoxc/xoxd), the HTTP client's cookies will be used automatically + if token != "" && len(token) > 5 && token[:5] == "xoxp-" { + req.Header.Set("Authorization", "Bearer "+token) + } + + // Execute request using the configured HTTP client (with cookies for xoxc/xoxd) + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read content: %w", err) + } + + return string(body), nil +} diff --git a/pkg/handler/conversations.go b/pkg/handler/conversations.go index 3a06387..46495e7 100644 --- a/pkg/handler/conversations.go +++ b/pkg/handler/conversations.go @@ -84,6 +84,20 @@ type ConversationsHandler struct { logger *zap.Logger } +type Conversation struct { + ID string `json:"id" csv:"id"` + Name string `json:"name" csv:"name"` + IsChannel bool `json:"is_channel" csv:"is_channel"` + IsPrivate bool `json:"is_private" csv:"is_private"` + IsIM bool `json:"is_im" csv:"is_im"` + IsMpIM bool `json:"is_mpim" csv:"is_mpim"` + IsMember bool `json:"is_member" csv:"is_member"` + MemberCount int `json:"member_count" csv:"member_count"` + Created int64 `json:"created" csv:"created"` + Topic string `json:"topic" csv:"topic"` + Purpose string `json:"purpose" csv:"purpose"` +} + func NewConversationsHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *ConversationsHandler { return &ConversationsHandler{ apiProvider: apiProvider, @@ -630,6 +644,14 @@ func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) ( } addFilter(filters, "from", f) } + if mentions := req.GetString("filter_mentions_user", ""); mentions != "" { + f, err := ch.paramFormatUser(mentions) + if err != nil { + ch.logger.Error("Invalid mentions-user filter", zap.String("filter", mentions), zap.Error(err)) + return nil, err + } + addFilter(filters, "to", f) + } dateMap, err := buildDateFilters( req.GetString("filter_date_before", ""), @@ -1002,3 +1024,49 @@ func buildQuery(freeText []string, filters map[string][]string) string { } return strings.Join(out, " ") } + +// UsersConversationsHandler lists all conversations the authenticated user is a member of +func (ch *ConversationsHandler) UsersConversationsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("UsersConversationsHandler called", zap.Any("params", request.Params)) + + // provider readiness + if ready, err := ch.apiProvider.IsReady(); !ready { + ch.logger.Error("API provider not ready", zap.Error(err)) + return nil, err + } + + // Get all conversations from cache + channelsMap := ch.apiProvider.ProvideChannelsMaps() + ch.logger.Debug("Retrieved channels from cache", zap.Int("total_count", len(channelsMap.Channels))) + + var conversations []Conversation + for _, channel := range channelsMap.Channels { + // Note: The cache only contains channels the user is a member of + conversations = append(conversations, Conversation{ + ID: channel.ID, + Name: channel.Name, + IsChannel: !channel.IsPrivate && !channel.IsIM && !channel.IsMpIM, + IsPrivate: channel.IsPrivate && !channel.IsIM && !channel.IsMpIM, + IsIM: channel.IsIM, + IsMpIM: channel.IsMpIM, + IsMember: true, // All cached channels are ones the user is a member of + MemberCount: channel.MemberCount, + Created: 0, // Not available in simplified Channel struct + Topic: channel.Topic, + Purpose: channel.Purpose, + }) + } + + ch.logger.Info("User conversations retrieved", + zap.Int("count", len(conversations)), + ) + + // Return as CSV + csvBytes, err := gocsv.MarshalBytes(&conversations) + if err != nil { + ch.logger.Error("Failed to marshal conversations to CSV", zap.Error(err)) + return nil, err + } + + return mcp.NewToolResultText(string(csvBytes)), nil +} diff --git a/pkg/handler/conversations_test.go b/pkg/handler/conversations_test.go index 1b8c601..50ad771 100644 --- a/pkg/handler/conversations_test.go +++ b/pkg/handler/conversations_test.go @@ -17,6 +17,7 @@ import ( "github.com/openai/openai-go/option" "github.com/openai/openai-go/packages/param" "github.com/openai/openai-go/responses" + "github.com/slack-go/slack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,6 +27,8 @@ func TestIntegrationConversations(t *testing.T) { require.NotEmpty(t, sseKey, "sseKey must be generated for integration tests") apiKey := os.Getenv("SLACK_MCP_OPENAI_API") require.NotEmpty(t, apiKey, "SLACK_MCP_OPENAI_API must be set for integration tests") + xoxpToken := os.Getenv("SLACK_MCP_XOXP_TOKEN") + require.NotEmpty(t, xoxpToken, "SLACK_MCP_XOXP_TOKEN must be set for integration tests") cfg := util.MCPConfig{ SSEKey: sseKey, @@ -62,27 +65,53 @@ func TestIntegrationConversations(t *testing.T) { expectedLLMOutputMatchingRules []string } + // First, post test messages to ensure we have content to retrieve + testChannelName := "testcase-1" + slackClient := slack.New(xoxpToken) + + // Get channel ID + channels, _, err := slackClient.GetConversationsContext(ctx, &slack.GetConversationsParameters{ + Types: []string{"public_channel"}, + Limit: 1000, + }) + if err != nil { + t.Skipf("Could not list channels for test setup: %v", err) + } + + var testChannelID string + for _, ch := range channels { + if ch.Name == testChannelName { + testChannelID = ch.ID + break + } + } + if testChannelID == "" { + t.Skipf("Test channel #%s not found, skipping test", testChannelName) + } + + // Post test messages + testMessages := []string{"test message 1", "test message 2", "test message 3"} + for _, msg := range testMessages { + _, _, err := slackClient.PostMessageContext(ctx, testChannelID, slack.MsgOptionText(msg, false)) + if err != nil { + t.Skipf("Could not post test message: %v", err) + } + } + time.Sleep(2 * time.Second) // Wait for Slack to index messages + cases := []tc{ { name: "Test conversations_history tool", - input: "Provide a list of slack messages from #testcase-1", + input: fmt.Sprintf("Provide a list of slack messages from #%s", testChannelName), expectedToolName: "conversations_history", expectedToolOutputMatchingRules: []matchingRule{ { csvFieldName: "Text", - csvFieldValueRE: "^message 3$", - }, - { - csvFieldName: "Text", - csvFieldValueRE: "^message 2$", - }, - { - csvFieldName: "Text", - csvFieldValueRE: "^message 1$", + csvFieldValueRE: "test message [123]", }, }, expectedLLMOutputMatchingRules: []string{ - "message 1", "message 2", "message 3", + "test message", }, }, } diff --git a/pkg/provider/api.go b/pkg/provider/api.go index 0681567..09edcef 100644 --- a/pkg/provider/api.go +++ b/pkg/provider/api.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io/ioutil" + "net/http" "os" "strings" @@ -47,7 +48,7 @@ type Channel struct { IsMpIM bool `json:"mpim"` IsIM bool `json:"im"` IsPrivate bool `json:"private"` - User string `json:"user,omitempty"` // User ID for IM channels + User string `json:"user,omitempty"` // User ID for IM channels Members []string `json:"members,omitempty"` // Member IDs for the channel } @@ -68,6 +69,12 @@ type SlackAPI interface { // Used to get channels list from both Slack and Enterprise Grid versions GetConversationsContext(ctx context.Context, params *slack.GetConversationsParameters) ([]slack.Channel, string, error) + // Canvas API methods + CreateCanvasContext(ctx context.Context, title string, documentContent slack.DocumentContent) (string, error) + EditCanvasContext(ctx context.Context, params slack.EditCanvasParams) error + LookupCanvasSectionsContext(ctx context.Context, params slack.LookupCanvasSectionsParams) ([]slack.CanvasSection, error) + GetFileInfoContext(ctx context.Context, fileID string, count, page int) (*slack.File, []slack.Comment, *slack.Paging, error) + // Edge API methods ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error) } @@ -75,6 +82,7 @@ type SlackAPI interface { type MCPSlackClient struct { slackClient *slack.Client edgeClient *edge.Client + httpClient *http.Client authResponse *slack.AuthTestResponse authProvider auth.Provider @@ -141,6 +149,7 @@ func NewMCPSlackClient(authProvider auth.Provider, logger *zap.Logger) (*MCPSlac return &MCPSlackClient{ slackClient: slackClient, edgeClient: edgeClient, + httpClient: httpClient, authResponse: authResponse, authProvider: authProvider, isEnterprise: isEnterprise, @@ -258,6 +267,22 @@ func (c *MCPSlackClient) PostMessageContext(ctx context.Context, channelID strin return c.slackClient.PostMessageContext(ctx, channelID, options...) } +func (c *MCPSlackClient) CreateCanvasContext(ctx context.Context, title string, documentContent slack.DocumentContent) (string, error) { + return c.slackClient.CreateCanvasContext(ctx, title, documentContent) +} + +func (c *MCPSlackClient) EditCanvasContext(ctx context.Context, params slack.EditCanvasParams) error { + return c.slackClient.EditCanvasContext(ctx, params) +} + +func (c *MCPSlackClient) LookupCanvasSectionsContext(ctx context.Context, params slack.LookupCanvasSectionsParams) ([]slack.CanvasSection, error) { + return c.slackClient.LookupCanvasSectionsContext(ctx, params) +} + +func (c *MCPSlackClient) GetFileInfoContext(ctx context.Context, fileID string, count, page int) (*slack.File, []slack.Comment, *slack.Paging, error) { + return c.slackClient.GetFileInfoContext(ctx, fileID, count, page) +} + func (c *MCPSlackClient) ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error) { return c.edgeClient.ClientUserBoot(ctx) } @@ -283,6 +308,14 @@ func (c *MCPSlackClient) Raw() struct { } } +func (c *MCPSlackClient) Token() string { + return c.authProvider.SlackToken() +} + +func (c *MCPSlackClient) HTTPClient() *http.Client { + return c.httpClient +} + func New(transport string, logger *zap.Logger) *ApiProvider { var ( authProvider auth.ValueAuth @@ -490,7 +523,7 @@ func (ap *ApiProvider) RefreshChannels(ctx context.Context) error { if c.IsIM { // Re-map the channel to get updated user name if available remappedChannel := mapChannel( - c.ID, "", "", c.Topic, c.Purpose, + c.ID, "", "", c.Topic, c.Purpose, c.User, c.Members, c.MemberCount, c.IsIM, c.IsMpIM, c.IsPrivate, usersMap, @@ -709,7 +742,7 @@ func mapChannel( if isIM { finalMemberCount = 2 userID = user // Store the user ID for later re-mapping - + // If user field is empty but we have members, try to extract from members if userID == "" && len(members) > 0 { // For IM channels, members should contain the other user's ID @@ -721,7 +754,7 @@ func mapChannel( } } } - + if u, ok := usersMap[userID]; ok { channelName = "@" + u.Name finalPurpose = "DM with " + u.RealName diff --git a/pkg/provider/edge/client.go b/pkg/provider/edge/client.go index f2fcd83..b3c100c 100644 --- a/pkg/provider/edge/client.go +++ b/pkg/provider/edge/client.go @@ -106,7 +106,7 @@ func (c IM) SlackChannel() slack.Channel { if c.User != "" { members = []string{c.User} } - + return slack.Channel{ GroupConversation: slack.GroupConversation{ Conversation: slack.Conversation{ diff --git a/pkg/provider/edge/client_boot.go b/pkg/provider/edge/client_boot.go index 1683302..f67f93f 100644 --- a/pkg/provider/edge/client_boot.go +++ b/pkg/provider/edge/client_boot.go @@ -149,7 +149,7 @@ func (c *UserBootChannel) SlackChannel() slack.Channel { } } } - + return slack.Channel{ GroupConversation: slack.GroupConversation{ Conversation: slack.Conversation{ diff --git a/pkg/server/server.go b/pkg/server/server.go index 95e1c30..673991c 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -110,6 +110,9 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer mcp.WithString("filter_users_from", mcp.Description("Filter messages from a specific user by their ID or display name. Example: 'U1234567890' or '@username'. If not provided, all users will be searched."), ), + mcp.WithString("filter_mentions_user", + mcp.Description("Filter messages that mention a specific user by their ID or display name. Example: 'U1234567890' or '@username'. Use '@me' to find messages mentioning you. If not provided, no mention filtering is applied."), + ), mcp.WithString("filter_date_before", mcp.Description("Filter messages sent before a specific date in format 'YYYY-MM-DD'. Example: '2023-10-01', 'July', 'Yesterday' or 'Today'. If not provided, all dates will be searched."), ), @@ -135,6 +138,10 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer ), ), conversationsHandler.ConversationsSearchHandler) + s.AddTool(mcp.NewTool("users_conversations", + mcp.WithDescription("Get list of all conversations (channels, DMs, group DMs) that the authenticated user is a member of. Returns conversation metadata including type, member count, and topics."), + ), conversationsHandler.UsersConversationsHandler) + channelsHandler := handler.NewChannelsHandler(provider, logger) s.AddTool(mcp.NewTool("channels_list", @@ -155,6 +162,57 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer ), ), channelsHandler.ChannelsHandler) + canvasesHandler := handler.NewCanvasesHandler(provider, logger) + + s.AddTool(mcp.NewTool("canvases_create", + mcp.WithDescription("Create a new canvas with markdown content"), + mcp.WithString("title", + mcp.Description("Title of the canvas (optional). If not provided, canvas will be created untitled."), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Markdown content for the canvas. Supports headings, lists, code blocks, tables, links, mentions (@user, #channel), and emojis."), + ), + ), canvasesHandler.CanvasesCreateHandler) + + s.AddTool(mcp.NewTool("canvases_edit", + mcp.WithDescription("Edit an existing canvas by adding, replacing, or deleting content"), + mcp.WithString("canvas_id", + mcp.Required(), + mcp.Description("ID of the canvas to edit (e.g., F1234567890)"), + ), + mcp.WithString("operation", + mcp.DefaultString("insert_at_end"), + mcp.Description("Type of edit operation. Allowed values: 'insert_at_start', 'insert_at_end', 'insert_before', 'insert_after', 'replace', 'delete'. Default: 'insert_at_end'"), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Markdown content to add or use for replacement. Supports headings, lists, code blocks, tables, links, mentions, and emojis."), + ), + mcp.WithString("section_id", + mcp.Description("Section ID for targeted operations (required for: insert_before, insert_after, delete). Use canvases_sections_lookup to find section IDs."), + ), + ), canvasesHandler.CanvasesEditHandler) + + s.AddTool(mcp.NewTool("canvases_sections_lookup", + mcp.WithDescription("Look up section IDs in a canvas for targeted edits"), + mcp.WithString("canvas_id", + mcp.Required(), + mcp.Description("ID of the canvas to search for sections (e.g., F1234567890)"), + ), + mcp.WithString("contains_text", + mcp.Description("Filter sections by text content (optional)"), + ), + ), canvasesHandler.CanvasesSectionsLookupHandler) + + s.AddTool(mcp.NewTool("canvases_read", + mcp.WithDescription("Read canvas metadata and content"), + mcp.WithString("canvas_id", + mcp.Required(), + mcp.Description("ID of the canvas to read (e.g., F1234567890)"), + ), + ), canvasesHandler.CanvasesReadHandler) + logger.Info("Authenticating with Slack API...", zap.String("context", "console"), ) diff --git a/pkg/test/util/mcp.go b/pkg/test/util/mcp.go index 7e140ea..cc28cc4 100644 --- a/pkg/test/util/mcp.go +++ b/pkg/test/util/mcp.go @@ -8,8 +8,8 @@ import ( "net" "os" "os/exec" - "strings" "strconv" + "strings" "syscall" "time" )