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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user