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
}
+75
View File
@@ -0,0 +1,75 @@
package command
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 (
ExecuteSlashCommandToolName = "mattermost_execute_slash_command"
)
var (
ExecuteSlashCommandTool = mcp.NewTool(
ExecuteSlashCommandToolName,
mcp.WithDescription("Execute a slash command in a channel (e.g., /remind, /poll)"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to execute command in")),
mcp.WithString("command", mcp.Required(), mcp.Description("Slash command to execute (e.g., /remind @channel meeting in 10 minutes)")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: ExecuteSlashCommandTool, Handler: ExecuteSlashCommandFn},
}
for _, t := range tools {
Tool.RegisterWrite(t)
}
}
func ExecuteSlashCommandFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Command] Called ExecuteSlashCommandFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
command, err := params.GetString(args, "command")
if err != nil {
return to.Error(fmt.Errorf("[command] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
resp, err := client.ExecuteSlashCommand(ctx, channelID, command)
if err != nil {
return to.Error(fmt.Errorf("[command] failed to execute: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"response": resp.ResponseType,
"text": resp.Text,
"goto_location": resp.GotoLocation,
}), nil
}
+115
View File
@@ -0,0 +1,115 @@
package dm
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"
)
var Tool = tool.New()
const (
CreateDMToolName = "mattermost_create_dm"
CreateGroupDMToolName = "mattermost_create_group_dm"
)
var (
CreateDMTool = mcp.NewTool(
CreateDMToolName,
mcp.WithDescription("Create direct message channel with user"),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to DM with")),
)
CreateGroupDMTool = mcp.NewTool(
CreateGroupDMToolName,
mcp.WithDescription("Create group DM channel with multiple users"),
mcp.WithString("user_ids", mcp.Required(), mcp.Description("Comma-separated list of user IDs to include in group DM")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: CreateDMTool, Handler: CreateDMFn},
{Tool: CreateGroupDMTool, Handler: CreateGroupDMFn},
}
for _, t := range tools {
Tool.RegisterWrite(t)
}
}
func CreateDMFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DM] Called CreateDMFn")
args := req.GetArguments()
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
}
// Get current user to get our own user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[me] failed to get current user: %v", err)), nil
}
// Create direct message channel between current user and target user
channel, err := client.CreateDirectChannel(ctx, me.Id, userId)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to create DM: %v", err)), nil
}
return to.Result(SlimChannel(channel)), nil
}
func CreateGroupDMFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DM] Called CreateGroupDMFn")
args := req.GetArguments()
userIDsStr, err := params.GetString(args, "user_ids")
if err != nil {
return to.Error(fmt.Errorf("[user_ids] %v", err)), nil
}
// Parse comma-separated user IDs
userIDs := strings.Split(userIDsStr, ",")
if len(userIDs) < 2 {
return to.Error(fmt.Errorf("[user_ids] at least 2 user IDs required for group DM (got %d)", len(userIDs))), nil
}
// Trim whitespace from each ID
for i, id := range userIDs {
userIDs[i] = strings.TrimSpace(id)
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Create group DM channel
channel, err := client.CreateGroupChannel(ctx, userIDs)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to create group DM: %v", err)), nil
}
return to.Result(SlimChannel(channel)), nil
}
+77
View File
@@ -0,0 +1,77 @@
package dm
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: "user1__user2",
DisplayName: "user1, user2",
Type: model.ChannelTypeDirect,
TeamId: "",
}
slim := SlimChannel(c)
assert.NotNil(t, slim)
assert.Equal(t, "channel123", slim["id"])
assert.Equal(t, "user1__user2", slim["name"])
assert.Equal(t, "user1, user2", slim["display_name"])
assert.Equal(t, model.ChannelTypeDirect, slim["type"])
assert.Equal(t, "", slim["team_id"])
}
func TestSlimChannel_Nil(t *testing.T) {
slim := SlimChannel(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 1)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[CreateDMToolName], "CreateDM tool should be registered")
}
func TestCreateDMFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": "user123",
},
},
}
result, err := CreateDMFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestCreateDMFn_MissingUserId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: CreateDMToolName,
Arguments: map[string]interface{}{},
},
}
result, err := CreateDMFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+16
View File
@@ -0,0 +1,16 @@
package dm
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": c.Type,
"team_id": c.TeamId,
}
}
+191
View File
@@ -0,0 +1,191 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/karti-ai/mattermost-mcp-server/pkg/file"
"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 (
UploadFileToolName = "mattermost_upload_file"
DownloadFileToolName = "mattermost_download_file"
)
var (
UploadFileTool = mcp.NewTool(
UploadFileToolName,
mcp.WithDescription("Upload file to channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to upload file to")),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Local file path to upload")),
mcp.WithString("message", mcp.Description("Message to post with file (optional)")),
)
DownloadFileTool = mcp.NewTool(
DownloadFileToolName,
mcp.WithDescription("Download file from Mattermost"),
mcp.WithString("file_id", mcp.Required(), mcp.Description("File ID to download")),
mcp.WithString("download_path", mcp.Required(), mcp.Description("Local path where file should be saved")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: UploadFileTool, Handler: UploadFileFn},
{Tool: DownloadFileTool, Handler: DownloadFileFn},
}
for _, t := range tools {
Tool.RegisterWrite(t)
}
}
func UploadFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[File] Called UploadFileFn")
args := req.GetArguments()
channelId, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
filePath, err := params.GetString(args, "file_path")
if err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
message := params.GetOptionalString(args, "message", "")
if !file.IsValidPath(filePath) {
return to.Error(fmt.Errorf("[file_path] path traversal detected: %s", filePath)), nil
}
fileInfo, err := file.GetFileInfo(filePath)
if err != nil {
return to.Error(fmt.Errorf("[file_path] failed to access file: %v", err)), nil
}
if fileInfo.IsDir() {
return to.Error(fmt.Errorf("[file_path] path is a directory, not a file: %s", filePath)), nil
}
if err := file.ValidateFileSize(fileInfo.Size()); err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
if err := file.ValidateFilename(fileInfo.Name()); err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
data, err := os.ReadFile(filePath)
if err != nil {
return to.Error(fmt.Errorf("[file_path] failed to read file: %v", err)), nil
}
if _, err := file.ValidateMimeType(data); err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
filename := filepath.Base(filePath)
uploadResp, err := client.UploadFile(ctx, data, channelId, filename)
if err != nil {
return to.Error(fmt.Errorf("[upload] failed to upload file: %v", err)), nil
}
if message != "" && len(uploadResp.FileInfos) > 0 {
post := &model.Post{
ChannelId: channelId,
Message: message,
FileIds: []string{uploadResp.FileInfos[0].Id},
}
_, err := client.CreatePost(ctx, post)
if err != nil {
return to.Result(map[string]interface{}{
"success": true,
"file_id": uploadResp.FileInfos[0].Id,
"file_infos": SlimFileInfos(uploadResp.FileInfos),
"warning": fmt.Sprintf("File uploaded but message failed to post: %v", err),
}), nil
}
}
return to.Result(map[string]interface{}{
"success": true,
"file_id": uploadResp.FileInfos[0].Id,
"file_infos": SlimFileInfos(uploadResp.FileInfos),
}), nil
}
func DownloadFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[File] Called DownloadFileFn")
args := req.GetArguments()
fileId, err := params.GetString(args, "file_id")
if err != nil {
return to.Error(fmt.Errorf("[file_id] %v", err)), nil
}
downloadPath, err := params.GetString(args, "download_path")
if err != nil {
return to.Error(fmt.Errorf("[download_path] %v", err)), nil
}
if !file.IsValidPath(downloadPath) {
return to.Error(fmt.Errorf("[download_path] path traversal detected: %s", downloadPath)), nil
}
if file.Exists(downloadPath) {
return to.Error(fmt.Errorf("[download_path] file already exists: %s", downloadPath)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
data, _, err := client.GetFile(ctx, fileId)
if err != nil {
return to.Error(fmt.Errorf("[download] failed to download file: %v", err)), nil
}
if int64(len(data)) > file.MaxFileSize {
return to.Error(fmt.Errorf("[download] downloaded file size %d exceeds maximum allowed %d", len(data), file.MaxFileSize)), nil
}
if err := file.CheckDiskSpace(downloadPath, int64(len(data))); err != nil {
return to.Error(fmt.Errorf("[download_path] %v", err)), nil
}
if err := os.WriteFile(downloadPath, data, 0644); err != nil {
return to.Error(fmt.Errorf("[download_path] failed to save file: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"file_id": fileId,
"download_path": downloadPath,
"size": len(data),
}), nil
}
+494
View File
@@ -0,0 +1,494 @@
package file
import (
"os"
"path/filepath"
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/file"
"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 TestSlimFileInfo(t *testing.T) {
f := &model.FileInfo{
Id: "file123",
Name: "test.pdf",
Extension: "pdf",
Size: 1024,
MimeType: "application/pdf",
ChannelId: "channel456",
CreateAt: 1234567890000,
}
slim := SlimFileInfo(f)
assert.NotNil(t, slim)
assert.Equal(t, "file123", slim["id"])
assert.Equal(t, "test.pdf", slim["name"])
assert.Equal(t, "pdf", slim["extension"])
assert.Equal(t, int64(1024), slim["size"])
assert.Equal(t, "application/pdf", slim["mime_type"])
assert.Equal(t, "channel456", slim["channel_id"])
assert.Equal(t, int64(1234567890000), slim["create_at"])
}
func TestSlimFileInfo_Nil(t *testing.T) {
slim := SlimFileInfo(nil)
assert.Nil(t, slim)
}
func TestSlimFileInfos(t *testing.T) {
infos := []*model.FileInfo{
{
Id: "file1",
Name: "test1.pdf",
Extension: "pdf",
Size: 1024,
MimeType: "application/pdf",
ChannelId: "channel1",
CreateAt: 1234567890000,
},
{
Id: "file2",
Name: "test2.png",
Extension: "png",
Size: 2048,
MimeType: "image/png",
ChannelId: "channel2",
CreateAt: 1234567890001,
},
}
slim := SlimFileInfos(infos)
assert.NotNil(t, slim)
assert.Len(t, slim, 2)
assert.Equal(t, "file1", slim[0]["id"])
assert.Equal(t, "file2", slim[1]["id"])
}
func TestSlimFileInfos_Nil(t *testing.T) {
slim := SlimFileInfos(nil)
assert.Nil(t, slim)
}
func TestSlimFileInfos_WithNilItem(t *testing.T) {
infos := []*model.FileInfo{
{
Id: "file1",
Name: "test1.pdf",
Extension: "pdf",
Size: 1024,
MimeType: "application/pdf",
ChannelId: "channel1",
CreateAt: 1234567890000,
},
nil,
{
Id: "file2",
Name: "test2.png",
Extension: "png",
Size: 2048,
MimeType: "image/png",
ChannelId: "channel2",
CreateAt: 1234567890001,
},
}
slim := SlimFileInfos(infos)
assert.NotNil(t, slim)
assert.Len(t, slim, 2)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 2)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[UploadFileToolName], "UploadFile tool should be registered")
assert.True(t, toolNames[DownloadFileToolName], "DownloadFile tool should be registered")
}
func TestUploadFileFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": "/tmp/test.txt",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestUploadFileFn_MissingChannelId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"file_path": "/tmp/test.txt",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestUploadFileFn_MissingFilePath(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestUploadFileFn_PathTraversal(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": "../../../etc/passwd",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "path traversal")
}
func TestUploadFileFn_AbsolutePath(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
assert.NoError(t, err)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": testFile,
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "client not initialized")
}
func TestUploadFileFn_DangerousExtension(t *testing.T) {
tmpDir := t.TempDir()
dangerousFile := filepath.Join(tmpDir, "malicious.sh")
err := os.WriteFile(dangerousFile, []byte("#!/bin/bash\necho 'pwned'"), 0644)
assert.NoError(t, err)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": dangerousFile,
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "not allowed")
}
func TestUploadFileFn_NonExistentFile(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": "/tmp/nonexistent_file_12345.txt",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "failed to access")
}
func TestUploadFileFn_DirectoryInsteadOfFile(t *testing.T) {
tmpDir := t.TempDir()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": tmpDir,
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "directory")
}
func TestDownloadFileFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
"download_path": "/tmp/downloaded.txt",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDownloadFileFn_MissingFileId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"download_path": "/tmp/downloaded.txt",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDownloadFileFn_MissingDownloadPath(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDownloadFileFn_PathTraversal(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
"download_path": "../../../etc/passwd",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "path traversal")
}
func TestDownloadFileFn_FileAlreadyExists(t *testing.T) {
tmpDir := t.TempDir()
existingFile := filepath.Join(tmpDir, "exists.txt")
err := os.WriteFile(existingFile, []byte("existing content"), 0644)
assert.NoError(t, err)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
"download_path": existingFile,
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "already exists")
}
func TestFileSecurityValidation(t *testing.T) {
t.Run("IsValidPath allows safe relative paths", func(t *testing.T) {
assert.True(t, file.IsValidPath("document.pdf"))
assert.True(t, file.IsValidPath("subdir/file.png"))
assert.True(t, file.IsValidPath("./file.txt"))
})
t.Run("IsValidPath blocks path traversal", func(t *testing.T) {
assert.False(t, file.IsValidPath("../file.txt"))
assert.False(t, file.IsValidPath("../../etc/passwd"))
assert.False(t, file.IsValidPath("subdir/../../../etc/passwd"))
})
t.Run("IsValidPath allows absolute paths without traversal", func(t *testing.T) {
assert.True(t, file.IsValidPath("/etc/passwd"))
assert.True(t, file.IsValidPath("/tmp/file.txt"))
})
t.Run("IsDangerousExtension blocks dangerous types", func(t *testing.T) {
assert.True(t, file.IsDangerousExtension("file.exe"))
assert.True(t, file.IsDangerousExtension("script.sh"))
assert.True(t, file.IsDangerousExtension("run.bat"))
assert.True(t, file.IsDangerousExtension("malicious.js"))
})
t.Run("IsDangerousExtension allows safe types", func(t *testing.T) {
assert.False(t, file.IsDangerousExtension("document.pdf"))
assert.False(t, file.IsDangerousExtension("image.png"))
assert.False(t, file.IsDangerousExtension("notes.txt"))
})
t.Run("ValidateFileSize blocks oversized files", func(t *testing.T) {
err := file.ValidateFileSize(100 * 1024 * 1024) // 100MB
assert.Error(t, err)
assert.Contains(t, err.Error(), "exceeds maximum")
})
t.Run("ValidateFileSize allows files under limit", func(t *testing.T) {
err := file.ValidateFileSize(10 * 1024 * 1024) // 10MB
assert.NoError(t, err)
})
t.Run("IsAllowedMimeType allows whitelist types", func(t *testing.T) {
assert.True(t, file.IsAllowedMimeType("image/jpeg"))
assert.True(t, file.IsAllowedMimeType("image/png"))
assert.True(t, file.IsAllowedMimeType("application/pdf"))
assert.True(t, file.IsAllowedMimeType("text/plain"))
})
t.Run("IsAllowedMimeType blocks non-whitelist types", func(t *testing.T) {
assert.False(t, file.IsAllowedMimeType("application/x-executable"))
assert.False(t, file.IsAllowedMimeType("application/x-sh"))
assert.False(t, file.IsAllowedMimeType("text/html"))
})
t.Run("DetectMimeType detects file types correctly", func(t *testing.T) {
pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
assert.Equal(t, "image/png", file.DetectMimeType(pngData))
textData := []byte("Hello, World!")
assert.Equal(t, "text/plain; charset=utf-8", file.DetectMimeType(textData))
})
}
func TestSanitizePath(t *testing.T) {
t.Run("SanitizePath returns clean path for valid input", func(t *testing.T) {
path, err := file.SanitizePath("subdir//file.txt")
assert.NoError(t, err)
assert.Equal(t, "subdir/file.txt", path)
})
t.Run("SanitizePath rejects traversal attempts", func(t *testing.T) {
_, err := file.SanitizePath("../file.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal")
})
t.Run("SanitizePath allows absolute paths without traversal", func(t *testing.T) {
path, err := file.SanitizePath("/etc/passwd")
assert.NoError(t, err)
assert.Equal(t, "/etc/passwd", path)
})
}
func TestValidateFilename(t *testing.T) {
t.Run("ValidateFilename rejects empty filename", func(t *testing.T) {
err := file.ValidateFilename("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot be empty")
})
t.Run("ValidateFilename rejects dangerous extensions", func(t *testing.T) {
err := file.ValidateFilename("malicious.exe")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not allowed")
})
t.Run("ValidateFilename accepts safe filenames", func(t *testing.T) {
err := file.ValidateFilename("document.pdf")
assert.NoError(t, err)
})
t.Run("ValidateFilename rejects null bytes", func(t *testing.T) {
err := file.ValidateFilename("file\x00.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid characters")
})
}
func TestCheckDiskSpace(t *testing.T) {
t.Run("CheckDiskSpace succeeds for writable directory", func(t *testing.T) {
tmpDir := t.TempDir()
testPath := filepath.Join(tmpDir, "subdir", "file.txt")
err := file.CheckDiskSpace(testPath, 1024)
assert.NoError(t, err)
})
}
func TestValidateMimeType(t *testing.T) {
t.Run("ValidateMimeType accepts PNG images", func(t *testing.T) {
pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52}
mimeType, err := file.ValidateMimeType(pngData)
assert.NoError(t, err)
assert.Equal(t, "image/png", mimeType)
})
t.Run("ValidateMimeType accepts plain text", func(t *testing.T) {
textData := []byte("Hello, World! This is plain text.")
mimeType, err := file.ValidateMimeType(textData)
assert.NoError(t, err)
assert.Contains(t, mimeType, "text/plain")
})
}
+31
View File
@@ -0,0 +1,31 @@
package file
import "github.com/mattermost/mattermost-server/v6/model"
func SlimFileInfo(f *model.FileInfo) map[string]interface{} {
if f == nil {
return nil
}
return map[string]interface{}{
"id": f.Id,
"name": f.Name,
"extension": f.Extension,
"size": f.Size,
"mime_type": f.MimeType,
"channel_id": f.ChannelId,
"create_at": f.CreateAt,
}
}
func SlimFileInfos(infos []*model.FileInfo) []map[string]interface{} {
if infos == nil {
return nil
}
result := make([]map[string]interface{}, 0, len(infos))
for _, info := range infos {
if slim := SlimFileInfo(info); slim != nil {
result = append(result, slim)
}
}
return result
}
File diff suppressed because it is too large Load Diff
+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
}
+99
View File
@@ -0,0 +1,99 @@
package operation
import (
"github.com/karti-ai/mattermost-mcp-server/operation/channel"
"github.com/karti-ai/mattermost-mcp-server/operation/command"
"github.com/karti-ai/mattermost-mcp-server/operation/dm"
"github.com/karti-ai/mattermost-mcp-server/operation/file"
"github.com/karti-ai/mattermost-mcp-server/operation/messaging"
"github.com/karti-ai/mattermost-mcp-server/operation/outgoing"
"github.com/karti-ai/mattermost-mcp-server/operation/reaction"
"github.com/karti-ai/mattermost-mcp-server/operation/system"
"github.com/karti-ai/mattermost-mcp-server/operation/team"
"github.com/karti-ai/mattermost-mcp-server/operation/user"
"github.com/karti-ai/mattermost-mcp-server/operation/webhook"
"github.com/karti-ai/mattermost-mcp-server/pkg/flag"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/server"
)
func Register() []server.ServerTool {
log.Infof("Registering tools for Mattermost MCP server %s", flag.Version)
tools := tool.New()
for _, t := range messaging.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range file.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range dm.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range reaction.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range channel.AdminTool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range messaging.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range file.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range channel.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range user.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range team.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range dm.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range reaction.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range webhook.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range webhook.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range command.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range outgoing.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range outgoing.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range system.Tool.ReadTools() {
tools.RegisterRead(t)
}
return tools.Tools()
}
+187
View File
@@ -0,0 +1,187 @@
package outgoing
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"
)
var Tool = tool.New()
const (
CreateOutgoingWebhookToolName = "mattermost_create_outgoing_webhook"
ListOutgoingWebhooksToolName = "mattermost_list_outgoing_webhooks"
DeleteOutgoingWebhookToolName = "mattermost_delete_outgoing_webhook"
)
var (
CreateOutgoingWebhookTool = mcp.NewTool(
CreateOutgoingWebhookToolName,
mcp.WithDescription("Create an outgoing webhook that triggers on specific words"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID for the webhook")),
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the webhook")),
mcp.WithString("trigger_words", mcp.Required(), mcp.Description("Comma-separated list of words that trigger the webhook")),
mcp.WithString("callback_url", mcp.Required(), mcp.Description("URL to POST to when triggered")),
)
ListOutgoingWebhooksTool = mcp.NewTool(
ListOutgoingWebhooksToolName,
mcp.WithDescription("List outgoing webhooks for a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list webhooks for")),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Items per page (default 20, max 100)")),
)
DeleteOutgoingWebhookTool = mcp.NewTool(
DeleteOutgoingWebhookToolName,
mcp.WithDescription("Delete an outgoing webhook"),
mcp.WithString("webhook_id", mcp.Required(), mcp.Description("Webhook ID to delete")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: CreateOutgoingWebhookTool, Handler: CreateOutgoingWebhookFn},
{Tool: ListOutgoingWebhooksTool, Handler: ListOutgoingWebhooksFn},
{Tool: DeleteOutgoingWebhookTool, Handler: DeleteOutgoingWebhookFn},
}
for _, t := range tools {
if t.Tool.Name == ListOutgoingWebhooksToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func CreateOutgoingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Outgoing] Called CreateOutgoingWebhookFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
displayName, err := params.GetString(args, "display_name")
if err != nil {
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
}
triggerWordsStr, err := params.GetString(args, "trigger_words")
if err != nil {
return to.Error(fmt.Errorf("[trigger_words] %v", err)), nil
}
triggerWords := strings.Split(triggerWordsStr, ",")
for i, word := range triggerWords {
triggerWords[i] = strings.TrimSpace(word)
}
callbackURL, err := params.GetString(args, "callback_url")
if err != nil {
return to.Error(fmt.Errorf("[callback_url] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hook, err := client.CreateOutgoingWebhook(ctx, teamID, displayName, triggerWords, callbackURL)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to create: %v", err)), nil
}
return to.Result(map[string]interface{}{
"id": hook.Id,
"team_id": hook.TeamId,
"display_name": hook.DisplayName,
"trigger_words": hook.TriggerWords,
"callback_url": callbackURL,
"message": "Outgoing webhook created successfully",
}), nil
}
func ListOutgoingWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Outgoing] Called ListOutgoingWebhooksFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 20)
if perPage > 100 {
perPage = 100
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hooks, err := client.ListOutgoingWebhooks(ctx, teamID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to list: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(hooks))
for _, hook := range hooks {
results = append(results, map[string]interface{}{
"id": hook.Id,
"team_id": hook.TeamId,
"display_name": hook.DisplayName,
"trigger_words": hook.TriggerWords,
})
}
return to.Result(map[string]interface{}{
"webhooks": results,
"count": len(results),
"page": page,
"per_page": perPage,
}), nil
}
func DeleteOutgoingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Outgoing] Called DeleteOutgoingWebhookFn")
args := req.GetArguments()
webhookID, err := params.GetString(args, "webhook_id")
if err != nil {
return to.Error(fmt.Errorf("[webhook_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeleteOutgoingWebhook(ctx, webhookID)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to delete: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"webhook_id": webhookID,
"message": "Outgoing webhook deleted successfully",
}), nil
}
+181
View File
@@ -0,0 +1,181 @@
package reaction
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 Tool = tool.New()
const (
AddReactionToolName = "mattermost_add_reaction"
RemoveReactionToolName = "mattermost_remove_reaction"
ListReactionsToolName = "mattermost_list_reactions"
)
var (
AddReactionTool = mcp.NewTool(
AddReactionToolName,
mcp.WithDescription("Add emoji reaction to post"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to add reaction to")),
mcp.WithString("emoji_name", mcp.Required(), mcp.Description("Emoji name without colons (e.g., thumbsup, not :thumbsup:)")),
)
RemoveReactionTool = mcp.NewTool(
RemoveReactionToolName,
mcp.WithDescription("Remove emoji reaction from post"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to remove reaction from")),
mcp.WithString("emoji_name", mcp.Required(), mcp.Description("Emoji name without colons")),
)
ListReactionsTool = mcp.NewTool(
ListReactionsToolName,
mcp.WithDescription("List all emoji reactions on a post"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to get reactions for")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: AddReactionTool, Handler: AddReactionFn},
{Tool: RemoveReactionTool, Handler: RemoveReactionFn},
{Tool: ListReactionsTool, Handler: ListReactionsFn},
}
for _, t := range tools {
if t.Tool.Name == ListReactionsToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func AddReactionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Reaction] Called AddReactionFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
emojiName, err := params.GetString(args, "emoji_name")
if err != nil {
return to.Error(fmt.Errorf("[emoji_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
reaction := &model.Reaction{
UserId: me.Id,
PostId: postId,
EmojiName: emojiName,
}
result, err := client.SaveReaction(ctx, reaction)
if err != nil {
return to.Error(fmt.Errorf("[reaction] failed to add reaction: %v", err)), nil
}
return to.Result(SlimReaction(result)), nil
}
func RemoveReactionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Reaction] Called RemoveReactionFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
emojiName, err := params.GetString(args, "emoji_name")
if err != nil {
return to.Error(fmt.Errorf("[emoji_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
reaction := &model.Reaction{
UserId: me.Id,
PostId: postId,
EmojiName: emojiName,
}
err = client.DeleteReaction(ctx, reaction)
if err != nil {
return to.Error(fmt.Errorf("[reaction] failed to remove reaction: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"emoji_name": emojiName,
"message": "Reaction removed successfully",
}), nil
}
func ListReactionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Reaction] Called ListReactionsFn")
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
}
reactions, err := client.GetReactions(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[reaction] failed to list reactions: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(reactions))
for _, r := range reactions {
results = append(results, SlimReaction(r))
}
return to.Result(map[string]interface{}{
"reactions": results,
"count": len(results),
"post_id": postId,
}), nil
}
+78
View File
@@ -0,0 +1,78 @@
package reaction
import (
"testing"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimReaction(t *testing.T) {
tests := []struct {
name string
reaction *model.Reaction
expected map[string]interface{}
}{
{
name: "valid reaction",
reaction: &model.Reaction{
UserId: "user123",
PostId: "post456",
EmojiName: "thumbsup",
CreateAt: 1234567890,
},
expected: map[string]interface{}{
"user_id": "user123",
"post_id": "post456",
"emoji_name": "thumbsup",
"create_at": int64(1234567890),
},
},
{
name: "nil reaction",
reaction: nil,
expected: nil,
},
{
name: "reaction with different emoji",
reaction: &model.Reaction{
UserId: "user789",
PostId: "post012",
EmojiName: "rocket",
CreateAt: 9876543210,
},
expected: map[string]interface{}{
"user_id": "user789",
"post_id": "post012",
"emoji_name": "rocket",
"create_at": int64(9876543210),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SlimReaction(tt.reaction)
assert.Equal(t, tt.expected, result)
})
}
}
func TestToolConstants(t *testing.T) {
assert.Equal(t, "mattermost_add_reaction", AddReactionToolName)
assert.Equal(t, "mattermost_remove_reaction", RemoveReactionToolName)
}
func TestToolRegistration(t *testing.T) {
// Verify tools are registered
tools := Tool.Tools()
assert.Len(t, tools, 2)
toolNames := make([]string, len(tools))
for i, t := range tools {
toolNames[i] = t.Tool.Name
}
assert.Contains(t, toolNames, AddReactionToolName)
assert.Contains(t, toolNames, RemoveReactionToolName)
}
+15
View File
@@ -0,0 +1,15 @@
package reaction
import "github.com/mattermost/mattermost-server/v6/model"
func SlimReaction(r *model.Reaction) map[string]interface{} {
if r == nil {
return nil
}
return map[string]interface{}{
"user_id": r.UserId,
"post_id": r.PostId,
"emoji_name": r.EmojiName,
"create_at": r.CreateAt,
}
}
+100
View File
@@ -0,0 +1,100 @@
package system
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 (
GetSystemLogsToolName = "mattermost_get_system_logs"
GetServerConfigToolName = "mattermost_get_server_config"
)
var (
GetSystemLogsTool = mcp.NewTool(
GetSystemLogsToolName,
mcp.WithDescription("Get system logs (requires admin privileges)"),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Log lines per page (default 100, max 500)")),
)
GetServerConfigTool = mcp.NewTool(
GetServerConfigToolName,
mcp.WithDescription("Get server configuration (requires admin privileges)"),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: GetSystemLogsTool, Handler: GetSystemLogsFn},
{Tool: GetServerConfigTool, Handler: GetServerConfigFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetSystemLogsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[System] Called GetSystemLogsFn")
args := req.GetArguments()
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 100)
if perPage > 500 {
perPage = 500
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
logs, _, err := client.GetSystemLogs(ctx, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[system] failed to get logs: %v", err)), nil
}
return to.Result(map[string]interface{}{
"logs": logs,
"count": len(logs),
"page": page,
"per_page": perPage,
}), nil
}
func GetServerConfigFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[System] Called GetServerConfigFn")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
config, err := client.GetConfig(ctx)
if err != nil {
return to.Error(fmt.Errorf("[system] failed to get config: %v", err)), nil
}
return to.Result(map[string]interface{}{
"site_name": config.TeamSettings.SiteName,
"max_notifications_per_channel": config.TeamSettings.MaxNotificationsPerChannel,
"enable_custom_emoji": config.ServiceSettings.EnableCustomEmoji,
"enable_link_previews": config.ServiceSettings.EnableLinkPreviews,
"enable_public_channels": config.TeamSettings.EnableOpenServer,
}), nil
}
+15
View File
@@ -0,0 +1,15 @@
package team
import "github.com/mattermost/mattermost-server/v6/model"
func SlimTeam(t *model.Team) map[string]interface{} {
if t == nil {
return nil
}
return map[string]interface{}{
"id": t.Id,
"name": t.Name,
"display_name": t.DisplayName,
"description": t.Description,
}
}
+245
View File
@@ -0,0 +1,245 @@
package team
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 (
ListTeamsToolName = "mattermost_list_teams"
ListTeamMembersToolName = "mattermost_list_team_members"
InviteUserToTeamToolName = "mattermost_invite_user_to_team"
RemoveUserFromTeamToolName = "mattermost_remove_user_from_team"
GetTeamStatsToolName = "mattermost_get_team_stats"
)
var (
ListTeamsTool = mcp.NewTool(
ListTeamsToolName,
mcp.WithDescription("List all teams the bot has access to"),
)
ListTeamMembersTool = mcp.NewTool(
ListTeamMembersToolName,
mcp.WithDescription("List all members of a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list members for")),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Members per page (default 60, max 200)")),
)
InviteUserToTeamTool = mcp.NewTool(
InviteUserToTeamToolName,
mcp.WithDescription("Invite/add a user to a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to invite user to")),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to invite")),
)
RemoveUserFromTeamTool = mcp.NewTool(
RemoveUserFromTeamToolName,
mcp.WithDescription("Remove a user from a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to remove user from")),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to remove")),
)
GetTeamStatsTool = mcp.NewTool(
GetTeamStatsToolName,
mcp.WithDescription("Get statistics for a team (member count, etc.)"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to get stats for")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: ListTeamsTool, Handler: ListTeamsFn},
{Tool: ListTeamMembersTool, Handler: ListTeamMembersFn},
{Tool: GetTeamStatsTool, Handler: GetTeamStatsFn},
{Tool: InviteUserToTeamTool, Handler: InviteUserToTeamFn},
{Tool: RemoveUserFromTeamTool, Handler: RemoveUserFromTeamFn},
}
for _, t := range tools {
if t.Tool.Name == InviteUserToTeamToolName || t.Tool.Name == RemoveUserFromTeamToolName {
Tool.RegisterWrite(t)
} else {
Tool.RegisterRead(t)
}
}
}
func ListTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called ListTeamsFn")
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
}
teams, err := client.GetTeamsForUser(ctx, user.Id)
if err != nil {
return to.Error(fmt.Errorf("[teams] failed to list teams: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(teams))
for _, t := range teams {
results = append(results, SlimTeam(t))
}
return to.Result(map[string]interface{}{
"teams": results,
"count": len(results),
}), nil
}
func ListTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called ListTeamMembersFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_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.ListTeamMembers(ctx, teamID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[team] failed to list members: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(members))
for _, m := range members {
results = append(results, map[string]interface{}{
"user_id": m.UserId,
"roles": m.Roles,
})
}
return to.Result(map[string]interface{}{
"members": results,
"count": len(results),
"team_id": teamID,
"page": page,
"per_page": perPage,
}), nil
}
func InviteUserToTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called InviteUserToTeamFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_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
}
_, err = client.InviteUserToTeam(ctx, teamID, userID)
if err != nil {
return to.Error(fmt.Errorf("[team] failed to invite user: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"team_id": teamID,
"user_id": userID,
"message": "User invited to team successfully",
}), nil
}
func RemoveUserFromTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called RemoveUserFromTeamFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_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
}
err = client.RemoveUserFromTeam(ctx, teamID, userID)
if err != nil {
return to.Error(fmt.Errorf("[team] failed to remove user: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"team_id": teamID,
"user_id": userID,
"message": "User removed from team successfully",
}), nil
}
func GetTeamStatsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called GetTeamStatsFn")
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
}
stats, err := client.GetTeamStats(ctx, teamID)
if err != nil {
return to.Error(fmt.Errorf("[team] failed to get stats: %v", err)), nil
}
return to.Result(map[string]interface{}{
"team_id": teamID,
"total_members": stats.TotalMemberCount,
"active_members": stats.ActiveMemberCount,
}), nil
}
+59
View File
@@ -0,0 +1,59 @@
package team
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 TestSlimTeam(t *testing.T) {
team := &model.Team{
Id: "team123",
Name: "my-team",
DisplayName: "My Team",
Description: "A test team for unit testing",
}
slim := SlimTeam(team)
assert.NotNil(t, slim)
assert.Equal(t, "team123", slim["id"])
assert.Equal(t, "my-team", slim["name"])
assert.Equal(t, "My Team", slim["display_name"])
assert.Equal(t, "A test team for unit testing", slim["description"])
}
func TestSlimTeam_Nil(t *testing.T) {
slim := SlimTeam(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 1)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[ListTeamsToolName], "ListTeams tool should be registered")
}
func TestListTeamsFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: ListTeamsToolName,
Arguments: map[string]interface{}{},
},
}
result, err := ListTeamsFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+17
View File
@@ -0,0 +1,17 @@
package user
import "github.com/mattermost/mattermost-server/v6/model"
func SlimUser(u *model.User) map[string]interface{} {
if u == nil {
return nil
}
return map[string]interface{}{
"id": u.Id,
"username": u.Username,
"email": u.Email,
"first_name": u.FirstName,
"last_name": u.LastName,
"roles": u.Roles,
}
}
+171
View File
@@ -0,0 +1,171 @@
package user
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/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetUserStatusToolName = "mattermost_get_user_status"
UpdateUserStatusToolName = "mattermost_update_user_status"
GetUsersStatusBulkToolName = "mattermost_get_users_status_bulk"
)
var (
GetUserStatusTool = mcp.NewTool(
GetUserStatusToolName,
mcp.WithDescription("Get the online status of a user (online, away, dnd, offline)"),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to check status for")),
)
UpdateUserStatusTool = mcp.NewTool(
UpdateUserStatusToolName,
mcp.WithDescription("Update your status (online, away, dnd, offline)"),
mcp.WithString("status", mcp.Required(), mcp.Description("Status to set: online, away, dnd, or offline")),
)
GetUsersStatusBulkTool = mcp.NewTool(
GetUsersStatusBulkToolName,
mcp.WithDescription("Get status for multiple users at once (up to 100)"),
mcp.WithString("user_ids", mcp.Required(), mcp.Description("Comma-separated list of user IDs (max 100)")),
)
)
func init() {
registerStatusTools()
}
func registerStatusTools() {
tools := []server.ServerTool{
{Tool: GetUserStatusTool, Handler: GetUserStatusFn},
{Tool: UpdateUserStatusTool, Handler: UpdateUserStatusFn},
{Tool: GetUsersStatusBulkTool, Handler: GetUsersStatusBulkFn},
}
for _, t := range tools {
if t.Tool.Name == UpdateUserStatusToolName {
Tool.RegisterWrite(t)
} else {
Tool.RegisterRead(t)
}
}
}
func GetUserStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserStatusFn")
args := req.GetArguments()
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
}
status, err := client.GetUserStatus(ctx, userID)
if err != nil {
return to.Error(fmt.Errorf("[status] failed to get user status: %v", err)), nil
}
return to.Result(map[string]interface{}{
"user_id": status.UserId,
"status": status.Status,
"manual": status.Manual,
"last_activity_at": status.LastActivityAt,
}), nil
}
func UpdateUserStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called UpdateUserStatusFn")
args := req.GetArguments()
status, err := params.GetString(args, "status")
if err != nil {
return to.Error(fmt.Errorf("[status] %v", err)), nil
}
// Validate status value
validStatuses := map[string]bool{"online": true, "away": true, "dnd": true, "offline": true}
if !validStatuses[status] {
return to.Error(fmt.Errorf("[status] invalid status '%s', must be one of: online, away, dnd, offline", status)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[me] failed to get current user: %v", err)), nil
}
updatedStatus, err := client.UpdateUserStatus(ctx, me.Id, status)
if err != nil {
return to.Error(fmt.Errorf("[status] failed to update status: %v", err)), nil
}
return to.Result(map[string]interface{}{
"user_id": updatedStatus.UserId,
"status": updatedStatus.Status,
"message": "Status updated successfully",
}), nil
}
func GetUsersStatusBulkFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUsersStatusBulkFn")
args := req.GetArguments()
userIDsStr, err := params.GetString(args, "user_ids")
if err != nil {
return to.Error(fmt.Errorf("[user_ids] %v", err)), nil
}
userIDs := strings.Split(userIDsStr, ",")
if len(userIDs) > 100 {
return to.Error(fmt.Errorf("[user_ids] too many user IDs (max 100, got %d)", len(userIDs))), nil
}
for i, id := range userIDs {
userIDs[i] = strings.TrimSpace(id)
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
statuses, err := client.GetUsersStatus(ctx, userIDs)
if err != nil {
return to.Error(fmt.Errorf("[status] failed to get users status: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(statuses))
for _, status := range statuses {
results = append(results, map[string]interface{}{
"user_id": status.UserId,
"status": status.Status,
"manual": status.Manual,
"last_activity_at": status.LastActivityAt,
})
}
return to.Result(map[string]interface{}{
"statuses": results,
"count": len(results),
}), nil
}
+129
View File
@@ -0,0 +1,129 @@
package user
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 Tool = tool.New()
const (
SearchUsersToolName = "mattermost_search_users"
GetUserToolName = "mattermost_get_user"
)
var (
SearchUsersTool = mcp.NewTool(
SearchUsersToolName,
mcp.WithDescription("Search users by term"),
mcp.WithString("term", mcp.Required(), mcp.Description("Search term (username, email, name)")),
mcp.WithString("team_id", mcp.Description("Limit to team (optional)")),
mcp.WithNumber("limit", mcp.Description("Max results (default 30)")),
)
GetUserTool = mcp.NewTool(
GetUserToolName,
mcp.WithDescription("Get a specific user by ID or username"),
mcp.WithString("user_id", mcp.Description("User ID to look up (optional if username provided)")),
mcp.WithString("username", mcp.Description("Username to look up (optional if user_id provided)")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: SearchUsersTool, Handler: SearchUsersFn},
{Tool: GetUserTool, Handler: GetUserFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func SearchUsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called SearchUsersFn")
args := req.GetArguments()
term, err := params.GetString(args, "term")
if err != nil {
return to.Error(fmt.Errorf("[term] %v", err)), nil
}
teamID := params.GetOptionalString(args, "team_id", "")
limit := params.GetOptionalInt(args, "limit", 30)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
search := &model.UserSearch{
Term: term,
Limit: int(limit),
}
if teamID != "" {
search.TeamId = teamID
}
users, err := client.SearchUsers(ctx, search)
if err != nil {
return to.Error(fmt.Errorf("[users] failed to search users: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
results = append(results, SlimUser(u))
}
return to.Result(map[string]interface{}{
"users": results,
"count": len(results),
}), nil
}
func GetUserFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserFn")
args := req.GetArguments()
userID := params.GetOptionalString(args, "user_id", "")
username := params.GetOptionalString(args, "username", "")
if userID == "" && username == "" {
return to.Error(fmt.Errorf("[user] either user_id or username must be provided")), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
var user *model.User
var err error
if userID != "" {
user, err = client.GetUser(ctx, userID)
} else {
user, err = client.GetUserByUsername(ctx, username)
}
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get user: %v", err)), nil
}
return to.Result(SlimUser(user)), nil
}
+80
View File
@@ -0,0 +1,80 @@
package user
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 TestSlimUser(t *testing.T) {
u := &model.User{
Id: "user123",
Username: "johndoe",
Email: "john@example.com",
FirstName: "John",
LastName: "Doe",
Roles: "system_user",
}
slim := SlimUser(u)
assert.NotNil(t, slim)
assert.Equal(t, "user123", slim["id"])
assert.Equal(t, "johndoe", slim["username"])
assert.Equal(t, "john@example.com", slim["email"])
assert.Equal(t, "John", slim["first_name"])
assert.Equal(t, "Doe", slim["last_name"])
assert.Equal(t, "system_user", slim["roles"])
}
func TestSlimUser_Nil(t *testing.T) {
slim := SlimUser(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 2)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[SearchUsersToolName], "SearchUsers tool should be registered")
assert.True(t, toolNames[GetUserStatusToolName], "GetUserStatus tool should be registered")
}
func TestSearchUsersFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SearchUsersToolName,
Arguments: map[string]interface{}{
"term": "john",
},
},
}
result, err := SearchUsersFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestSearchUsersFn_MissingTerm(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SearchUsersToolName,
Arguments: map[string]interface{}{},
},
}
result, err := SearchUsersFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+168
View File
@@ -0,0 +1,168 @@
package webhook
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 (
CreateIncomingWebhookToolName = "mattermost_create_incoming_webhook"
ListIncomingWebhooksToolName = "mattermost_list_incoming_webhooks"
DeleteIncomingWebhookToolName = "mattermost_delete_incoming_webhook"
)
var (
CreateIncomingWebhookTool = mcp.NewTool(
CreateIncomingWebhookToolName,
mcp.WithDescription("Create an incoming webhook for a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to create webhook for")),
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the webhook")),
)
ListIncomingWebhooksTool = mcp.NewTool(
ListIncomingWebhooksToolName,
mcp.WithDescription("List incoming webhooks for a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list webhooks for")),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Items per page (default 20, max 100)")),
)
DeleteIncomingWebhookTool = mcp.NewTool(
DeleteIncomingWebhookToolName,
mcp.WithDescription("Delete an incoming webhook"),
mcp.WithString("webhook_id", mcp.Required(), mcp.Description("Webhook ID to delete")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: CreateIncomingWebhookTool, Handler: CreateIncomingWebhookFn},
{Tool: ListIncomingWebhooksTool, Handler: ListIncomingWebhooksFn},
{Tool: DeleteIncomingWebhookTool, Handler: DeleteIncomingWebhookFn},
}
for _, t := range tools {
if t.Tool.Name == ListIncomingWebhooksToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func CreateIncomingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Webhook] Called CreateIncomingWebhookFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
displayName, err := params.GetString(args, "display_name")
if err != nil {
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hook, err := client.CreateIncomingWebhook(ctx, channelID, displayName)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to create: %v", err)), nil
}
return to.Result(map[string]interface{}{
"id": hook.Id,
"channel_id": hook.ChannelId,
"display_name": hook.DisplayName,
"message": "Webhook created successfully - retrieve URL from Mattermost UI",
}), nil
}
func ListIncomingWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Webhook] Called ListIncomingWebhooksFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 20)
if perPage > 100 {
perPage = 100
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hooks, err := client.ListIncomingWebhooks(ctx, teamID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to list: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(hooks))
for _, hook := range hooks {
results = append(results, map[string]interface{}{
"id": hook.Id,
"channel_id": hook.ChannelId,
"display_name": hook.DisplayName,
"create_at": hook.CreateAt,
})
}
return to.Result(map[string]interface{}{
"webhooks": results,
"count": len(results),
"page": page,
"per_page": perPage,
}), nil
}
func DeleteIncomingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Webhook] Called DeleteIncomingWebhookFn")
args := req.GetArguments()
webhookID, err := params.GetString(args, "webhook_id")
if err != nil {
return to.Error(fmt.Errorf("[webhook_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeleteIncomingWebhook(ctx, webhookID)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to delete: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"webhook_id": webhookID,
"message": "Webhook deleted successfully",
}), nil
}