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
+220
View File
@@ -0,0 +1,220 @@
package channel
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/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 AdminTool = tool.New()
const (
CreateChannelToolName = "mattermost_create_channel"
InviteToChannelToolName = "mattermost_invite_to_channel"
DeleteChannelToolName = "mattermost_delete_channel"
LeaveChannelToolName = "mattermost_leave_channel"
)
var (
CreateChannelTool = mcp.NewTool(
CreateChannelToolName,
mcp.WithDescription("Create a new channel (public or private) in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team to create channel in")),
mcp.WithString("name", mcp.Required(), mcp.Description("Channel name (lowercase, no spaces, 2-64 characters)")),
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the channel (2-64 characters)")),
mcp.WithString("type", mcp.Required(), mcp.Description("Channel type: 'O' for public, 'P' for private")),
mcp.WithString("purpose", mcp.Description("Channel description/purpose (optional)")),
)
InviteToChannelTool = mcp.NewTool(
InviteToChannelToolName,
mcp.WithDescription("Invite a user to a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to invite user to")),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User to invite")),
)
DeleteChannelTool = mcp.NewTool(
DeleteChannelToolName,
mcp.WithDescription("Delete/archive a channel (soft delete by default)"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to delete")),
mcp.WithBoolean("permanent", mcp.Description("Permanently delete instead of archive (default: false)")),
)
LeaveChannelTool = mcp.NewTool(
LeaveChannelToolName,
mcp.WithDescription("Remove self from a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to leave")),
)
)
func init() {
registerAdminTools()
}
func registerAdminTools() {
tools := []server.ServerTool{
{Tool: CreateChannelTool, Handler: CreateChannelFn},
{Tool: InviteToChannelTool, Handler: InviteToChannelFn},
{Tool: DeleteChannelTool, Handler: DeleteChannelFn},
{Tool: LeaveChannelTool, Handler: LeaveChannelFn},
}
for _, t := range tools {
AdminTool.RegisterWrite(t)
}
}
func CreateChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called CreateChannelFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
name, err := params.GetString(args, "name")
if err != nil {
return to.Error(fmt.Errorf("[name] %v", err)), nil
}
displayName, err := params.GetString(args, "display_name")
if err != nil {
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
}
channelType, err := params.GetString(args, "type")
if err != nil {
return to.Error(fmt.Errorf("[type] %v", err)), nil
}
if channelType != "O" && channelType != "P" {
return to.Error(fmt.Errorf("[type] must be 'O' (public) or 'P' (private)")), nil
}
purpose := params.GetOptionalString(args, "purpose", "")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
channel := &model.Channel{
TeamId: teamID,
Name: name,
DisplayName: displayName,
Type: model.ChannelType(channelType),
Purpose: purpose,
}
created, err := client.CreateChannel(ctx, channel)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to create channel: %v", err)), nil
}
return to.Result(SlimChannel(created)), nil
}
func InviteToChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called InviteToChannelFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
userID, err := params.GetString(args, "user_id")
if err != nil {
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
member, err := client.AddChannelMember(ctx, channelID, userID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to invite user to channel: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"user_id": userID,
"member": member,
"message": "User invited to channel successfully",
}), nil
}
func DeleteChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called DeleteChannelFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
_ = params.GetOptionalBool(args, "permanent", false)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeleteChannel(ctx, channelID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to delete channel: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"message": "Channel deleted successfully",
}), nil
}
func LeaveChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called LeaveChannelFn")
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
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
err = client.RemoveChannelMember(ctx, channelID, user.Id)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to leave channel: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"user_id": user.Id,
"message": "Successfully left the channel",
}), nil
}
+207
View File
@@ -0,0 +1,207 @@
package channel
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/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ListChannelsToolName = "mattermost_list_channels"
GetChannelByNameToolName = "mattermost_get_channel_by_name"
GetChannelInfoToolName = "mattermost_get_channel_info"
ListChannelMembersToolName = "mattermost_list_channel_members"
)
var (
ListChannelsTool = mcp.NewTool(
ListChannelsToolName,
mcp.WithDescription("List accessible channels for the authenticated user in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list channels from")),
mcp.WithNumber("limit", mcp.Description("Maximum number of results to return (default 30)")),
)
GetChannelByNameTool = mcp.NewTool(
GetChannelByNameToolName,
mcp.WithDescription("Get a channel by name in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to search in")),
mcp.WithString("channel_name", mcp.Required(), mcp.Description("Channel name to find (e.g., \"general\", \"social\", \"trading-desk\")")),
)
GetChannelInfoTool = mcp.NewTool(
GetChannelInfoToolName,
mcp.WithDescription("Get detailed channel information including member count, purpose, etc."),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to get info for")),
)
ListChannelMembersTool = mcp.NewTool(
ListChannelMembersToolName,
mcp.WithDescription("List all members of a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to list members for")),
mcp.WithNumber("page", mcp.Description("Page number for pagination (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Members per page (default 60, max 200)")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: ListChannelsTool, Handler: ListChannelsFn},
{Tool: GetChannelByNameTool, Handler: GetChannelByNameFn},
{Tool: GetChannelInfoTool, Handler: GetChannelInfoFn},
{Tool: ListChannelMembersTool, Handler: ListChannelMembersFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func ListChannelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called ListChannelsFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
limit := params.GetOptionalInt(args, "limit", 30)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
channels, err := client.GetChannelsForTeamForUser(ctx, teamID, user.Id, false)
if err != nil {
return to.Error(fmt.Errorf("[channels] failed to list channels: %v", err)), nil
}
if len(channels) > int(limit) {
channels = channels[:limit]
}
results := make([]map[string]interface{}, 0, len(channels))
for _, ch := range channels {
results = append(results, SlimChannel(ch))
}
return to.Result(map[string]interface{}{
"channels": results,
"count": len(results),
}), nil
}
func GetChannelByNameFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called GetChannelByNameFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
channelName, err := params.GetString(args, "channel_name")
if err != nil {
return to.Error(fmt.Errorf("[channel_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
channel, err := client.GetChannelByName(ctx, teamID, channelName)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get channel: %v", err)), nil
}
return to.Result(SlimChannel(channel)), nil
}
func GetChannelInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called GetChannelInfoFn")
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
}
channel, err := client.GetChannel(ctx, channelID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get channel: %v", err)), nil
}
stats, err := client.GetChannelStats(ctx, channelID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get channel stats: %v", err)), nil
}
return to.Result(DetailedChannel(channel, stats.MemberCount)), nil
}
func ListChannelMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called ListChannelMembersFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 60)
if perPage > 200 {
perPage = 200
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
members, err := client.GetChannelMembers(ctx, channelID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to list members: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(members))
for _, m := range members {
results = append(results, SlimChannelMember(m))
}
return to.Result(map[string]interface{}{
"members": results,
"count": len(results),
"channel_id": channelID,
"page": page,
"per_page": perPage,
}), nil
}
+108
View File
@@ -0,0 +1,108 @@
package channel
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 TestSlimChannel(t *testing.T) {
c := &model.Channel{
Id: "channel123",
Name: "general",
DisplayName: "General",
Type: "O",
TeamId: "team456",
}
slim := SlimChannel(c)
assert.NotNil(t, slim)
assert.Equal(t, "channel123", slim["id"])
assert.Equal(t, "general", slim["name"])
assert.Equal(t, "General", slim["display_name"])
assert.Equal(t, "O", slim["type"])
assert.Equal(t, "team456", slim["team_id"])
}
func TestSlimChannel_Nil(t *testing.T) {
slim := SlimChannel(nil)
assert.Nil(t, slim)
}
func TestSlimChannel_DifferentTypes(t *testing.T) {
tests := []struct {
name string
chType model.ChannelType
expected string
}{
{"Open channel", model.ChannelTypeOpen, "O"},
{"Private channel", model.ChannelTypePrivate, "P"},
{"Direct message", model.ChannelTypeDirect, "D"},
{"Group message", model.ChannelTypeGroup, "G"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &model.Channel{
Id: "channel123",
Name: "test-channel",
DisplayName: "Test Channel",
Type: tt.chType,
TeamId: "team456",
}
slim := SlimChannel(c)
assert.Equal(t, tt.expected, slim["type"])
})
}
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 5)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[ListChannelsToolName], "ListChannels tool should be registered")
assert.True(t, toolNames[GetChannelByNameToolName], "GetChannelByName tool should be registered")
assert.True(t, toolNames[GetChannelInfoToolName], "GetChannelInfo tool should be registered")
assert.True(t, toolNames[GetUnreadCountToolName], "GetUnreadCount tool should be registered")
assert.True(t, toolNames[MarkChannelReadToolName], "MarkChannelRead tool should be registered")
}
func TestListChannelsFn_MissingTeamId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: ListChannelsToolName,
Arguments: map[string]interface{}{},
},
}
result, err := ListChannelsFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestListChannelsFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: ListChannelsToolName,
Arguments: map[string]interface{}{
"team_id": "team123",
},
},
}
result, err := ListChannelsFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+45
View File
@@ -0,0 +1,45 @@
package channel
import "github.com/mattermost/mattermost-server/v6/model"
func SlimChannel(c *model.Channel) map[string]interface{} {
if c == nil {
return nil
}
return map[string]interface{}{
"id": c.Id,
"name": c.Name,
"display_name": c.DisplayName,
"type": string(c.Type),
"team_id": c.TeamId,
}
}
func DetailedChannel(c *model.Channel, memberCount int64) map[string]interface{} {
if c == nil {
return nil
}
return map[string]interface{}{
"id": c.Id,
"name": c.Name,
"display_name": c.DisplayName,
"type": string(c.Type),
"team_id": c.TeamId,
"purpose": c.Purpose,
"header": c.Header,
"creator_id": c.CreatorId,
"create_at": c.CreateAt,
"member_count": memberCount,
}
}
func SlimChannelMember(m model.ChannelMember) map[string]interface{} {
return map[string]interface{}{
"user_id": m.UserId,
"channel_id": m.ChannelId,
"roles": m.Roles,
"last_viewed": m.LastViewedAt,
"msg_count": m.MsgCount,
"mention_count": m.MentionCount,
}
}
+120
View File
@@ -0,0 +1,120 @@
package channel
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 (
GetUnreadCountToolName = "mattermost_get_unread_count"
MarkChannelReadToolName = "mattermost_mark_channel_read"
)
var (
GetUnreadCountTool = mcp.NewTool(
GetUnreadCountToolName,
mcp.WithDescription("Get unread message counts for all channels in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to get unread counts for")),
)
MarkChannelReadTool = mcp.NewTool(
MarkChannelReadToolName,
mcp.WithDescription("Mark a channel as read (clear unread notifications)"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to mark as read")),
)
)
func init() {
registerUnreadTools()
}
func registerUnreadTools() {
tools := []server.ServerTool{
{Tool: GetUnreadCountTool, Handler: GetUnreadCountFn},
{Tool: MarkChannelReadTool, Handler: MarkChannelReadFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetUnreadCountFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called GetUnreadCountFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
members, err := client.GetChannelMembersForUser(ctx, user.Id, teamID)
if err != nil {
return to.Error(fmt.Errorf("[channels] failed to get channel members: %v", err)), nil
}
memberResults := make([]map[string]interface{}, 0, len(members))
for _, member := range members {
memberResults = append(memberResults, map[string]interface{}{
"channel_id": member.ChannelId,
"user_id": member.UserId,
"unread_messages": member.MsgCount,
"unread_mentions": member.MentionCount,
"last_viewed_at": member.LastViewedAt,
})
}
return to.Result(map[string]interface{}{
"channels": memberResults,
"count": len(memberResults),
}), nil
}
func MarkChannelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called MarkChannelReadFn")
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
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
_, err = client.MarkChannelAsRead(ctx, channelID, user.Id)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to mark channel as read: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"message": "Channel marked as read successfully",
}), nil
}