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..d713a7f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,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. +### 6. 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 + +### 7. 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. + +### 8. 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` + +### 9. 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_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..afc34e3 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -155,6 +155,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" )