feat: initial release with 48 Mattermost MCP tools

- 27 base tools: channels, messaging, users, reactions, files, DMs
- Team management: invite/remove users, get stats, list members
- Slash commands: execute /remind, /poll, etc.
- Webhook management: incoming and outgoing webhooks
- System tools: server config, logs, bulk status updates
- Channel admin: create, invite, leave, delete channels
- Read-only mode for safe exploration
- Dual token support (bot + PAT) for enhanced security
- Apache 2.0 licensed
This commit is contained in:
2026-04-15 21:14:47 -07:00
commit 35a0b2b715
52 changed files with 10495 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
package messaging
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetChannelMessagesToolName = "mattermost_get_channel_messages"
)
var (
GetChannelMessagesTool = mcp.NewTool(
GetChannelMessagesToolName,
mcp.WithDescription("Read message history from a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to read from")),
mcp.WithNumber("limit", mcp.Description("Number of messages to return (default 60, max 200)")),
mcp.WithString("before", mcp.Description("Get messages before this post ID (for pagination)")),
mcp.WithString("after", mcp.Description("Get messages after this post ID (for pagination)")),
)
)
func init() {
registerGetChannelMessagesTool()
}
func registerGetChannelMessagesTool() {
tools := []server.ServerTool{
{Tool: GetChannelMessagesTool, Handler: GetChannelMessagesFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetChannelMessagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetChannelMessagesFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
limit := params.GetOptionalInt(args, "limit", 60)
if limit > 200 {
limit = 200
}
if limit < 1 {
limit = 60
}
before := params.GetOptionalString(args, "before", "")
after := params.GetOptionalString(args, "after", "")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
posts, err := client.GetChannelPosts(ctx, channelID, int(limit), before, after)
if err != nil {
return to.Error(fmt.Errorf("[posts] failed to get channel messages: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(posts.Posts))
for _, post := range posts.Posts {
results = append(results, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": results,
"count": len(results),
"channel_id": channelID,
"has_more": len(results) == int(limit),
}), nil
}
+369
View File
@@ -0,0 +1,369 @@
package messaging
import (
"context"
"fmt"
"strings"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mattermost/mattermost-server/v6/model"
)
var Tool = tool.New()
const (
SendMessageToolName = "mattermost_send_message"
EditMessageToolName = "mattermost_edit_message"
DeleteMessageToolName = "mattermost_delete_message"
BulkDeleteMessagesToolName = "mattermost_bulk_delete_messages"
PinPostToolName = "mattermost_pin_post"
UnpinPostToolName = "mattermost_unpin_post"
GetPinnedPostsToolName = "mattermost_get_pinned_posts"
GetPostToolName = "mattermost_get_post"
)
var (
SendMessageTool = mcp.NewTool(
SendMessageToolName,
mcp.WithDescription("Send a message to a Mattermost channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to send the message to")),
mcp.WithString("message", mcp.Required(), mcp.Description("Message content to send")),
mcp.WithString("thread_id", mcp.Description("Thread ID for replies (optional). If provided, message will be posted as a reply in the thread")),
)
EditMessageTool = mcp.NewTool(
EditMessageToolName,
mcp.WithDescription("Edit an existing message in Mattermost"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID of the message to edit")),
mcp.WithString("message", mcp.Required(), mcp.Description("New message content")),
)
DeleteMessageTool = mcp.NewTool(
DeleteMessageToolName,
mcp.WithDescription("Delete a message from Mattermost"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID of the message to delete")),
)
BulkDeleteMessagesTool = mcp.NewTool(
BulkDeleteMessagesToolName,
mcp.WithDescription("Delete multiple messages at once (up to 100)"),
mcp.WithString("post_ids", mcp.Required(), mcp.Description("Comma-separated list of post IDs to delete (max 100)")),
)
PinPostTool = mcp.NewTool(
PinPostToolName,
mcp.WithDescription("Pin a post to a channel"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to pin")),
)
UnpinPostTool = mcp.NewTool(
UnpinPostToolName,
mcp.WithDescription("Unpin a post from a channel"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to unpin")),
)
GetPinnedPostsTool = mcp.NewTool(
GetPinnedPostsToolName,
mcp.WithDescription("Get all pinned posts in a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to get pinned posts from")),
)
GetPostTool = mcp.NewTool(
GetPostToolName,
mcp.WithDescription("Get a single post by its ID"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to retrieve")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: SendMessageTool, Handler: SendMessageFn},
{Tool: EditMessageTool, Handler: EditMessageFn},
{Tool: DeleteMessageTool, Handler: DeleteMessageFn},
{Tool: BulkDeleteMessagesTool, Handler: BulkDeleteMessagesFn},
{Tool: PinPostTool, Handler: PinPostFn},
{Tool: UnpinPostTool, Handler: UnpinPostFn},
{Tool: GetPinnedPostsTool, Handler: GetPinnedPostsFn},
{Tool: GetPostTool, Handler: GetPostFn},
}
for _, t := range tools {
if t.Tool.Name == GetPinnedPostsToolName || t.Tool.Name == GetPostToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func SendMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called SendMessageFn")
args := req.GetArguments()
channelId, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
message, err := params.GetString(args, "message")
if err != nil {
return to.Error(fmt.Errorf("[message] %v", err)), nil
}
threadId := params.GetOptionalString(args, "thread_id", "")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
post := &model.Post{
ChannelId: channelId,
Message: message,
}
if threadId != "" {
post.RootId = threadId
}
result, err := client.CreatePost(ctx, post)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to send message: %v", err)), nil
}
return to.Result(SlimPost(result)), nil
}
func EditMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called EditMessageFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
message, err := params.GetString(args, "message")
if err != nil {
return to.Error(fmt.Errorf("[message] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
post := &model.Post{
Message: message,
}
result, err := client.UpdatePost(ctx, postId, post)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to edit message: %v", err)), nil
}
return to.Result(SlimPost(result)), nil
}
func DeleteMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called DeleteMessageFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeletePost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to delete message: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"message": "Message deleted successfully",
}), nil
}
func BulkDeleteMessagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called BulkDeleteMessagesFn")
args := req.GetArguments()
postIDsStr, err := params.GetString(args, "post_ids")
if err != nil {
return to.Error(fmt.Errorf("[post_ids] %v", err)), nil
}
postIDs := strings.Split(postIDsStr, ",")
if len(postIDs) > 100 {
return to.Error(fmt.Errorf("[post_ids] too many post IDs (max 100, got %d)", len(postIDs))), nil
}
for i, id := range postIDs {
postIDs[i] = strings.TrimSpace(id)
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
deleted := 0
failed := 0
var failedIDs []string
var lastError string
for _, postID := range postIDs {
err := client.DeletePost(ctx, postID)
if err != nil {
failed++
failedIDs = append(failedIDs, postID)
lastError = err.Error()
} else {
deleted++
}
}
result := map[string]interface{}{
"total": len(postIDs),
"deleted": deleted,
"failed": failed,
"successful": deleted == len(postIDs),
}
if failed > 0 {
result["failed_ids"] = failedIDs
result["last_error"] = lastError
return to.Result(result), nil
}
return to.Result(result), nil
}
func PinPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called PinPostFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.PinPost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to pin: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"message": "Post pinned successfully",
}), nil
}
func UnpinPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called UnpinPostFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.UnpinPost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to unpin: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"message": "Post unpinned successfully",
}), nil
}
func GetPinnedPostsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetPinnedPostsFn")
args := req.GetArguments()
channelId, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
posts, err := client.GetPinnedPosts(ctx, channelId)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get pinned posts: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(posts.Posts))
for _, post := range posts.Posts {
results = append(results, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": results,
"count": len(results),
"channel_id": channelId,
}), nil
}
func GetPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetPostFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
post, err := client.GetPost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to get: %v", err)), nil
}
return to.Result(SlimPost(post)), nil
}
+192
View File
@@ -0,0 +1,192 @@
package messaging
import (
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimPost(t *testing.T) {
p := &model.Post{
Id: "abc123",
ChannelId: "channel456",
UserId: "user789",
Message: "Hello World",
CreateAt: 1234567890000,
UpdateAt: 1234567890001,
}
slim := SlimPost(p)
assert.NotNil(t, slim)
assert.Equal(t, "abc123", slim["id"])
assert.Equal(t, "channel456", slim["channel_id"])
assert.Equal(t, "user789", slim["user_id"])
assert.Equal(t, "Hello World", slim["message"])
assert.Equal(t, int64(1234567890000), slim["create_at"])
assert.Equal(t, int64(1234567890001), slim["update_at"])
}
func TestSlimPost_Nil(t *testing.T) {
slim := SlimPost(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
writeTools := Tool.WriteTools()
assert.Len(t, writeTools, 3)
readTools := Tool.ReadTools()
assert.Len(t, readTools, 3)
toolNames := make(map[string]bool)
for _, t := range writeTools {
toolNames[t.Tool.Name] = true
}
for _, t := range readTools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[SendMessageToolName], "SendMessage tool should be registered")
assert.True(t, toolNames[EditMessageToolName], "EditMessage tool should be registered")
assert.True(t, toolNames[DeleteMessageToolName], "DeleteMessage tool should be registered")
assert.True(t, toolNames[GetChannelMessagesToolName], "GetChannelMessages tool should be registered")
assert.True(t, toolNames[GetThreadToolName], "GetThread tool should be registered")
assert.True(t, toolNames[SearchPostsToolName], "SearchPosts tool should be registered")
}
func TestSendMessageFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"message": "Test message",
},
},
}
result, err := SendMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestEditMessageFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: EditMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
"message": "Updated message",
},
},
}
result, err := EditMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDeleteMessageFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DeleteMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
},
},
}
result, err := DeleteMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestSendMessageFn_MissingChannelId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SendMessageToolName,
Arguments: map[string]interface{}{
"message": "Test message",
},
},
}
result, err := SendMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestSendMessageFn_MissingMessage(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
},
},
}
result, err := SendMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestEditMessageFn_MissingPostId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: EditMessageToolName,
Arguments: map[string]interface{}{
"message": "Updated message",
},
},
}
result, err := EditMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestEditMessageFn_MissingMessage(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: EditMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
},
},
}
result, err := EditMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDeleteMessageFn_MissingPostId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DeleteMessageToolName,
Arguments: map[string]interface{}{},
},
}
result, err := DeleteMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+85
View File
@@ -0,0 +1,85 @@
package messaging
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
SearchPostsToolName = "mattermost_search_posts"
)
var (
SearchPostsTool = mcp.NewTool(
SearchPostsToolName,
mcp.WithDescription("Search for posts/messages in a team using search terms"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to search in")),
mcp.WithString("terms", mcp.Required(), mcp.Description("Search terms (e.g., \"BTCUSD\", \"error\", \"meeting\")")),
mcp.WithBoolean("is_or_search", mcp.Description("Use OR logic instead of AND (default: false)")),
mcp.WithNumber("limit", mcp.Description("Maximum number of results to return (default 30)")),
)
)
func init() {
registerSearchTools()
}
func registerSearchTools() {
tools := []server.ServerTool{
{Tool: SearchPostsTool, Handler: SearchPostsFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func SearchPostsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called SearchPostsFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
terms, err := params.GetString(args, "terms")
if err != nil {
return to.Error(fmt.Errorf("[terms] %v", err)), nil
}
isOrSearch := params.GetOptionalBool(args, "is_or_search", false)
limit := params.GetOptionalInt(args, "limit", 30)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
results, err := client.SearchPosts(ctx, teamID, terms, isOrSearch)
if err != nil {
return to.Error(fmt.Errorf("[search] failed to search posts: %v", err)), nil
}
posts := results.ToSlice()
if len(posts) > int(limit) {
posts = posts[:limit]
}
postResults := make([]map[string]interface{}, 0, len(posts))
for _, post := range posts {
postResults = append(postResults, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": postResults,
"count": len(postResults),
}), nil
}
+17
View File
@@ -0,0 +1,17 @@
package messaging
import "github.com/mattermost/mattermost-server/v6/model"
func SlimPost(p *model.Post) map[string]interface{} {
if p == nil {
return nil
}
return map[string]interface{}{
"id": p.Id,
"channel_id": p.ChannelId,
"user_id": p.UserId,
"message": p.Message,
"create_at": p.CreateAt,
"update_at": p.UpdateAt,
}
}
+71
View File
@@ -0,0 +1,71 @@
package messaging
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetThreadToolName = "mattermost_get_thread"
)
var (
GetThreadTool = mcp.NewTool(
GetThreadToolName,
mcp.WithDescription("Read all messages in a thread conversation"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("The root post ID of the thread")),
)
)
func init() {
registerGetThreadTool()
}
func registerGetThreadTool() {
tools := []server.ServerTool{
{Tool: GetThreadTool, Handler: GetThreadFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetThreadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetThreadFn")
args := req.GetArguments()
postID, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
thread, err := client.GetPostThread(ctx, postID)
if err != nil {
return to.Error(fmt.Errorf("[thread] failed to get thread: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(thread.Posts))
for _, post := range thread.Posts {
results = append(results, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": results,
"count": len(results),
"root_id": postID,
"has_replies": len(results) > 1,
}), nil
}