From 2af92dde4967b25e32140018a30055da9ee89c78 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Tue, 25 Nov 2025 23:42:13 +0000 Subject: [PATCH] Add get_permalink tool to generate Slack message URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new MCP tool that constructs Slack permalinks from channel IDs and message timestamps. This enables Claude to provide clickable links when referencing Slack messages, which is valuable for daily summaries, citations, and quick navigation. Changes: - Added FormatPermalinkTimestamp() helper to text_processor.go - Added GetPermalinkHandler() and permalinkParams to conversations.go - Registered get_permalink tool in server.go with required parameters The tool accepts channel_id and message_ts, fetches the workspace name via AuthTest, and constructs the permalink in format: https://{workspace}.slack.com/archives/{channel_id}/p{timestamp_without_dot} 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/handler/conversations.go | 77 ++++++++++++++++++++++++++++++++++++ pkg/server/server.go | 12 ++++++ pkg/text/text_processor.go | 8 ++++ 3 files changed, 97 insertions(+) diff --git a/pkg/handler/conversations.go b/pkg/handler/conversations.go index 3a06387f..f855e63c 100644 --- a/pkg/handler/conversations.go +++ b/pkg/handler/conversations.go @@ -79,6 +79,11 @@ type addMessageParams struct { contentType string } +type permalinkParams struct { + channel string + messageTs string +} + type ConversationsHandler struct { apiProvider *provider.ApiProvider logger *zap.Logger @@ -1002,3 +1007,75 @@ func buildQuery(freeText []string, filters map[string][]string) string { } return strings.Join(out, " ") } + +// GetPermalinkHandler generates a Slack permalink for a specific message +func (ch *ConversationsHandler) GetPermalinkHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ch.logger.Debug("GetPermalinkHandler called", zap.Any("params", request.Params)) + + params, err := ch.parseParamsToolGetPermalink(request) + if err != nil { + ch.logger.Error("Failed to parse permalink params", zap.Error(err)) + return nil, err + } + + // Get workspace from Slack auth + ar, err := ch.apiProvider.Slack().AuthTest() + if err != nil { + ch.logger.Error("Slack AuthTest failed", zap.Error(err)) + return nil, err + } + + ws, err := text.Workspace(ar.URL) + if err != nil { + ch.logger.Error("Failed to parse workspace from URL", + zap.String("url", ar.URL), + zap.Error(err), + ) + return nil, fmt.Errorf("failed to parse workspace from URL: %v", err) + } + + // Format timestamp for permalink + formattedTs, err := text.FormatPermalinkTimestamp(params.messageTs) + if err != nil { + ch.logger.Error("Failed to format timestamp", + zap.String("timestamp", params.messageTs), + zap.Error(err), + ) + return nil, fmt.Errorf("failed to format timestamp: %v", err) + } + + // Construct permalink + permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", ws, params.channel, formattedTs) + + ch.logger.Debug("Generated permalink", + zap.String("channel", params.channel), + zap.String("timestamp", params.messageTs), + zap.String("permalink", permalink), + ) + + return mcp.NewToolResultText(permalink), nil +} + +func (ch *ConversationsHandler) parseParamsToolGetPermalink(request mcp.CallToolRequest) (*permalinkParams, error) { + channel := request.GetString("channel_id", "") + if channel == "" { + ch.logger.Error("channel_id missing in permalink params") + return nil, errors.New("channel_id must be a string") + } + + messageTs := request.GetString("message_ts", "") + if messageTs == "" { + ch.logger.Error("message_ts missing in permalink params") + return nil, errors.New("message_ts must be a string") + } + + if !strings.Contains(messageTs, ".") { + ch.logger.Error("Invalid message_ts format", zap.String("message_ts", messageTs)) + return nil, errors.New("message_ts must be a valid timestamp in format 1234567890.123456") + } + + return &permalinkParams{ + channel: channel, + messageTs: messageTs, + }, nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 95e1c300..79df12bb 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -135,6 +135,18 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer ), ), conversationsHandler.ConversationsSearchHandler) + s.AddTool(mcp.NewTool("get_permalink", + mcp.WithDescription("Get the Slack permalink URL for a specific message"), + mcp.WithString("channel_id", + mcp.Required(), + mcp.Description("ID of the channel. Example: 'C1234567890' for channels, 'D1234567890' for DMs."), + ), + mcp.WithString("message_ts", + mcp.Required(), + mcp.Description("Message timestamp in format 1234567890.123456. This is the unique identifier for the message."), + ), + ), conversationsHandler.GetPermalinkHandler) + channelsHandler := handler.NewChannelsHandler(provider, logger) s.AddTool(mcp.NewTool("channels_list", diff --git a/pkg/text/text_processor.go b/pkg/text/text_processor.go index 9731d61d..8aa9d83e 100644 --- a/pkg/text/text_processor.go +++ b/pkg/text/text_processor.go @@ -173,6 +173,14 @@ func TimestampToIsoRFC3339(slackTS string) (string, error) { return t.UTC().Format(time.RFC3339), nil } +func FormatPermalinkTimestamp(slackTS string) (string, error) { + parts := strings.Split(slackTS, ".") + if len(parts) != 2 { + return "", fmt.Errorf("invalid slack timestamp format: %s", slackTS) + } + return parts[0] + parts[1], nil +} + func ProcessText(s string) string { s = filterSpecialChars(s)