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:
Karti Tripathi
2026-04-15 21:14:47 -07:00
commit a4e51f6412
52 changed files with 10495 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Binary
mattermost-mcp-server
mcp-server
# Log files
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Build directories
dist/
build/
# Internal tracking and QA (not for repo)
.sisyphus/
.opencode/
evidence/
final-qa/
# Test reports (generated)
*test_report*.md
*test_results*.md
*token_test*.md
# Backup files
*.backup
*.bak
# Environment files
.env
.env.local
+70
View File
@@ -0,0 +1,70 @@
# Changelog
All notable changes to the Mattermost MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-04-12
### Added
- Initial release of Mattermost MCP Server
- Support for 22 MCP tools categorized as Read, Write, and Admin operations
#### Read Operations (11)
- `mattermost_list_channels` - List accessible channels in a team
- `mattermost_get_channel_by_name` - Get a channel by name in a team
- `mattermost_get_channel_info` - Get detailed channel information including member count
- `mattermost_search_users` - Search for users by username, email, or display name
- `mattermost_list_teams` - List all teams the bot has access to
- `mattermost_search_posts` - Search for posts/messages in a team using search terms
- `mattermost_get_thread` - Read all messages in a thread conversation
- `mattermost_get_unread_count` - Get unread message counts for all channels in a team
- `mattermost_mark_channel_read` - Mark a channel as read (clear unread notifications)
- `mattermost_get_user_status` - Get the online status of a user (online, away, dnd, offline)
- `mattermost_get_channel_messages` - Read message history from a channel
#### Write Operations (8)
- `mattermost_send_message` - Send a message to a channel or user
- `mattermost_edit_message` - Edit an existing message
- `mattermost_delete_message` - Delete a message from a channel
- `mattermost_create_dm` - Create a direct message channel with a user
- `mattermost_add_reaction` - Add an emoji reaction to a message
- `mattermost_remove_reaction` - Remove an emoji reaction from a message
- `mattermost_upload_file` - Upload a file to a channel or user
- `mattermost_download_file` - Download a file from a message
#### Admin Operations (4)
- `mattermost_create_channel` - Create a new channel (public or private) in a team
- `mattermost_invite_to_channel` - Invite a user to a channel
- `mattermost_delete_channel` - Delete/archive a channel (soft delete by default)
- `mattermost_leave_channel` - Remove self from a channel
### Features
- Multiple authentication methods (Bot tokens, PAT, User tokens)
- Read-only mode for safe exploration (`--read-only` flag)
- Debug logging support (`--debug` flag)
- Configuration via environment variables or CLI flags
- Comprehensive error handling with descriptive messages
- File upload security with validation and restrictions
- Integration tests for core functionality
- OpenClaw and Claude Code configuration support
### Security
- Path traversal prevention for file operations
- Dangerous file extension blocking
- File size limits (50MB maximum)
- MIME type whitelist validation for uploads
- Token-safe logging (tokens are never logged)
- TLS verification enabled by default
- `--insecure` flag available only for development/testing
## [Unreleased]
### Planned
- WebSocket support for real-time notifications
- Additional search capabilities (file search, user directory)
- Message threading improvements
- Bulk operations support
- Rate limiting and throttling options
- Extended file type support for uploads
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has advised the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Karti AI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+30
View File
@@ -0,0 +1,30 @@
# Mattermost MCP Server Makefile
.PHONY: build test clean dev lint
BINARY_NAME=mattermost-mcp-server
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS=-ldflags "-X main.Version=$(VERSION)"
build:
go build $(LDFLAGS) -o $(BINARY_NAME) .
test:
go test -v ./...
clean:
rm -f $(BINARY_NAME)
go clean
dev:
go run . -log-level=debug
lint:
golangci-lint run
mod:
go mod tidy
go mod verify
.DEFAULT_GOAL := build
+351
View File
@@ -0,0 +1,351 @@
# Mattermost MCP Server
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/badge/Go-1.23%2B-brightgreen.svg)](go.mod)
A Model Context Protocol (MCP) server for Mattermost, enabling AI assistants to interact with your Mattermost workspace. This server provides tools for sending messages, managing channels, searching users, and handling file uploads/downloads.
## Features
- **27 Total Tools**: Comprehensive coverage of Mattermost operations
- **Read Operations**: Teams, channels, users, messages, reactions, files
- **Write Operations**: Send/edit/delete messages, reactions, channel management, file operations
- **Read-Only Mode**: Safe mode with only read operations enabled
- **Dual Token Support**: Bot token for reads, PAT for writes (enhanced security)
- **Debug Logging**: Optional verbose logging for troubleshooting
## Installation
### Method 1: Using Go Install
```bash
go install github.com/karti-ai/mattermost-mcp-server@latest
```
### Method 2: Binary Download
Download the latest binary from the [releases page](https://github.com/karti-ai/mattermost-mcp-server/releases):
```bash
curl -L -o mattermost-mcp-server https://github.com/karti-ai/mattermost-mcp-server/releases/latest/download/mattermost-mcp-server-linux-amd64
chmod +x mattermost-mcp-server
```
### Method 3: Build from Source
```bash
git clone https://github.com/karti-ai/mattermost-mcp-server.git
cd mattermost-mcp-server
go build -ldflags "-s -w" -o mattermost-mcp-server .
```
## Configuration
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `MATTERMOST_HOST` | Mattermost server URL (e.g., `https://mattermost.example.com`) | Yes |
| `MATTERMOST_TOKEN` | Access token (bot, PAT, or user) | Yes* |
| `MATTERMOST_BOT_TOKEN` | Bot token (alternative to TOKEN) | Yes* |
| `MATTERMOST_PAT` | Personal Access Token (alternative to TOKEN) | Yes* |
| `MATTERMOST_TEAM` | Default team name | No |
*At least one token type is required.
### CLI Flags
| Flag | Short | Description |
|------|-------|-------------|
| `-host` | `-H` | Mattermost server URL |
| `-token` | `-t` | Mattermost access token |
| `-bot-token` | | Mattermost bot token |
| `-pat` | | Mattermost personal access token |
| `-team` | | Mattermost team name |
| `-debug` | `-d` | Enable debug logging |
| `-insecure` | | Allow insecure TLS connections |
| `-read-only` | `-r` | Enable read-only mode |
| `-version` | `-v` | Show version and exit |
### Example .env File
```bash
# Mattermost Configuration
MATTERMOST_HOST=https://mattermost.company.com
MATTERMOST_TOKEN=your-bot-token-here
MATTERMOST_TEAM=general
# Optional: Enable debug logging
# DEBUG=true
```
## Available Tools
The server exposes **27 tools** grouped by category:
### Team Operations (1)
| Tool | Description |
|------|-------------|
| `mattermost_list_teams` | List all teams the bot has access to |
### Channel Operations (5)
| Tool | Description |
|------|-------------|
| `mattermost_list_channels` | List all accessible channels in a team |
| `mattermost_get_channel_by_name` | Get a channel by name in a team |
| `mattermost_get_channel_info` | Get detailed channel info with member count |
| `mattermost_list_channel_members` | List all members of a channel |
| `mattermost_mark_channel_read` | Mark a channel as read (clear unread) |
### Channel Admin (4)
| Tool | Description |
|------|-------------|
| `mattermost_create_channel` | Create a new public or private channel |
| `mattermost_invite_to_channel` | Invite a user to a channel |
| `mattermost_leave_channel` | Leave a channel |
| `mattermost_delete_channel` | Delete/archive a channel |
### Messaging (6)
| Tool | Description |
|------|-------------|
| `mattermost_send_message` | Send a message (supports thread replies) |
| `mattermost_edit_message` | Edit an existing message |
| `mattermost_delete_message` | Delete a message |
| `mattermost_get_channel_messages` | Read message history with pagination |
| `mattermost_get_thread` | Read all messages in a thread |
| `mattermost_search_posts` | Search messages in a team |
### User Operations (4)
| Tool | Description |
|------|-------------|
| `mattermost_search_users` | Search users by username, email, or name |
| `mattermost_get_user` | Get a specific user by ID or username |
| `mattermost_get_user_status` | Get online status (online/away/dnd/offline) |
| `mattermost_update_user_status` | Set your status (requires PAT) |
### Direct Messages (2)
| Tool | Description |
|------|-------------|
| `mattermost_create_dm` | Create a 1:1 direct message channel |
| `mattermost_create_group_dm` | Create a group DM with multiple users |
### Reactions (3)
| Tool | Description |
|------|-------------|
| `mattermost_add_reaction` | Add an emoji reaction to a message |
| `mattermost_remove_reaction` | Remove a reaction from a message |
| `mattermost_list_reactions` | List all reactions on a message |
### File Operations (2)
| Tool | Description |
|------|-------------|
| `mattermost_upload_file` | Upload a file to a channel |
| `mattermost_download_file` | Download a file from a message |
### Unread/Notifications (1)
| Tool | Description |
|------|-------------|
| `mattermost_get_unread_count` | Get unread message counts per channel |
### Read-Only Mode
When running with `--read-only` or `-r` flag, only the 14 read tools are available, preventing any modifications to your Mattermost workspace.
## OpenClaw Setup
Add the following to your `~/.config/openclaw/openclaw.json`:
```json
{
"mcpServers": {
"mattermost": {
"command": "mattermost-mcp-server",
"env": {
"MATTERMOST_HOST": "https://mattermost.example.com",
"MATTERMOST_TOKEN": "your-token-here",
"MATTERMOST_TEAM": "general"
}
}
}
}
```
With read-only mode:
```json
{
"mcpServers": {
"mattermost": {
"command": "mattermost-mcp-server",
"args": ["--read-only"],
"env": {
"MATTERMOST_HOST": "https://mattermost.example.com",
"MATTERMOST_TOKEN": "your-token-here"
}
}
}
}
```
## Claude Code Setup
### Method 1: Using .mcp.json
Create `.mcp.json` in your project root:
```json
{
"mcpServers": {
"mattermost": {
"command": "mattermost-mcp-server",
"env": {
"MATTERMOST_HOST": "https://mattermost.example.com",
"MATTERMOST_TOKEN": "your-token-here",
"MATTERMOST_TEAM": "general"
}
}
}
}
```
### Method 2: CLI Setup
```bash
# Add to Claude Code configuration
claude config add mcpServer mattermost mattermost-mcp-server
# Set environment variables
claude config set mattermost.env.MATTERMOST_HOST https://mattermost.example.com
claude config set mattermost.env.MATTERMOST_TOKEN your-token-here
```
### Method 3: Direct Command
```bash
claude --mcp mattermost mattermost-mcp-server -- -H https://mattermost.example.com -t your-token-here
```
## Security Considerations
### Token Storage
- **Never commit tokens to version control** - Always use environment variables
- **Use bot tokens when possible** - Bot tokens have limited permissions and are safer than user tokens
- **Rotate tokens regularly** - Change tokens periodically and after any suspected compromise
- **Use secrets management** - For production, use tools like HashiCorp Vault, AWS Secrets Manager, or Kubernetes Secrets
### Read-Only Mode
- **Enable for safe exploration** - Use `--read-only` flag when first testing or giving AI assistants limited access
- **Audit before write access** - Review all write tool descriptions before disabling read-only mode
- **Principle of least privilege** - Only enable write access when absolutely necessary
### File Upload Restrictions
- The server allows file uploads to Mattermost channels
- Configure Mattermost server-side file upload limits and restrictions
- Be aware that AI assistants can upload arbitrary files if given write access
- Consider read-only mode if file upload functionality is not required
### TLS/SSL
- **Always use HTTPS** in production
- The `--insecure` flag disables TLS certificate verification - **only use for development/testing**
- Ensure your Mattermost server has valid TLS certificates
### Network Security
- Run the MCP server on localhost or behind a firewall
- Do not expose the MCP server to untrusted networks
- Use VPN or SSH tunnels when accessing remote Mattermost servers
## Usage Examples
### Listing Channels
```
Please list all channels in the general team
```
### Sending a Message
```
Send a message to the "announcements" channel saying "Meeting starts in 5 minutes"
```
### Searching Users
```
Find users with "john" in their username
```
### Managing Reactions
```
Add a :thumbsup: reaction to the last message in the general channel
```
### File Operations
```
Upload the report.pdf file to the marketing channel
```
## Troubleshooting
### Debug Mode
Enable debug logging to see detailed request/response information:
```bash
mattermost-mcp-server -d
```
### Common Issues
1. **Connection refused**: Check that `MATTERMOST_HOST` includes the full URL with protocol (http/https)
2. **401 Unauthorized**: Verify your token is valid and has not expired
3. **403 Forbidden**: Ensure the token has appropriate permissions for the operations you're trying to perform
4. **Team not found**: Verify the team name exactly matches the URL slug in Mattermost
## Development
### Building
```bash
go build -ldflags "-s -w" -o mattermost-mcp-server .
```
### Testing
```bash
go test ./...
```
### Smoke Tests
```bash
# Build and verify
make build
# Run tests
./mattermost-mcp-server --version
./mattermost-mcp-server --help
```
## License
Apache License 2.0 - See [LICENSE](LICENSE) for details.
## Contributing
Contributions are welcome! Feel free to open issues and pull requests.
## Support
- **Issues**: [GitHub Issues](https://github.com/karti-ai/mattermost-mcp-server/issues)
- **Discussions**: [GitHub Discussions](https://github.com/karti-ai/mattermost-mcp-server/discussions)
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
+104
View File
@@ -0,0 +1,104 @@
package cmd
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/karti-ai/mattermost-mcp-server/operation"
pkgflag "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/mattermost"
"github.com/mark3labs/mcp-go/server"
)
func Execute() {
// Parse CLI flags
flag.StringVar(&pkgflag.Token, "token", os.Getenv("MATTERMOST_TOKEN"), "Mattermost access token")
flag.StringVar(&pkgflag.Token, "t", os.Getenv("MATTERMOST_TOKEN"), "Mattermost access token (short)")
flag.StringVar(&pkgflag.BotToken, "bot-token", os.Getenv("MATTERMOST_BOT_TOKEN"), "Mattermost bot token")
flag.StringVar(&pkgflag.PAT, "pat", os.Getenv("MATTERMOST_PAT"), "Mattermost personal access token")
flag.StringVar(&pkgflag.Host, "host", os.Getenv("MATTERMOST_HOST"), "Mattermost server URL")
flag.StringVar(&pkgflag.Host, "H", os.Getenv("MATTERMOST_HOST"), "Mattermost server URL (short)")
flag.StringVar(&pkgflag.Team, "team", os.Getenv("MATTERMOST_TEAM"), "Mattermost team (optional)")
flag.BoolVar(&pkgflag.ReadOnly, "read-only", os.Getenv("MATTERMOST_READONLY") == "true", "Enable read-only mode")
flag.BoolVar(&pkgflag.ReadOnly, "r", os.Getenv("MATTERMOST_READONLY") == "true", "Enable read-only mode (short)")
flag.BoolVar(&pkgflag.Debug, "debug", os.Getenv("MATTERMOST_DEBUG") == "true", "Enable debug logging")
flag.BoolVar(&pkgflag.Debug, "d", os.Getenv("MATTERMOST_DEBUG") == "true", "Enable debug logging (short)")
flag.BoolVar(&pkgflag.Insecure, "insecure", os.Getenv("MATTERMOST_INSECURE") == "true", "Allow insecure TLS connections")
flag.BoolVar(&pkgflag.ShowVersion, "version", false, "Show version and exit")
flag.BoolVar(&pkgflag.ShowVersion, "v", false, "Show version (short)")
flag.Parse()
if pkgflag.ShowVersion {
fmt.Printf("mattermost-mcp-server %s\n", pkgflag.Version)
os.Exit(0)
}
// Validate at least one token is provided
if pkgflag.Token == "" && pkgflag.BotToken == "" && pkgflag.PAT == "" {
fmt.Fprintln(os.Stderr, "Error: At least one token is required (via --token, --bot-token, --pat, or environment variables)")
flag.Usage()
os.Exit(1)
}
if pkgflag.Host == "" {
fmt.Fprintln(os.Stderr, "Error: Mattermost host is required (via --host or MATTERMOST_HOST environment variable)")
flag.Usage()
os.Exit(1)
}
// Initialize logging
logLevel := "info"
if pkgflag.Debug {
logLevel = "debug"
}
if err := log.Initialize(logLevel); err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logging: %v\n", err)
os.Exit(1)
}
defer log.Sync()
log.Infof("Starting mattermost-mcp-server %s", pkgflag.Version)
log.Infof("Host: %s", pkgflag.Host)
log.Infof("Read-only mode: %v", pkgflag.ReadOnly)
client := mattermost.NewClient(pkgflag.Host, pkgflag.BotToken, pkgflag.PAT)
mattermost.SetGlobalClient(client)
log.Infof("Initialized Mattermost client for host: %s", pkgflag.Host)
// Create MCP server
mcpServer := server.NewMCPServer(
"mattermost-mcp",
pkgflag.Version,
)
// Register tools
tools := operation.Register()
for _, tool := range tools {
mcpServer.AddTool(tool.Tool, tool.Handler)
}
log.Infof("Registered %d tools", len(tools))
// Set up graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Info("Received shutdown signal, shutting down gracefully...")
}()
// Start stdio server
log.Info("Starting stdio server...")
if err := server.ServeStdio(mcpServer); err != nil {
log.Errorf("Server error: %v", err)
os.Exit(1)
}
log.Info("Server stopped")
}
+64
View File
@@ -0,0 +1,64 @@
module github.com/karti-ai/mattermost-mcp-server
go 1.23.0
require (
github.com/mark3labs/mcp-go v0.44.0
github.com/mattermost/mattermost-server/v6 v6.7.2
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.27.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/graph-gophers/graphql-go v1.3.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.15.1 // indirect
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
github.com/mattermost/logr/v2 v2.0.15 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.24 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/tinylib/msgp v1.1.6 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.3 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect
golang.org/x/sys v0.0.0-20220403205710-6acee93ad0eb // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+2220
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"runtime/debug"
"github.com/karti-ai/mattermost-mcp-server/cmd"
"github.com/karti-ai/mattermost-mcp-server/pkg/flag"
)
var Version = "dev"
func init() {
if Version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
Version = info.Main.Version
}
}
flag.Version = Version
}
func main() {
cmd.Execute()
}
+220
View File
@@ -0,0 +1,220 @@
package channel
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mattermost/mattermost-server/v6/model"
)
var AdminTool = tool.New()
const (
CreateChannelToolName = "mattermost_create_channel"
InviteToChannelToolName = "mattermost_invite_to_channel"
DeleteChannelToolName = "mattermost_delete_channel"
LeaveChannelToolName = "mattermost_leave_channel"
)
var (
CreateChannelTool = mcp.NewTool(
CreateChannelToolName,
mcp.WithDescription("Create a new channel (public or private) in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team to create channel in")),
mcp.WithString("name", mcp.Required(), mcp.Description("Channel name (lowercase, no spaces, 2-64 characters)")),
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the channel (2-64 characters)")),
mcp.WithString("type", mcp.Required(), mcp.Description("Channel type: 'O' for public, 'P' for private")),
mcp.WithString("purpose", mcp.Description("Channel description/purpose (optional)")),
)
InviteToChannelTool = mcp.NewTool(
InviteToChannelToolName,
mcp.WithDescription("Invite a user to a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to invite user to")),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User to invite")),
)
DeleteChannelTool = mcp.NewTool(
DeleteChannelToolName,
mcp.WithDescription("Delete/archive a channel (soft delete by default)"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to delete")),
mcp.WithBoolean("permanent", mcp.Description("Permanently delete instead of archive (default: false)")),
)
LeaveChannelTool = mcp.NewTool(
LeaveChannelToolName,
mcp.WithDescription("Remove self from a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to leave")),
)
)
func init() {
registerAdminTools()
}
func registerAdminTools() {
tools := []server.ServerTool{
{Tool: CreateChannelTool, Handler: CreateChannelFn},
{Tool: InviteToChannelTool, Handler: InviteToChannelFn},
{Tool: DeleteChannelTool, Handler: DeleteChannelFn},
{Tool: LeaveChannelTool, Handler: LeaveChannelFn},
}
for _, t := range tools {
AdminTool.RegisterWrite(t)
}
}
func CreateChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called CreateChannelFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
name, err := params.GetString(args, "name")
if err != nil {
return to.Error(fmt.Errorf("[name] %v", err)), nil
}
displayName, err := params.GetString(args, "display_name")
if err != nil {
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
}
channelType, err := params.GetString(args, "type")
if err != nil {
return to.Error(fmt.Errorf("[type] %v", err)), nil
}
if channelType != "O" && channelType != "P" {
return to.Error(fmt.Errorf("[type] must be 'O' (public) or 'P' (private)")), nil
}
purpose := params.GetOptionalString(args, "purpose", "")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
channel := &model.Channel{
TeamId: teamID,
Name: name,
DisplayName: displayName,
Type: model.ChannelType(channelType),
Purpose: purpose,
}
created, err := client.CreateChannel(ctx, channel)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to create channel: %v", err)), nil
}
return to.Result(SlimChannel(created)), nil
}
func InviteToChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called InviteToChannelFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
userID, err := params.GetString(args, "user_id")
if err != nil {
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
member, err := client.AddChannelMember(ctx, channelID, userID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to invite user to channel: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"user_id": userID,
"member": member,
"message": "User invited to channel successfully",
}), nil
}
func DeleteChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called DeleteChannelFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
_ = params.GetOptionalBool(args, "permanent", false)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeleteChannel(ctx, channelID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to delete channel: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"message": "Channel deleted successfully",
}), nil
}
func LeaveChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called LeaveChannelFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
err = client.RemoveChannelMember(ctx, channelID, user.Id)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to leave channel: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"user_id": user.Id,
"message": "Successfully left the channel",
}), nil
}
+207
View File
@@ -0,0 +1,207 @@
package channel
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ListChannelsToolName = "mattermost_list_channels"
GetChannelByNameToolName = "mattermost_get_channel_by_name"
GetChannelInfoToolName = "mattermost_get_channel_info"
ListChannelMembersToolName = "mattermost_list_channel_members"
)
var (
ListChannelsTool = mcp.NewTool(
ListChannelsToolName,
mcp.WithDescription("List accessible channels for the authenticated user in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list channels from")),
mcp.WithNumber("limit", mcp.Description("Maximum number of results to return (default 30)")),
)
GetChannelByNameTool = mcp.NewTool(
GetChannelByNameToolName,
mcp.WithDescription("Get a channel by name in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to search in")),
mcp.WithString("channel_name", mcp.Required(), mcp.Description("Channel name to find (e.g., \"general\", \"social\", \"trading-desk\")")),
)
GetChannelInfoTool = mcp.NewTool(
GetChannelInfoToolName,
mcp.WithDescription("Get detailed channel information including member count, purpose, etc."),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to get info for")),
)
ListChannelMembersTool = mcp.NewTool(
ListChannelMembersToolName,
mcp.WithDescription("List all members of a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to list members for")),
mcp.WithNumber("page", mcp.Description("Page number for pagination (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Members per page (default 60, max 200)")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: ListChannelsTool, Handler: ListChannelsFn},
{Tool: GetChannelByNameTool, Handler: GetChannelByNameFn},
{Tool: GetChannelInfoTool, Handler: GetChannelInfoFn},
{Tool: ListChannelMembersTool, Handler: ListChannelMembersFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func ListChannelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called ListChannelsFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
limit := params.GetOptionalInt(args, "limit", 30)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
channels, err := client.GetChannelsForTeamForUser(ctx, teamID, user.Id, false)
if err != nil {
return to.Error(fmt.Errorf("[channels] failed to list channels: %v", err)), nil
}
if len(channels) > int(limit) {
channels = channels[:limit]
}
results := make([]map[string]interface{}, 0, len(channels))
for _, ch := range channels {
results = append(results, SlimChannel(ch))
}
return to.Result(map[string]interface{}{
"channels": results,
"count": len(results),
}), nil
}
func GetChannelByNameFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called GetChannelByNameFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
channelName, err := params.GetString(args, "channel_name")
if err != nil {
return to.Error(fmt.Errorf("[channel_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
channel, err := client.GetChannelByName(ctx, teamID, channelName)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get channel: %v", err)), nil
}
return to.Result(SlimChannel(channel)), nil
}
func GetChannelInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called GetChannelInfoFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
channel, err := client.GetChannel(ctx, channelID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get channel: %v", err)), nil
}
stats, err := client.GetChannelStats(ctx, channelID)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get channel stats: %v", err)), nil
}
return to.Result(DetailedChannel(channel, stats.MemberCount)), nil
}
func ListChannelMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called ListChannelMembersFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 60)
if perPage > 200 {
perPage = 200
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
members, err := client.GetChannelMembers(ctx, channelID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to list members: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(members))
for _, m := range members {
results = append(results, SlimChannelMember(m))
}
return to.Result(map[string]interface{}{
"members": results,
"count": len(results),
"channel_id": channelID,
"page": page,
"per_page": perPage,
}), nil
}
+108
View File
@@ -0,0 +1,108 @@
package channel
import (
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimChannel(t *testing.T) {
c := &model.Channel{
Id: "channel123",
Name: "general",
DisplayName: "General",
Type: "O",
TeamId: "team456",
}
slim := SlimChannel(c)
assert.NotNil(t, slim)
assert.Equal(t, "channel123", slim["id"])
assert.Equal(t, "general", slim["name"])
assert.Equal(t, "General", slim["display_name"])
assert.Equal(t, "O", slim["type"])
assert.Equal(t, "team456", slim["team_id"])
}
func TestSlimChannel_Nil(t *testing.T) {
slim := SlimChannel(nil)
assert.Nil(t, slim)
}
func TestSlimChannel_DifferentTypes(t *testing.T) {
tests := []struct {
name string
chType model.ChannelType
expected string
}{
{"Open channel", model.ChannelTypeOpen, "O"},
{"Private channel", model.ChannelTypePrivate, "P"},
{"Direct message", model.ChannelTypeDirect, "D"},
{"Group message", model.ChannelTypeGroup, "G"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &model.Channel{
Id: "channel123",
Name: "test-channel",
DisplayName: "Test Channel",
Type: tt.chType,
TeamId: "team456",
}
slim := SlimChannel(c)
assert.Equal(t, tt.expected, slim["type"])
})
}
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 5)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[ListChannelsToolName], "ListChannels tool should be registered")
assert.True(t, toolNames[GetChannelByNameToolName], "GetChannelByName tool should be registered")
assert.True(t, toolNames[GetChannelInfoToolName], "GetChannelInfo tool should be registered")
assert.True(t, toolNames[GetUnreadCountToolName], "GetUnreadCount tool should be registered")
assert.True(t, toolNames[MarkChannelReadToolName], "MarkChannelRead tool should be registered")
}
func TestListChannelsFn_MissingTeamId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: ListChannelsToolName,
Arguments: map[string]interface{}{},
},
}
result, err := ListChannelsFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestListChannelsFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: ListChannelsToolName,
Arguments: map[string]interface{}{
"team_id": "team123",
},
},
}
result, err := ListChannelsFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+45
View File
@@ -0,0 +1,45 @@
package channel
import "github.com/mattermost/mattermost-server/v6/model"
func SlimChannel(c *model.Channel) map[string]interface{} {
if c == nil {
return nil
}
return map[string]interface{}{
"id": c.Id,
"name": c.Name,
"display_name": c.DisplayName,
"type": string(c.Type),
"team_id": c.TeamId,
}
}
func DetailedChannel(c *model.Channel, memberCount int64) map[string]interface{} {
if c == nil {
return nil
}
return map[string]interface{}{
"id": c.Id,
"name": c.Name,
"display_name": c.DisplayName,
"type": string(c.Type),
"team_id": c.TeamId,
"purpose": c.Purpose,
"header": c.Header,
"creator_id": c.CreatorId,
"create_at": c.CreateAt,
"member_count": memberCount,
}
}
func SlimChannelMember(m model.ChannelMember) map[string]interface{} {
return map[string]interface{}{
"user_id": m.UserId,
"channel_id": m.ChannelId,
"roles": m.Roles,
"last_viewed": m.LastViewedAt,
"msg_count": m.MsgCount,
"mention_count": m.MentionCount,
}
}
+120
View File
@@ -0,0 +1,120 @@
package channel
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetUnreadCountToolName = "mattermost_get_unread_count"
MarkChannelReadToolName = "mattermost_mark_channel_read"
)
var (
GetUnreadCountTool = mcp.NewTool(
GetUnreadCountToolName,
mcp.WithDescription("Get unread message counts for all channels in a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to get unread counts for")),
)
MarkChannelReadTool = mcp.NewTool(
MarkChannelReadToolName,
mcp.WithDescription("Mark a channel as read (clear unread notifications)"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to mark as read")),
)
)
func init() {
registerUnreadTools()
}
func registerUnreadTools() {
tools := []server.ServerTool{
{Tool: GetUnreadCountTool, Handler: GetUnreadCountFn},
{Tool: MarkChannelReadTool, Handler: MarkChannelReadFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetUnreadCountFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called GetUnreadCountFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
members, err := client.GetChannelMembersForUser(ctx, user.Id, teamID)
if err != nil {
return to.Error(fmt.Errorf("[channels] failed to get channel members: %v", err)), nil
}
memberResults := make([]map[string]interface{}, 0, len(members))
for _, member := range members {
memberResults = append(memberResults, map[string]interface{}{
"channel_id": member.ChannelId,
"user_id": member.UserId,
"unread_messages": member.MsgCount,
"unread_mentions": member.MentionCount,
"last_viewed_at": member.LastViewedAt,
})
}
return to.Result(map[string]interface{}{
"channels": memberResults,
"count": len(memberResults),
}), nil
}
func MarkChannelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Channel] Called MarkChannelReadFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
_, err = client.MarkChannelAsRead(ctx, channelID, user.Id)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to mark channel as read: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"channel_id": channelID,
"message": "Channel marked as read successfully",
}), nil
}
+75
View File
@@ -0,0 +1,75 @@
package command
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ExecuteSlashCommandToolName = "mattermost_execute_slash_command"
)
var (
ExecuteSlashCommandTool = mcp.NewTool(
ExecuteSlashCommandToolName,
mcp.WithDescription("Execute a slash command in a channel (e.g., /remind, /poll)"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to execute command in")),
mcp.WithString("command", mcp.Required(), mcp.Description("Slash command to execute (e.g., /remind @channel meeting in 10 minutes)")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: ExecuteSlashCommandTool, Handler: ExecuteSlashCommandFn},
}
for _, t := range tools {
Tool.RegisterWrite(t)
}
}
func ExecuteSlashCommandFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Command] Called ExecuteSlashCommandFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
command, err := params.GetString(args, "command")
if err != nil {
return to.Error(fmt.Errorf("[command] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
resp, err := client.ExecuteSlashCommand(ctx, channelID, command)
if err != nil {
return to.Error(fmt.Errorf("[command] failed to execute: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"response": resp.ResponseType,
"text": resp.Text,
"goto_location": resp.GotoLocation,
}), nil
}
+115
View File
@@ -0,0 +1,115 @@
package dm
import (
"context"
"fmt"
"strings"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
CreateDMToolName = "mattermost_create_dm"
CreateGroupDMToolName = "mattermost_create_group_dm"
)
var (
CreateDMTool = mcp.NewTool(
CreateDMToolName,
mcp.WithDescription("Create direct message channel with user"),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to DM with")),
)
CreateGroupDMTool = mcp.NewTool(
CreateGroupDMToolName,
mcp.WithDescription("Create group DM channel with multiple users"),
mcp.WithString("user_ids", mcp.Required(), mcp.Description("Comma-separated list of user IDs to include in group DM")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: CreateDMTool, Handler: CreateDMFn},
{Tool: CreateGroupDMTool, Handler: CreateGroupDMFn},
}
for _, t := range tools {
Tool.RegisterWrite(t)
}
}
func CreateDMFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DM] Called CreateDMFn")
args := req.GetArguments()
userId, err := params.GetString(args, "user_id")
if err != nil {
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user to get our own user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[me] failed to get current user: %v", err)), nil
}
// Create direct message channel between current user and target user
channel, err := client.CreateDirectChannel(ctx, me.Id, userId)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to create DM: %v", err)), nil
}
return to.Result(SlimChannel(channel)), nil
}
func CreateGroupDMFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DM] Called CreateGroupDMFn")
args := req.GetArguments()
userIDsStr, err := params.GetString(args, "user_ids")
if err != nil {
return to.Error(fmt.Errorf("[user_ids] %v", err)), nil
}
// Parse comma-separated user IDs
userIDs := strings.Split(userIDsStr, ",")
if len(userIDs) < 2 {
return to.Error(fmt.Errorf("[user_ids] at least 2 user IDs required for group DM (got %d)", len(userIDs))), nil
}
// Trim whitespace from each ID
for i, id := range userIDs {
userIDs[i] = strings.TrimSpace(id)
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Create group DM channel
channel, err := client.CreateGroupChannel(ctx, userIDs)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to create group DM: %v", err)), nil
}
return to.Result(SlimChannel(channel)), nil
}
+77
View File
@@ -0,0 +1,77 @@
package dm
import (
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimChannel(t *testing.T) {
c := &model.Channel{
Id: "channel123",
Name: "user1__user2",
DisplayName: "user1, user2",
Type: model.ChannelTypeDirect,
TeamId: "",
}
slim := SlimChannel(c)
assert.NotNil(t, slim)
assert.Equal(t, "channel123", slim["id"])
assert.Equal(t, "user1__user2", slim["name"])
assert.Equal(t, "user1, user2", slim["display_name"])
assert.Equal(t, model.ChannelTypeDirect, slim["type"])
assert.Equal(t, "", slim["team_id"])
}
func TestSlimChannel_Nil(t *testing.T) {
slim := SlimChannel(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 1)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[CreateDMToolName], "CreateDM tool should be registered")
}
func TestCreateDMFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": "user123",
},
},
}
result, err := CreateDMFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestCreateDMFn_MissingUserId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: CreateDMToolName,
Arguments: map[string]interface{}{},
},
}
result, err := CreateDMFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+16
View File
@@ -0,0 +1,16 @@
package dm
import "github.com/mattermost/mattermost-server/v6/model"
func SlimChannel(c *model.Channel) map[string]interface{} {
if c == nil {
return nil
}
return map[string]interface{}{
"id": c.Id,
"name": c.Name,
"display_name": c.DisplayName,
"type": c.Type,
"team_id": c.TeamId,
}
}
+191
View File
@@ -0,0 +1,191 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/karti-ai/mattermost-mcp-server/pkg/file"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mattermost/mattermost-server/v6/model"
)
var Tool = tool.New()
const (
UploadFileToolName = "mattermost_upload_file"
DownloadFileToolName = "mattermost_download_file"
)
var (
UploadFileTool = mcp.NewTool(
UploadFileToolName,
mcp.WithDescription("Upload file to channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to upload file to")),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Local file path to upload")),
mcp.WithString("message", mcp.Description("Message to post with file (optional)")),
)
DownloadFileTool = mcp.NewTool(
DownloadFileToolName,
mcp.WithDescription("Download file from Mattermost"),
mcp.WithString("file_id", mcp.Required(), mcp.Description("File ID to download")),
mcp.WithString("download_path", mcp.Required(), mcp.Description("Local path where file should be saved")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: UploadFileTool, Handler: UploadFileFn},
{Tool: DownloadFileTool, Handler: DownloadFileFn},
}
for _, t := range tools {
Tool.RegisterWrite(t)
}
}
func UploadFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[File] Called UploadFileFn")
args := req.GetArguments()
channelId, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
filePath, err := params.GetString(args, "file_path")
if err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
message := params.GetOptionalString(args, "message", "")
if !file.IsValidPath(filePath) {
return to.Error(fmt.Errorf("[file_path] path traversal detected: %s", filePath)), nil
}
fileInfo, err := file.GetFileInfo(filePath)
if err != nil {
return to.Error(fmt.Errorf("[file_path] failed to access file: %v", err)), nil
}
if fileInfo.IsDir() {
return to.Error(fmt.Errorf("[file_path] path is a directory, not a file: %s", filePath)), nil
}
if err := file.ValidateFileSize(fileInfo.Size()); err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
if err := file.ValidateFilename(fileInfo.Name()); err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
data, err := os.ReadFile(filePath)
if err != nil {
return to.Error(fmt.Errorf("[file_path] failed to read file: %v", err)), nil
}
if _, err := file.ValidateMimeType(data); err != nil {
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
filename := filepath.Base(filePath)
uploadResp, err := client.UploadFile(ctx, data, channelId, filename)
if err != nil {
return to.Error(fmt.Errorf("[upload] failed to upload file: %v", err)), nil
}
if message != "" && len(uploadResp.FileInfos) > 0 {
post := &model.Post{
ChannelId: channelId,
Message: message,
FileIds: []string{uploadResp.FileInfos[0].Id},
}
_, err := client.CreatePost(ctx, post)
if err != nil {
return to.Result(map[string]interface{}{
"success": true,
"file_id": uploadResp.FileInfos[0].Id,
"file_infos": SlimFileInfos(uploadResp.FileInfos),
"warning": fmt.Sprintf("File uploaded but message failed to post: %v", err),
}), nil
}
}
return to.Result(map[string]interface{}{
"success": true,
"file_id": uploadResp.FileInfos[0].Id,
"file_infos": SlimFileInfos(uploadResp.FileInfos),
}), nil
}
func DownloadFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[File] Called DownloadFileFn")
args := req.GetArguments()
fileId, err := params.GetString(args, "file_id")
if err != nil {
return to.Error(fmt.Errorf("[file_id] %v", err)), nil
}
downloadPath, err := params.GetString(args, "download_path")
if err != nil {
return to.Error(fmt.Errorf("[download_path] %v", err)), nil
}
if !file.IsValidPath(downloadPath) {
return to.Error(fmt.Errorf("[download_path] path traversal detected: %s", downloadPath)), nil
}
if file.Exists(downloadPath) {
return to.Error(fmt.Errorf("[download_path] file already exists: %s", downloadPath)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
data, _, err := client.GetFile(ctx, fileId)
if err != nil {
return to.Error(fmt.Errorf("[download] failed to download file: %v", err)), nil
}
if int64(len(data)) > file.MaxFileSize {
return to.Error(fmt.Errorf("[download] downloaded file size %d exceeds maximum allowed %d", len(data), file.MaxFileSize)), nil
}
if err := file.CheckDiskSpace(downloadPath, int64(len(data))); err != nil {
return to.Error(fmt.Errorf("[download_path] %v", err)), nil
}
if err := os.WriteFile(downloadPath, data, 0644); err != nil {
return to.Error(fmt.Errorf("[download_path] failed to save file: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"file_id": fileId,
"download_path": downloadPath,
"size": len(data),
}), nil
}
+494
View File
@@ -0,0 +1,494 @@
package file
import (
"os"
"path/filepath"
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/file"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimFileInfo(t *testing.T) {
f := &model.FileInfo{
Id: "file123",
Name: "test.pdf",
Extension: "pdf",
Size: 1024,
MimeType: "application/pdf",
ChannelId: "channel456",
CreateAt: 1234567890000,
}
slim := SlimFileInfo(f)
assert.NotNil(t, slim)
assert.Equal(t, "file123", slim["id"])
assert.Equal(t, "test.pdf", slim["name"])
assert.Equal(t, "pdf", slim["extension"])
assert.Equal(t, int64(1024), slim["size"])
assert.Equal(t, "application/pdf", slim["mime_type"])
assert.Equal(t, "channel456", slim["channel_id"])
assert.Equal(t, int64(1234567890000), slim["create_at"])
}
func TestSlimFileInfo_Nil(t *testing.T) {
slim := SlimFileInfo(nil)
assert.Nil(t, slim)
}
func TestSlimFileInfos(t *testing.T) {
infos := []*model.FileInfo{
{
Id: "file1",
Name: "test1.pdf",
Extension: "pdf",
Size: 1024,
MimeType: "application/pdf",
ChannelId: "channel1",
CreateAt: 1234567890000,
},
{
Id: "file2",
Name: "test2.png",
Extension: "png",
Size: 2048,
MimeType: "image/png",
ChannelId: "channel2",
CreateAt: 1234567890001,
},
}
slim := SlimFileInfos(infos)
assert.NotNil(t, slim)
assert.Len(t, slim, 2)
assert.Equal(t, "file1", slim[0]["id"])
assert.Equal(t, "file2", slim[1]["id"])
}
func TestSlimFileInfos_Nil(t *testing.T) {
slim := SlimFileInfos(nil)
assert.Nil(t, slim)
}
func TestSlimFileInfos_WithNilItem(t *testing.T) {
infos := []*model.FileInfo{
{
Id: "file1",
Name: "test1.pdf",
Extension: "pdf",
Size: 1024,
MimeType: "application/pdf",
ChannelId: "channel1",
CreateAt: 1234567890000,
},
nil,
{
Id: "file2",
Name: "test2.png",
Extension: "png",
Size: 2048,
MimeType: "image/png",
ChannelId: "channel2",
CreateAt: 1234567890001,
},
}
slim := SlimFileInfos(infos)
assert.NotNil(t, slim)
assert.Len(t, slim, 2)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 2)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[UploadFileToolName], "UploadFile tool should be registered")
assert.True(t, toolNames[DownloadFileToolName], "DownloadFile tool should be registered")
}
func TestUploadFileFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": "/tmp/test.txt",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestUploadFileFn_MissingChannelId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"file_path": "/tmp/test.txt",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestUploadFileFn_MissingFilePath(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestUploadFileFn_PathTraversal(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": "../../../etc/passwd",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "path traversal")
}
func TestUploadFileFn_AbsolutePath(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
assert.NoError(t, err)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": testFile,
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "client not initialized")
}
func TestUploadFileFn_DangerousExtension(t *testing.T) {
tmpDir := t.TempDir()
dangerousFile := filepath.Join(tmpDir, "malicious.sh")
err := os.WriteFile(dangerousFile, []byte("#!/bin/bash\necho 'pwned'"), 0644)
assert.NoError(t, err)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": dangerousFile,
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "not allowed")
}
func TestUploadFileFn_NonExistentFile(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": "/tmp/nonexistent_file_12345.txt",
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "failed to access")
}
func TestUploadFileFn_DirectoryInsteadOfFile(t *testing.T) {
tmpDir := t.TempDir()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: UploadFileToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"file_path": tmpDir,
},
},
}
result, err := UploadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "directory")
}
func TestDownloadFileFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
"download_path": "/tmp/downloaded.txt",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDownloadFileFn_MissingFileId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"download_path": "/tmp/downloaded.txt",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDownloadFileFn_MissingDownloadPath(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDownloadFileFn_PathTraversal(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
"download_path": "../../../etc/passwd",
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "path traversal")
}
func TestDownloadFileFn_FileAlreadyExists(t *testing.T) {
tmpDir := t.TempDir()
existingFile := filepath.Join(tmpDir, "exists.txt")
err := os.WriteFile(existingFile, []byte("existing content"), 0644)
assert.NoError(t, err)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DownloadFileToolName,
Arguments: map[string]interface{}{
"file_id": "file123",
"download_path": existingFile,
},
},
}
result, err := DownloadFileFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "already exists")
}
func TestFileSecurityValidation(t *testing.T) {
t.Run("IsValidPath allows safe relative paths", func(t *testing.T) {
assert.True(t, file.IsValidPath("document.pdf"))
assert.True(t, file.IsValidPath("subdir/file.png"))
assert.True(t, file.IsValidPath("./file.txt"))
})
t.Run("IsValidPath blocks path traversal", func(t *testing.T) {
assert.False(t, file.IsValidPath("../file.txt"))
assert.False(t, file.IsValidPath("../../etc/passwd"))
assert.False(t, file.IsValidPath("subdir/../../../etc/passwd"))
})
t.Run("IsValidPath allows absolute paths without traversal", func(t *testing.T) {
assert.True(t, file.IsValidPath("/etc/passwd"))
assert.True(t, file.IsValidPath("/tmp/file.txt"))
})
t.Run("IsDangerousExtension blocks dangerous types", func(t *testing.T) {
assert.True(t, file.IsDangerousExtension("file.exe"))
assert.True(t, file.IsDangerousExtension("script.sh"))
assert.True(t, file.IsDangerousExtension("run.bat"))
assert.True(t, file.IsDangerousExtension("malicious.js"))
})
t.Run("IsDangerousExtension allows safe types", func(t *testing.T) {
assert.False(t, file.IsDangerousExtension("document.pdf"))
assert.False(t, file.IsDangerousExtension("image.png"))
assert.False(t, file.IsDangerousExtension("notes.txt"))
})
t.Run("ValidateFileSize blocks oversized files", func(t *testing.T) {
err := file.ValidateFileSize(100 * 1024 * 1024) // 100MB
assert.Error(t, err)
assert.Contains(t, err.Error(), "exceeds maximum")
})
t.Run("ValidateFileSize allows files under limit", func(t *testing.T) {
err := file.ValidateFileSize(10 * 1024 * 1024) // 10MB
assert.NoError(t, err)
})
t.Run("IsAllowedMimeType allows whitelist types", func(t *testing.T) {
assert.True(t, file.IsAllowedMimeType("image/jpeg"))
assert.True(t, file.IsAllowedMimeType("image/png"))
assert.True(t, file.IsAllowedMimeType("application/pdf"))
assert.True(t, file.IsAllowedMimeType("text/plain"))
})
t.Run("IsAllowedMimeType blocks non-whitelist types", func(t *testing.T) {
assert.False(t, file.IsAllowedMimeType("application/x-executable"))
assert.False(t, file.IsAllowedMimeType("application/x-sh"))
assert.False(t, file.IsAllowedMimeType("text/html"))
})
t.Run("DetectMimeType detects file types correctly", func(t *testing.T) {
pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
assert.Equal(t, "image/png", file.DetectMimeType(pngData))
textData := []byte("Hello, World!")
assert.Equal(t, "text/plain; charset=utf-8", file.DetectMimeType(textData))
})
}
func TestSanitizePath(t *testing.T) {
t.Run("SanitizePath returns clean path for valid input", func(t *testing.T) {
path, err := file.SanitizePath("subdir//file.txt")
assert.NoError(t, err)
assert.Equal(t, "subdir/file.txt", path)
})
t.Run("SanitizePath rejects traversal attempts", func(t *testing.T) {
_, err := file.SanitizePath("../file.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal")
})
t.Run("SanitizePath allows absolute paths without traversal", func(t *testing.T) {
path, err := file.SanitizePath("/etc/passwd")
assert.NoError(t, err)
assert.Equal(t, "/etc/passwd", path)
})
}
func TestValidateFilename(t *testing.T) {
t.Run("ValidateFilename rejects empty filename", func(t *testing.T) {
err := file.ValidateFilename("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot be empty")
})
t.Run("ValidateFilename rejects dangerous extensions", func(t *testing.T) {
err := file.ValidateFilename("malicious.exe")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not allowed")
})
t.Run("ValidateFilename accepts safe filenames", func(t *testing.T) {
err := file.ValidateFilename("document.pdf")
assert.NoError(t, err)
})
t.Run("ValidateFilename rejects null bytes", func(t *testing.T) {
err := file.ValidateFilename("file\x00.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid characters")
})
}
func TestCheckDiskSpace(t *testing.T) {
t.Run("CheckDiskSpace succeeds for writable directory", func(t *testing.T) {
tmpDir := t.TempDir()
testPath := filepath.Join(tmpDir, "subdir", "file.txt")
err := file.CheckDiskSpace(testPath, 1024)
assert.NoError(t, err)
})
}
func TestValidateMimeType(t *testing.T) {
t.Run("ValidateMimeType accepts PNG images", func(t *testing.T) {
pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52}
mimeType, err := file.ValidateMimeType(pngData)
assert.NoError(t, err)
assert.Equal(t, "image/png", mimeType)
})
t.Run("ValidateMimeType accepts plain text", func(t *testing.T) {
textData := []byte("Hello, World! This is plain text.")
mimeType, err := file.ValidateMimeType(textData)
assert.NoError(t, err)
assert.Contains(t, mimeType, "text/plain")
})
}
+31
View File
@@ -0,0 +1,31 @@
package file
import "github.com/mattermost/mattermost-server/v6/model"
func SlimFileInfo(f *model.FileInfo) map[string]interface{} {
if f == nil {
return nil
}
return map[string]interface{}{
"id": f.Id,
"name": f.Name,
"extension": f.Extension,
"size": f.Size,
"mime_type": f.MimeType,
"channel_id": f.ChannelId,
"create_at": f.CreateAt,
}
}
func SlimFileInfos(infos []*model.FileInfo) []map[string]interface{} {
if infos == nil {
return nil
}
result := make([]map[string]interface{}, 0, len(infos))
for _, info := range infos {
if slim := SlimFileInfo(info); slim != nil {
result = append(result, slim)
}
}
return result
}
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
package messaging
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetChannelMessagesToolName = "mattermost_get_channel_messages"
)
var (
GetChannelMessagesTool = mcp.NewTool(
GetChannelMessagesToolName,
mcp.WithDescription("Read message history from a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to read from")),
mcp.WithNumber("limit", mcp.Description("Number of messages to return (default 60, max 200)")),
mcp.WithString("before", mcp.Description("Get messages before this post ID (for pagination)")),
mcp.WithString("after", mcp.Description("Get messages after this post ID (for pagination)")),
)
)
func init() {
registerGetChannelMessagesTool()
}
func registerGetChannelMessagesTool() {
tools := []server.ServerTool{
{Tool: GetChannelMessagesTool, Handler: GetChannelMessagesFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetChannelMessagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetChannelMessagesFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
limit := params.GetOptionalInt(args, "limit", 60)
if limit > 200 {
limit = 200
}
if limit < 1 {
limit = 60
}
before := params.GetOptionalString(args, "before", "")
after := params.GetOptionalString(args, "after", "")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
posts, err := client.GetChannelPosts(ctx, channelID, int(limit), before, after)
if err != nil {
return to.Error(fmt.Errorf("[posts] failed to get channel messages: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(posts.Posts))
for _, post := range posts.Posts {
results = append(results, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": results,
"count": len(results),
"channel_id": channelID,
"has_more": len(results) == int(limit),
}), nil
}
+369
View File
@@ -0,0 +1,369 @@
package messaging
import (
"context"
"fmt"
"strings"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mattermost/mattermost-server/v6/model"
)
var Tool = tool.New()
const (
SendMessageToolName = "mattermost_send_message"
EditMessageToolName = "mattermost_edit_message"
DeleteMessageToolName = "mattermost_delete_message"
BulkDeleteMessagesToolName = "mattermost_bulk_delete_messages"
PinPostToolName = "mattermost_pin_post"
UnpinPostToolName = "mattermost_unpin_post"
GetPinnedPostsToolName = "mattermost_get_pinned_posts"
GetPostToolName = "mattermost_get_post"
)
var (
SendMessageTool = mcp.NewTool(
SendMessageToolName,
mcp.WithDescription("Send a message to a Mattermost channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to send the message to")),
mcp.WithString("message", mcp.Required(), mcp.Description("Message content to send")),
mcp.WithString("thread_id", mcp.Description("Thread ID for replies (optional). If provided, message will be posted as a reply in the thread")),
)
EditMessageTool = mcp.NewTool(
EditMessageToolName,
mcp.WithDescription("Edit an existing message in Mattermost"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID of the message to edit")),
mcp.WithString("message", mcp.Required(), mcp.Description("New message content")),
)
DeleteMessageTool = mcp.NewTool(
DeleteMessageToolName,
mcp.WithDescription("Delete a message from Mattermost"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID of the message to delete")),
)
BulkDeleteMessagesTool = mcp.NewTool(
BulkDeleteMessagesToolName,
mcp.WithDescription("Delete multiple messages at once (up to 100)"),
mcp.WithString("post_ids", mcp.Required(), mcp.Description("Comma-separated list of post IDs to delete (max 100)")),
)
PinPostTool = mcp.NewTool(
PinPostToolName,
mcp.WithDescription("Pin a post to a channel"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to pin")),
)
UnpinPostTool = mcp.NewTool(
UnpinPostToolName,
mcp.WithDescription("Unpin a post from a channel"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to unpin")),
)
GetPinnedPostsTool = mcp.NewTool(
GetPinnedPostsToolName,
mcp.WithDescription("Get all pinned posts in a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to get pinned posts from")),
)
GetPostTool = mcp.NewTool(
GetPostToolName,
mcp.WithDescription("Get a single post by its ID"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to retrieve")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: SendMessageTool, Handler: SendMessageFn},
{Tool: EditMessageTool, Handler: EditMessageFn},
{Tool: DeleteMessageTool, Handler: DeleteMessageFn},
{Tool: BulkDeleteMessagesTool, Handler: BulkDeleteMessagesFn},
{Tool: PinPostTool, Handler: PinPostFn},
{Tool: UnpinPostTool, Handler: UnpinPostFn},
{Tool: GetPinnedPostsTool, Handler: GetPinnedPostsFn},
{Tool: GetPostTool, Handler: GetPostFn},
}
for _, t := range tools {
if t.Tool.Name == GetPinnedPostsToolName || t.Tool.Name == GetPostToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func SendMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called SendMessageFn")
args := req.GetArguments()
channelId, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
message, err := params.GetString(args, "message")
if err != nil {
return to.Error(fmt.Errorf("[message] %v", err)), nil
}
threadId := params.GetOptionalString(args, "thread_id", "")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
post := &model.Post{
ChannelId: channelId,
Message: message,
}
if threadId != "" {
post.RootId = threadId
}
result, err := client.CreatePost(ctx, post)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to send message: %v", err)), nil
}
return to.Result(SlimPost(result)), nil
}
func EditMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called EditMessageFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
message, err := params.GetString(args, "message")
if err != nil {
return to.Error(fmt.Errorf("[message] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
post := &model.Post{
Message: message,
}
result, err := client.UpdatePost(ctx, postId, post)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to edit message: %v", err)), nil
}
return to.Result(SlimPost(result)), nil
}
func DeleteMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called DeleteMessageFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeletePost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to delete message: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"message": "Message deleted successfully",
}), nil
}
func BulkDeleteMessagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called BulkDeleteMessagesFn")
args := req.GetArguments()
postIDsStr, err := params.GetString(args, "post_ids")
if err != nil {
return to.Error(fmt.Errorf("[post_ids] %v", err)), nil
}
postIDs := strings.Split(postIDsStr, ",")
if len(postIDs) > 100 {
return to.Error(fmt.Errorf("[post_ids] too many post IDs (max 100, got %d)", len(postIDs))), nil
}
for i, id := range postIDs {
postIDs[i] = strings.TrimSpace(id)
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
deleted := 0
failed := 0
var failedIDs []string
var lastError string
for _, postID := range postIDs {
err := client.DeletePost(ctx, postID)
if err != nil {
failed++
failedIDs = append(failedIDs, postID)
lastError = err.Error()
} else {
deleted++
}
}
result := map[string]interface{}{
"total": len(postIDs),
"deleted": deleted,
"failed": failed,
"successful": deleted == len(postIDs),
}
if failed > 0 {
result["failed_ids"] = failedIDs
result["last_error"] = lastError
return to.Result(result), nil
}
return to.Result(result), nil
}
func PinPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called PinPostFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.PinPost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to pin: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"message": "Post pinned successfully",
}), nil
}
func UnpinPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called UnpinPostFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.UnpinPost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to unpin: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"message": "Post unpinned successfully",
}), nil
}
func GetPinnedPostsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetPinnedPostsFn")
args := req.GetArguments()
channelId, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
posts, err := client.GetPinnedPosts(ctx, channelId)
if err != nil {
return to.Error(fmt.Errorf("[channel] failed to get pinned posts: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(posts.Posts))
for _, post := range posts.Posts {
results = append(results, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": results,
"count": len(results),
"channel_id": channelId,
}), nil
}
func GetPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetPostFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
post, err := client.GetPost(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[post] failed to get: %v", err)), nil
}
return to.Result(SlimPost(post)), nil
}
+192
View File
@@ -0,0 +1,192 @@
package messaging
import (
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimPost(t *testing.T) {
p := &model.Post{
Id: "abc123",
ChannelId: "channel456",
UserId: "user789",
Message: "Hello World",
CreateAt: 1234567890000,
UpdateAt: 1234567890001,
}
slim := SlimPost(p)
assert.NotNil(t, slim)
assert.Equal(t, "abc123", slim["id"])
assert.Equal(t, "channel456", slim["channel_id"])
assert.Equal(t, "user789", slim["user_id"])
assert.Equal(t, "Hello World", slim["message"])
assert.Equal(t, int64(1234567890000), slim["create_at"])
assert.Equal(t, int64(1234567890001), slim["update_at"])
}
func TestSlimPost_Nil(t *testing.T) {
slim := SlimPost(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
writeTools := Tool.WriteTools()
assert.Len(t, writeTools, 3)
readTools := Tool.ReadTools()
assert.Len(t, readTools, 3)
toolNames := make(map[string]bool)
for _, t := range writeTools {
toolNames[t.Tool.Name] = true
}
for _, t := range readTools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[SendMessageToolName], "SendMessage tool should be registered")
assert.True(t, toolNames[EditMessageToolName], "EditMessage tool should be registered")
assert.True(t, toolNames[DeleteMessageToolName], "DeleteMessage tool should be registered")
assert.True(t, toolNames[GetChannelMessagesToolName], "GetChannelMessages tool should be registered")
assert.True(t, toolNames[GetThreadToolName], "GetThread tool should be registered")
assert.True(t, toolNames[SearchPostsToolName], "SearchPosts tool should be registered")
}
func TestSendMessageFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"message": "Test message",
},
},
}
result, err := SendMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestEditMessageFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: EditMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
"message": "Updated message",
},
},
}
result, err := EditMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDeleteMessageFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DeleteMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
},
},
}
result, err := DeleteMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestSendMessageFn_MissingChannelId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SendMessageToolName,
Arguments: map[string]interface{}{
"message": "Test message",
},
},
}
result, err := SendMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestSendMessageFn_MissingMessage(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
},
},
}
result, err := SendMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestEditMessageFn_MissingPostId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: EditMessageToolName,
Arguments: map[string]interface{}{
"message": "Updated message",
},
},
}
result, err := EditMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestEditMessageFn_MissingMessage(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: EditMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
},
},
}
result, err := EditMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestDeleteMessageFn_MissingPostId(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: DeleteMessageToolName,
Arguments: map[string]interface{}{},
},
}
result, err := DeleteMessageFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+85
View File
@@ -0,0 +1,85 @@
package messaging
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
SearchPostsToolName = "mattermost_search_posts"
)
var (
SearchPostsTool = mcp.NewTool(
SearchPostsToolName,
mcp.WithDescription("Search for posts/messages in a team using search terms"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to search in")),
mcp.WithString("terms", mcp.Required(), mcp.Description("Search terms (e.g., \"BTCUSD\", \"error\", \"meeting\")")),
mcp.WithBoolean("is_or_search", mcp.Description("Use OR logic instead of AND (default: false)")),
mcp.WithNumber("limit", mcp.Description("Maximum number of results to return (default 30)")),
)
)
func init() {
registerSearchTools()
}
func registerSearchTools() {
tools := []server.ServerTool{
{Tool: SearchPostsTool, Handler: SearchPostsFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func SearchPostsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called SearchPostsFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
terms, err := params.GetString(args, "terms")
if err != nil {
return to.Error(fmt.Errorf("[terms] %v", err)), nil
}
isOrSearch := params.GetOptionalBool(args, "is_or_search", false)
limit := params.GetOptionalInt(args, "limit", 30)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
results, err := client.SearchPosts(ctx, teamID, terms, isOrSearch)
if err != nil {
return to.Error(fmt.Errorf("[search] failed to search posts: %v", err)), nil
}
posts := results.ToSlice()
if len(posts) > int(limit) {
posts = posts[:limit]
}
postResults := make([]map[string]interface{}, 0, len(posts))
for _, post := range posts {
postResults = append(postResults, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": postResults,
"count": len(postResults),
}), nil
}
+17
View File
@@ -0,0 +1,17 @@
package messaging
import "github.com/mattermost/mattermost-server/v6/model"
func SlimPost(p *model.Post) map[string]interface{} {
if p == nil {
return nil
}
return map[string]interface{}{
"id": p.Id,
"channel_id": p.ChannelId,
"user_id": p.UserId,
"message": p.Message,
"create_at": p.CreateAt,
"update_at": p.UpdateAt,
}
}
+71
View File
@@ -0,0 +1,71 @@
package messaging
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetThreadToolName = "mattermost_get_thread"
)
var (
GetThreadTool = mcp.NewTool(
GetThreadToolName,
mcp.WithDescription("Read all messages in a thread conversation"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("The root post ID of the thread")),
)
)
func init() {
registerGetThreadTool()
}
func registerGetThreadTool() {
tools := []server.ServerTool{
{Tool: GetThreadTool, Handler: GetThreadFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetThreadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Messaging] Called GetThreadFn")
args := req.GetArguments()
postID, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
thread, err := client.GetPostThread(ctx, postID)
if err != nil {
return to.Error(fmt.Errorf("[thread] failed to get thread: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(thread.Posts))
for _, post := range thread.Posts {
results = append(results, SlimPost(post))
}
return to.Result(map[string]interface{}{
"posts": results,
"count": len(results),
"root_id": postID,
"has_replies": len(results) > 1,
}), nil
}
+99
View File
@@ -0,0 +1,99 @@
package operation
import (
"github.com/karti-ai/mattermost-mcp-server/operation/channel"
"github.com/karti-ai/mattermost-mcp-server/operation/command"
"github.com/karti-ai/mattermost-mcp-server/operation/dm"
"github.com/karti-ai/mattermost-mcp-server/operation/file"
"github.com/karti-ai/mattermost-mcp-server/operation/messaging"
"github.com/karti-ai/mattermost-mcp-server/operation/outgoing"
"github.com/karti-ai/mattermost-mcp-server/operation/reaction"
"github.com/karti-ai/mattermost-mcp-server/operation/system"
"github.com/karti-ai/mattermost-mcp-server/operation/team"
"github.com/karti-ai/mattermost-mcp-server/operation/user"
"github.com/karti-ai/mattermost-mcp-server/operation/webhook"
"github.com/karti-ai/mattermost-mcp-server/pkg/flag"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/server"
)
func Register() []server.ServerTool {
log.Infof("Registering tools for Mattermost MCP server %s", flag.Version)
tools := tool.New()
for _, t := range messaging.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range file.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range dm.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range reaction.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range channel.AdminTool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range messaging.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range file.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range channel.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range user.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range team.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range dm.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range reaction.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range webhook.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range webhook.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range command.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range outgoing.Tool.WriteTools() {
tools.RegisterWrite(t)
}
for _, t := range outgoing.Tool.ReadTools() {
tools.RegisterRead(t)
}
for _, t := range system.Tool.ReadTools() {
tools.RegisterRead(t)
}
return tools.Tools()
}
+187
View File
@@ -0,0 +1,187 @@
package outgoing
import (
"context"
"fmt"
"strings"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
CreateOutgoingWebhookToolName = "mattermost_create_outgoing_webhook"
ListOutgoingWebhooksToolName = "mattermost_list_outgoing_webhooks"
DeleteOutgoingWebhookToolName = "mattermost_delete_outgoing_webhook"
)
var (
CreateOutgoingWebhookTool = mcp.NewTool(
CreateOutgoingWebhookToolName,
mcp.WithDescription("Create an outgoing webhook that triggers on specific words"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID for the webhook")),
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the webhook")),
mcp.WithString("trigger_words", mcp.Required(), mcp.Description("Comma-separated list of words that trigger the webhook")),
mcp.WithString("callback_url", mcp.Required(), mcp.Description("URL to POST to when triggered")),
)
ListOutgoingWebhooksTool = mcp.NewTool(
ListOutgoingWebhooksToolName,
mcp.WithDescription("List outgoing webhooks for a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list webhooks for")),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Items per page (default 20, max 100)")),
)
DeleteOutgoingWebhookTool = mcp.NewTool(
DeleteOutgoingWebhookToolName,
mcp.WithDescription("Delete an outgoing webhook"),
mcp.WithString("webhook_id", mcp.Required(), mcp.Description("Webhook ID to delete")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: CreateOutgoingWebhookTool, Handler: CreateOutgoingWebhookFn},
{Tool: ListOutgoingWebhooksTool, Handler: ListOutgoingWebhooksFn},
{Tool: DeleteOutgoingWebhookTool, Handler: DeleteOutgoingWebhookFn},
}
for _, t := range tools {
if t.Tool.Name == ListOutgoingWebhooksToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func CreateOutgoingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Outgoing] Called CreateOutgoingWebhookFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
displayName, err := params.GetString(args, "display_name")
if err != nil {
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
}
triggerWordsStr, err := params.GetString(args, "trigger_words")
if err != nil {
return to.Error(fmt.Errorf("[trigger_words] %v", err)), nil
}
triggerWords := strings.Split(triggerWordsStr, ",")
for i, word := range triggerWords {
triggerWords[i] = strings.TrimSpace(word)
}
callbackURL, err := params.GetString(args, "callback_url")
if err != nil {
return to.Error(fmt.Errorf("[callback_url] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hook, err := client.CreateOutgoingWebhook(ctx, teamID, displayName, triggerWords, callbackURL)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to create: %v", err)), nil
}
return to.Result(map[string]interface{}{
"id": hook.Id,
"team_id": hook.TeamId,
"display_name": hook.DisplayName,
"trigger_words": hook.TriggerWords,
"callback_url": callbackURL,
"message": "Outgoing webhook created successfully",
}), nil
}
func ListOutgoingWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Outgoing] Called ListOutgoingWebhooksFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 20)
if perPage > 100 {
perPage = 100
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hooks, err := client.ListOutgoingWebhooks(ctx, teamID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to list: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(hooks))
for _, hook := range hooks {
results = append(results, map[string]interface{}{
"id": hook.Id,
"team_id": hook.TeamId,
"display_name": hook.DisplayName,
"trigger_words": hook.TriggerWords,
})
}
return to.Result(map[string]interface{}{
"webhooks": results,
"count": len(results),
"page": page,
"per_page": perPage,
}), nil
}
func DeleteOutgoingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Outgoing] Called DeleteOutgoingWebhookFn")
args := req.GetArguments()
webhookID, err := params.GetString(args, "webhook_id")
if err != nil {
return to.Error(fmt.Errorf("[webhook_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeleteOutgoingWebhook(ctx, webhookID)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to delete: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"webhook_id": webhookID,
"message": "Outgoing webhook deleted successfully",
}), nil
}
+181
View File
@@ -0,0 +1,181 @@
package reaction
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mattermost/mattermost-server/v6/model"
)
var Tool = tool.New()
const (
AddReactionToolName = "mattermost_add_reaction"
RemoveReactionToolName = "mattermost_remove_reaction"
ListReactionsToolName = "mattermost_list_reactions"
)
var (
AddReactionTool = mcp.NewTool(
AddReactionToolName,
mcp.WithDescription("Add emoji reaction to post"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to add reaction to")),
mcp.WithString("emoji_name", mcp.Required(), mcp.Description("Emoji name without colons (e.g., thumbsup, not :thumbsup:)")),
)
RemoveReactionTool = mcp.NewTool(
RemoveReactionToolName,
mcp.WithDescription("Remove emoji reaction from post"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to remove reaction from")),
mcp.WithString("emoji_name", mcp.Required(), mcp.Description("Emoji name without colons")),
)
ListReactionsTool = mcp.NewTool(
ListReactionsToolName,
mcp.WithDescription("List all emoji reactions on a post"),
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to get reactions for")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: AddReactionTool, Handler: AddReactionFn},
{Tool: RemoveReactionTool, Handler: RemoveReactionFn},
{Tool: ListReactionsTool, Handler: ListReactionsFn},
}
for _, t := range tools {
if t.Tool.Name == ListReactionsToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func AddReactionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Reaction] Called AddReactionFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
emojiName, err := params.GetString(args, "emoji_name")
if err != nil {
return to.Error(fmt.Errorf("[emoji_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
reaction := &model.Reaction{
UserId: me.Id,
PostId: postId,
EmojiName: emojiName,
}
result, err := client.SaveReaction(ctx, reaction)
if err != nil {
return to.Error(fmt.Errorf("[reaction] failed to add reaction: %v", err)), nil
}
return to.Result(SlimReaction(result)), nil
}
func RemoveReactionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Reaction] Called RemoveReactionFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
emojiName, err := params.GetString(args, "emoji_name")
if err != nil {
return to.Error(fmt.Errorf("[emoji_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
reaction := &model.Reaction{
UserId: me.Id,
PostId: postId,
EmojiName: emojiName,
}
err = client.DeleteReaction(ctx, reaction)
if err != nil {
return to.Error(fmt.Errorf("[reaction] failed to remove reaction: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"post_id": postId,
"emoji_name": emojiName,
"message": "Reaction removed successfully",
}), nil
}
func ListReactionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Reaction] Called ListReactionsFn")
args := req.GetArguments()
postId, err := params.GetString(args, "post_id")
if err != nil {
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
reactions, err := client.GetReactions(ctx, postId)
if err != nil {
return to.Error(fmt.Errorf("[reaction] failed to list reactions: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(reactions))
for _, r := range reactions {
results = append(results, SlimReaction(r))
}
return to.Result(map[string]interface{}{
"reactions": results,
"count": len(results),
"post_id": postId,
}), nil
}
+78
View File
@@ -0,0 +1,78 @@
package reaction
import (
"testing"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimReaction(t *testing.T) {
tests := []struct {
name string
reaction *model.Reaction
expected map[string]interface{}
}{
{
name: "valid reaction",
reaction: &model.Reaction{
UserId: "user123",
PostId: "post456",
EmojiName: "thumbsup",
CreateAt: 1234567890,
},
expected: map[string]interface{}{
"user_id": "user123",
"post_id": "post456",
"emoji_name": "thumbsup",
"create_at": int64(1234567890),
},
},
{
name: "nil reaction",
reaction: nil,
expected: nil,
},
{
name: "reaction with different emoji",
reaction: &model.Reaction{
UserId: "user789",
PostId: "post012",
EmojiName: "rocket",
CreateAt: 9876543210,
},
expected: map[string]interface{}{
"user_id": "user789",
"post_id": "post012",
"emoji_name": "rocket",
"create_at": int64(9876543210),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SlimReaction(tt.reaction)
assert.Equal(t, tt.expected, result)
})
}
}
func TestToolConstants(t *testing.T) {
assert.Equal(t, "mattermost_add_reaction", AddReactionToolName)
assert.Equal(t, "mattermost_remove_reaction", RemoveReactionToolName)
}
func TestToolRegistration(t *testing.T) {
// Verify tools are registered
tools := Tool.Tools()
assert.Len(t, tools, 2)
toolNames := make([]string, len(tools))
for i, t := range tools {
toolNames[i] = t.Tool.Name
}
assert.Contains(t, toolNames, AddReactionToolName)
assert.Contains(t, toolNames, RemoveReactionToolName)
}
+15
View File
@@ -0,0 +1,15 @@
package reaction
import "github.com/mattermost/mattermost-server/v6/model"
func SlimReaction(r *model.Reaction) map[string]interface{} {
if r == nil {
return nil
}
return map[string]interface{}{
"user_id": r.UserId,
"post_id": r.PostId,
"emoji_name": r.EmojiName,
"create_at": r.CreateAt,
}
}
+100
View File
@@ -0,0 +1,100 @@
package system
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
GetSystemLogsToolName = "mattermost_get_system_logs"
GetServerConfigToolName = "mattermost_get_server_config"
)
var (
GetSystemLogsTool = mcp.NewTool(
GetSystemLogsToolName,
mcp.WithDescription("Get system logs (requires admin privileges)"),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Log lines per page (default 100, max 500)")),
)
GetServerConfigTool = mcp.NewTool(
GetServerConfigToolName,
mcp.WithDescription("Get server configuration (requires admin privileges)"),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: GetSystemLogsTool, Handler: GetSystemLogsFn},
{Tool: GetServerConfigTool, Handler: GetServerConfigFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func GetSystemLogsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[System] Called GetSystemLogsFn")
args := req.GetArguments()
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 100)
if perPage > 500 {
perPage = 500
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
logs, _, err := client.GetSystemLogs(ctx, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[system] failed to get logs: %v", err)), nil
}
return to.Result(map[string]interface{}{
"logs": logs,
"count": len(logs),
"page": page,
"per_page": perPage,
}), nil
}
func GetServerConfigFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[System] Called GetServerConfigFn")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
config, err := client.GetConfig(ctx)
if err != nil {
return to.Error(fmt.Errorf("[system] failed to get config: %v", err)), nil
}
return to.Result(map[string]interface{}{
"site_name": config.TeamSettings.SiteName,
"max_notifications_per_channel": config.TeamSettings.MaxNotificationsPerChannel,
"enable_custom_emoji": config.ServiceSettings.EnableCustomEmoji,
"enable_link_previews": config.ServiceSettings.EnableLinkPreviews,
"enable_public_channels": config.TeamSettings.EnableOpenServer,
}), nil
}
+15
View File
@@ -0,0 +1,15 @@
package team
import "github.com/mattermost/mattermost-server/v6/model"
func SlimTeam(t *model.Team) map[string]interface{} {
if t == nil {
return nil
}
return map[string]interface{}{
"id": t.Id,
"name": t.Name,
"display_name": t.DisplayName,
"description": t.Description,
}
}
+245
View File
@@ -0,0 +1,245 @@
package team
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ListTeamsToolName = "mattermost_list_teams"
ListTeamMembersToolName = "mattermost_list_team_members"
InviteUserToTeamToolName = "mattermost_invite_user_to_team"
RemoveUserFromTeamToolName = "mattermost_remove_user_from_team"
GetTeamStatsToolName = "mattermost_get_team_stats"
)
var (
ListTeamsTool = mcp.NewTool(
ListTeamsToolName,
mcp.WithDescription("List all teams the bot has access to"),
)
ListTeamMembersTool = mcp.NewTool(
ListTeamMembersToolName,
mcp.WithDescription("List all members of a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list members for")),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Members per page (default 60, max 200)")),
)
InviteUserToTeamTool = mcp.NewTool(
InviteUserToTeamToolName,
mcp.WithDescription("Invite/add a user to a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to invite user to")),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to invite")),
)
RemoveUserFromTeamTool = mcp.NewTool(
RemoveUserFromTeamToolName,
mcp.WithDescription("Remove a user from a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to remove user from")),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to remove")),
)
GetTeamStatsTool = mcp.NewTool(
GetTeamStatsToolName,
mcp.WithDescription("Get statistics for a team (member count, etc.)"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to get stats for")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: ListTeamsTool, Handler: ListTeamsFn},
{Tool: ListTeamMembersTool, Handler: ListTeamMembersFn},
{Tool: GetTeamStatsTool, Handler: GetTeamStatsFn},
{Tool: InviteUserToTeamTool, Handler: InviteUserToTeamFn},
{Tool: RemoveUserFromTeamTool, Handler: RemoveUserFromTeamFn},
}
for _, t := range tools {
if t.Tool.Name == InviteUserToTeamToolName || t.Tool.Name == RemoveUserFromTeamToolName {
Tool.RegisterWrite(t)
} else {
Tool.RegisterRead(t)
}
}
}
func ListTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called ListTeamsFn")
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
user, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
}
teams, err := client.GetTeamsForUser(ctx, user.Id)
if err != nil {
return to.Error(fmt.Errorf("[teams] failed to list teams: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(teams))
for _, t := range teams {
results = append(results, SlimTeam(t))
}
return to.Result(map[string]interface{}{
"teams": results,
"count": len(results),
}), nil
}
func ListTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called ListTeamMembersFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 60)
if perPage > 200 {
perPage = 200
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
members, err := client.ListTeamMembers(ctx, teamID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[team] failed to list members: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(members))
for _, m := range members {
results = append(results, map[string]interface{}{
"user_id": m.UserId,
"roles": m.Roles,
})
}
return to.Result(map[string]interface{}{
"members": results,
"count": len(results),
"team_id": teamID,
"page": page,
"per_page": perPage,
}), nil
}
func InviteUserToTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called InviteUserToTeamFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
userID, err := params.GetString(args, "user_id")
if err != nil {
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
_, err = client.InviteUserToTeam(ctx, teamID, userID)
if err != nil {
return to.Error(fmt.Errorf("[team] failed to invite user: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"team_id": teamID,
"user_id": userID,
"message": "User invited to team successfully",
}), nil
}
func RemoveUserFromTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called RemoveUserFromTeamFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
userID, err := params.GetString(args, "user_id")
if err != nil {
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.RemoveUserFromTeam(ctx, teamID, userID)
if err != nil {
return to.Error(fmt.Errorf("[team] failed to remove user: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"team_id": teamID,
"user_id": userID,
"message": "User removed from team successfully",
}), nil
}
func GetTeamStatsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Team] Called GetTeamStatsFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
stats, err := client.GetTeamStats(ctx, teamID)
if err != nil {
return to.Error(fmt.Errorf("[team] failed to get stats: %v", err)), nil
}
return to.Result(map[string]interface{}{
"team_id": teamID,
"total_members": stats.TotalMemberCount,
"active_members": stats.ActiveMemberCount,
}), nil
}
+59
View File
@@ -0,0 +1,59 @@
package team
import (
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimTeam(t *testing.T) {
team := &model.Team{
Id: "team123",
Name: "my-team",
DisplayName: "My Team",
Description: "A test team for unit testing",
}
slim := SlimTeam(team)
assert.NotNil(t, slim)
assert.Equal(t, "team123", slim["id"])
assert.Equal(t, "my-team", slim["name"])
assert.Equal(t, "My Team", slim["display_name"])
assert.Equal(t, "A test team for unit testing", slim["description"])
}
func TestSlimTeam_Nil(t *testing.T) {
slim := SlimTeam(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 1)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[ListTeamsToolName], "ListTeams tool should be registered")
}
func TestListTeamsFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: ListTeamsToolName,
Arguments: map[string]interface{}{},
},
}
result, err := ListTeamsFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+17
View File
@@ -0,0 +1,17 @@
package user
import "github.com/mattermost/mattermost-server/v6/model"
func SlimUser(u *model.User) map[string]interface{} {
if u == nil {
return nil
}
return map[string]interface{}{
"id": u.Id,
"username": u.Username,
"email": u.Email,
"first_name": u.FirstName,
"last_name": u.LastName,
"roles": u.Roles,
}
}
+171
View File
@@ -0,0 +1,171 @@
package user
import (
"context"
"fmt"
"strings"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetUserStatusToolName = "mattermost_get_user_status"
UpdateUserStatusToolName = "mattermost_update_user_status"
GetUsersStatusBulkToolName = "mattermost_get_users_status_bulk"
)
var (
GetUserStatusTool = mcp.NewTool(
GetUserStatusToolName,
mcp.WithDescription("Get the online status of a user (online, away, dnd, offline)"),
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to check status for")),
)
UpdateUserStatusTool = mcp.NewTool(
UpdateUserStatusToolName,
mcp.WithDescription("Update your status (online, away, dnd, offline)"),
mcp.WithString("status", mcp.Required(), mcp.Description("Status to set: online, away, dnd, or offline")),
)
GetUsersStatusBulkTool = mcp.NewTool(
GetUsersStatusBulkToolName,
mcp.WithDescription("Get status for multiple users at once (up to 100)"),
mcp.WithString("user_ids", mcp.Required(), mcp.Description("Comma-separated list of user IDs (max 100)")),
)
)
func init() {
registerStatusTools()
}
func registerStatusTools() {
tools := []server.ServerTool{
{Tool: GetUserStatusTool, Handler: GetUserStatusFn},
{Tool: UpdateUserStatusTool, Handler: UpdateUserStatusFn},
{Tool: GetUsersStatusBulkTool, Handler: GetUsersStatusBulkFn},
}
for _, t := range tools {
if t.Tool.Name == UpdateUserStatusToolName {
Tool.RegisterWrite(t)
} else {
Tool.RegisterRead(t)
}
}
}
func GetUserStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserStatusFn")
args := req.GetArguments()
userID, err := params.GetString(args, "user_id")
if err != nil {
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
status, err := client.GetUserStatus(ctx, userID)
if err != nil {
return to.Error(fmt.Errorf("[status] failed to get user status: %v", err)), nil
}
return to.Result(map[string]interface{}{
"user_id": status.UserId,
"status": status.Status,
"manual": status.Manual,
"last_activity_at": status.LastActivityAt,
}), nil
}
func UpdateUserStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called UpdateUserStatusFn")
args := req.GetArguments()
status, err := params.GetString(args, "status")
if err != nil {
return to.Error(fmt.Errorf("[status] %v", err)), nil
}
// Validate status value
validStatuses := map[string]bool{"online": true, "away": true, "dnd": true, "offline": true}
if !validStatuses[status] {
return to.Error(fmt.Errorf("[status] invalid status '%s', must be one of: online, away, dnd, offline", status)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
// Get current user ID
me, err := client.GetMe(ctx)
if err != nil {
return to.Error(fmt.Errorf("[me] failed to get current user: %v", err)), nil
}
updatedStatus, err := client.UpdateUserStatus(ctx, me.Id, status)
if err != nil {
return to.Error(fmt.Errorf("[status] failed to update status: %v", err)), nil
}
return to.Result(map[string]interface{}{
"user_id": updatedStatus.UserId,
"status": updatedStatus.Status,
"message": "Status updated successfully",
}), nil
}
func GetUsersStatusBulkFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUsersStatusBulkFn")
args := req.GetArguments()
userIDsStr, err := params.GetString(args, "user_ids")
if err != nil {
return to.Error(fmt.Errorf("[user_ids] %v", err)), nil
}
userIDs := strings.Split(userIDsStr, ",")
if len(userIDs) > 100 {
return to.Error(fmt.Errorf("[user_ids] too many user IDs (max 100, got %d)", len(userIDs))), nil
}
for i, id := range userIDs {
userIDs[i] = strings.TrimSpace(id)
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
statuses, err := client.GetUsersStatus(ctx, userIDs)
if err != nil {
return to.Error(fmt.Errorf("[status] failed to get users status: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(statuses))
for _, status := range statuses {
results = append(results, map[string]interface{}{
"user_id": status.UserId,
"status": status.Status,
"manual": status.Manual,
"last_activity_at": status.LastActivityAt,
})
}
return to.Result(map[string]interface{}{
"statuses": results,
"count": len(results),
}), nil
}
+129
View File
@@ -0,0 +1,129 @@
package user
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mattermost/mattermost-server/v6/model"
)
var Tool = tool.New()
const (
SearchUsersToolName = "mattermost_search_users"
GetUserToolName = "mattermost_get_user"
)
var (
SearchUsersTool = mcp.NewTool(
SearchUsersToolName,
mcp.WithDescription("Search users by term"),
mcp.WithString("term", mcp.Required(), mcp.Description("Search term (username, email, name)")),
mcp.WithString("team_id", mcp.Description("Limit to team (optional)")),
mcp.WithNumber("limit", mcp.Description("Max results (default 30)")),
)
GetUserTool = mcp.NewTool(
GetUserToolName,
mcp.WithDescription("Get a specific user by ID or username"),
mcp.WithString("user_id", mcp.Description("User ID to look up (optional if username provided)")),
mcp.WithString("username", mcp.Description("Username to look up (optional if user_id provided)")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: SearchUsersTool, Handler: SearchUsersFn},
{Tool: GetUserTool, Handler: GetUserFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
func SearchUsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called SearchUsersFn")
args := req.GetArguments()
term, err := params.GetString(args, "term")
if err != nil {
return to.Error(fmt.Errorf("[term] %v", err)), nil
}
teamID := params.GetOptionalString(args, "team_id", "")
limit := params.GetOptionalInt(args, "limit", 30)
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
search := &model.UserSearch{
Term: term,
Limit: int(limit),
}
if teamID != "" {
search.TeamId = teamID
}
users, err := client.SearchUsers(ctx, search)
if err != nil {
return to.Error(fmt.Errorf("[users] failed to search users: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
results = append(results, SlimUser(u))
}
return to.Result(map[string]interface{}{
"users": results,
"count": len(results),
}), nil
}
func GetUserFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserFn")
args := req.GetArguments()
userID := params.GetOptionalString(args, "user_id", "")
username := params.GetOptionalString(args, "username", "")
if userID == "" && username == "" {
return to.Error(fmt.Errorf("[user] either user_id or username must be provided")), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
var user *model.User
var err error
if userID != "" {
user, err = client.GetUser(ctx, userID)
} else {
user, err = client.GetUserByUsername(ctx, username)
}
if err != nil {
return to.Error(fmt.Errorf("[user] failed to get user: %v", err)), nil
}
return to.Result(SlimUser(user)), nil
}
+80
View File
@@ -0,0 +1,80 @@
package user
import (
"testing"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func TestSlimUser(t *testing.T) {
u := &model.User{
Id: "user123",
Username: "johndoe",
Email: "john@example.com",
FirstName: "John",
LastName: "Doe",
Roles: "system_user",
}
slim := SlimUser(u)
assert.NotNil(t, slim)
assert.Equal(t, "user123", slim["id"])
assert.Equal(t, "johndoe", slim["username"])
assert.Equal(t, "john@example.com", slim["email"])
assert.Equal(t, "John", slim["first_name"])
assert.Equal(t, "Doe", slim["last_name"])
assert.Equal(t, "system_user", slim["roles"])
}
func TestSlimUser_Nil(t *testing.T) {
slim := SlimUser(nil)
assert.Nil(t, slim)
}
func TestToolRegistration(t *testing.T) {
tools := Tool.Tools()
assert.Len(t, tools, 2)
toolNames := make(map[string]bool)
for _, t := range tools {
toolNames[t.Tool.Name] = true
}
assert.True(t, toolNames[SearchUsersToolName], "SearchUsers tool should be registered")
assert.True(t, toolNames[GetUserStatusToolName], "GetUserStatus tool should be registered")
}
func TestSearchUsersFn_ClientNotInitialized(t *testing.T) {
mattermost.SetGlobalClient(nil)
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SearchUsersToolName,
Arguments: map[string]interface{}{
"term": "john",
},
},
}
result, err := SearchUsersFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
func TestSearchUsersFn_MissingTerm(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: SearchUsersToolName,
Arguments: map[string]interface{}{},
},
}
result, err := SearchUsersFn(nil, req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError)
}
+168
View File
@@ -0,0 +1,168 @@
package webhook
import (
"context"
"fmt"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
CreateIncomingWebhookToolName = "mattermost_create_incoming_webhook"
ListIncomingWebhooksToolName = "mattermost_list_incoming_webhooks"
DeleteIncomingWebhookToolName = "mattermost_delete_incoming_webhook"
)
var (
CreateIncomingWebhookTool = mcp.NewTool(
CreateIncomingWebhookToolName,
mcp.WithDescription("Create an incoming webhook for a channel"),
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to create webhook for")),
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the webhook")),
)
ListIncomingWebhooksTool = mcp.NewTool(
ListIncomingWebhooksToolName,
mcp.WithDescription("List incoming webhooks for a team"),
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list webhooks for")),
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
mcp.WithNumber("per_page", mcp.Description("Items per page (default 20, max 100)")),
)
DeleteIncomingWebhookTool = mcp.NewTool(
DeleteIncomingWebhookToolName,
mcp.WithDescription("Delete an incoming webhook"),
mcp.WithString("webhook_id", mcp.Required(), mcp.Description("Webhook ID to delete")),
)
)
func init() {
registerTools()
}
func registerTools() {
tools := []server.ServerTool{
{Tool: CreateIncomingWebhookTool, Handler: CreateIncomingWebhookFn},
{Tool: ListIncomingWebhooksTool, Handler: ListIncomingWebhooksFn},
{Tool: DeleteIncomingWebhookTool, Handler: DeleteIncomingWebhookFn},
}
for _, t := range tools {
if t.Tool.Name == ListIncomingWebhooksToolName {
Tool.RegisterRead(t)
} else {
Tool.RegisterWrite(t)
}
}
}
func CreateIncomingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Webhook] Called CreateIncomingWebhookFn")
args := req.GetArguments()
channelID, err := params.GetString(args, "channel_id")
if err != nil {
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
}
displayName, err := params.GetString(args, "display_name")
if err != nil {
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hook, err := client.CreateIncomingWebhook(ctx, channelID, displayName)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to create: %v", err)), nil
}
return to.Result(map[string]interface{}{
"id": hook.Id,
"channel_id": hook.ChannelId,
"display_name": hook.DisplayName,
"message": "Webhook created successfully - retrieve URL from Mattermost UI",
}), nil
}
func ListIncomingWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Webhook] Called ListIncomingWebhooksFn")
args := req.GetArguments()
teamID, err := params.GetString(args, "team_id")
if err != nil {
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
}
page := params.GetOptionalInt(args, "page", 0)
perPage := params.GetOptionalInt(args, "per_page", 20)
if perPage > 100 {
perPage = 100
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
hooks, err := client.ListIncomingWebhooks(ctx, teamID, int(page), int(perPage))
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to list: %v", err)), nil
}
results := make([]map[string]interface{}, 0, len(hooks))
for _, hook := range hooks {
results = append(results, map[string]interface{}{
"id": hook.Id,
"channel_id": hook.ChannelId,
"display_name": hook.DisplayName,
"create_at": hook.CreateAt,
})
}
return to.Result(map[string]interface{}{
"webhooks": results,
"count": len(results),
"page": page,
"per_page": perPage,
}), nil
}
func DeleteIncomingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Webhook] Called DeleteIncomingWebhookFn")
args := req.GetArguments()
webhookID, err := params.GetString(args, "webhook_id")
if err != nil {
return to.Error(fmt.Errorf("[webhook_id] %v", err)), nil
}
client := mattermost.GetGlobalClient()
if client == nil {
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
}
err = client.DeleteIncomingWebhook(ctx, webhookID)
if err != nil {
return to.Error(fmt.Errorf("[webhook] failed to delete: %v", err)), nil
}
return to.Result(map[string]interface{}{
"success": true,
"webhook_id": webhookID,
"message": "Webhook deleted successfully",
}), nil
}
+360
View File
@@ -0,0 +1,360 @@
package errors
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
type ErrorCategory string
const (
CategoryFile ErrorCategory = "file"
CategoryAuth ErrorCategory = "auth"
CategoryRepo ErrorCategory = "repo"
CategoryChannel ErrorCategory = "channel"
CategoryUser ErrorCategory = "user"
CategoryMessage ErrorCategory = "message"
CategoryNetwork ErrorCategory = "network"
CategoryUnknown ErrorCategory = "unknown"
)
type EnhancedError struct {
Original error
Translated string
Category ErrorCategory
Operation string
Context map[string]string
Timestamp time.Time
}
func (e *EnhancedError) Error() string {
if e.Translated != "" {
return e.Translated
}
if e.Original != nil {
return e.Original.Error()
}
return "unknown error"
}
func (e *EnhancedError) Unwrap() error {
return e.Original
}
func (e *EnhancedError) WithContext(key, value string) *EnhancedError {
if e.Context == nil {
e.Context = make(map[string]string)
}
e.Context[key] = value
return e
}
func (e *EnhancedError) WithOperation(op string) *EnhancedError {
e.Operation = op
return e
}
func (e *EnhancedError) WithParam(key, value string) *EnhancedError {
return e.WithContext(key, value)
}
func (e *EnhancedError) FormatDetailed() string {
details := map[string]any{
"error": e.Error(),
"category": e.Category,
"timestamp": e.Timestamp.Format(time.RFC3339),
}
if e.Operation != "" {
details["operation"] = e.Operation
}
if len(e.Context) > 0 {
details["context"] = e.Context
}
if e.Original != nil && e.Original.Error() != e.Error() {
details["original"] = e.Original.Error()
}
jsonBytes, err := json.MarshalIndent(details, "", " ")
if err != nil {
return e.Format()
}
return string(jsonBytes)
}
func (e *EnhancedError) Format() string {
var parts []string
if e.Operation != "" {
parts = append(parts, fmt.Sprintf("Operation: %s", e.Operation))
}
parts = append(parts, fmt.Sprintf("Error: %s", e.Error()))
if e.Category != "" && e.Category != CategoryUnknown {
parts = append(parts, fmt.Sprintf("Category: %s", e.Category))
}
if len(e.Context) > 0 {
var ctxParts []string
for k, v := range e.Context {
ctxParts = append(ctxParts, fmt.Sprintf("%s=%s", k, v))
}
parts = append(parts, fmt.Sprintf("Context: %s", strings.Join(ctxParts, ", ")))
}
if e.Original != nil && e.Original.Error() != e.Error() {
parts = append(parts, fmt.Sprintf("Original: %s", e.Original.Error()))
}
return strings.Join(parts, " | ")
}
func TranslateError(err error, context map[string]string) error {
if err == nil {
return nil
}
var existing *EnhancedError
if errors.As(err, &existing) {
if context != nil {
for k, v := range context {
existing.WithContext(k, v)
}
}
return existing
}
translated, category := translateErrorMessage(err)
operation := ""
if context != nil {
operation = context["operation"]
}
enhanced := &EnhancedError{
Original: err,
Translated: translated,
Category: category,
Operation: operation,
Context: context,
Timestamp: time.Now().UTC(),
}
return enhanced
}
func translateErrorMessage(err error) (string, ErrorCategory) {
if err == nil {
return "", CategoryUnknown
}
msg := err.Error()
lowerMsg := strings.ToLower(msg)
if strings.Contains(msg, "404") {
return "Resource not found", CategoryUnknown
}
if strings.Contains(msg, "401") {
return "Authentication failed - check your access token", CategoryAuth
}
if strings.Contains(msg, "403") {
return "Permission denied - you don't have access to this resource", CategoryAuth
}
translations := []struct {
pattern string
message string
category ErrorCategory
}{
{"GetUser", "User not found", CategoryUser},
{"GetChannel", "Channel not found", CategoryChannel},
{"GetPost", "Message not found", CategoryMessage},
{"CreatePost", "Failed to create message", CategoryMessage},
{"UpdatePost", "Failed to update message", CategoryMessage},
{"DeletePost", "Failed to delete message", CategoryMessage},
{"CreateChannel", "Failed to create channel", CategoryChannel},
}
for _, t := range translations {
if strings.Contains(msg, t.pattern) {
return t.message, t.category
}
}
if strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "deadline exceeded") {
return "Request timed out - the server took too long to respond", CategoryNetwork
}
if strings.Contains(lowerMsg, "connection refused") || strings.Contains(lowerMsg, "no such host") {
return "Network error - cannot connect to server", CategoryNetwork
}
return msg, CategoryUnknown
}
func IsNotFound(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
switch enhanced.Category {
case CategoryFile, CategoryRepo, CategoryChannel, CategoryUser, CategoryMessage:
return true
}
return strings.Contains(enhanced.Translated, "not found")
}
var httpErr interface{ Error() string }
if errors.As(err, &httpErr) {
if strings.Contains(httpErr.Error(), "404") {
return true
}
}
lowerMsg := strings.ToLower(err.Error())
return strings.Contains(lowerMsg, "not found") || strings.Contains(lowerMsg, "404")
}
func IsAuthError(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryAuth
}
msg := err.Error()
if strings.Contains(msg, "401") || strings.Contains(msg, "403") {
return true
}
lowerMsg := strings.ToLower(msg)
return strings.Contains(lowerMsg, "authentication") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "permission denied") ||
strings.Contains(lowerMsg, "forbidden")
}
func IsTimeout(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryNetwork || strings.Contains(enhanced.Translated, "timed out")
}
lowerMsg := strings.ToLower(err.Error())
return strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "deadline exceeded") ||
strings.Contains(lowerMsg, "context deadline")
}
func IsNetworkError(err error) bool {
if err == nil {
return false
}
var enhanced *EnhancedError
if errors.As(err, &enhanced) {
return enhanced.Category == CategoryNetwork
}
lowerMsg := strings.ToLower(err.Error())
return strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "no such host") ||
strings.Contains(lowerMsg, "dial tcp")
}
func NewEnhancedError(original error, translated string, category ErrorCategory) *EnhancedError {
return &EnhancedError{
Original: original,
Translated: translated,
Category: category,
Context: make(map[string]string),
Timestamp: time.Now().UTC(),
}
}
func Wrap(err error, operation string) error {
if err == nil {
return nil
}
return TranslateError(err, map[string]string{"operation": operation})
}
type HTTPError interface {
error
Status() int
}
type statusError struct {
status int
message string
}
func (e *statusError) Error() string { return e.message }
func (e *statusError) Status() int { return e.status }
func IsHTTPError(err error, statusCode int) bool {
if err == nil {
return false
}
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.Status() == statusCode
}
msg := err.Error()
return strings.Contains(msg, fmt.Sprintf("status %d", statusCode)) ||
strings.Contains(msg, fmt.Sprintf("%d", statusCode))
}
func IsUnauthorized(err error) bool {
return IsHTTPError(err, http.StatusUnauthorized)
}
func IsForbidden(err error) bool {
return IsHTTPError(err, http.StatusForbidden)
}
func IsNotFoundHTTP(err error) bool {
return IsHTTPError(err, http.StatusNotFound)
}
func IsServerError(err error) bool {
if err == nil {
return false
}
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.Status() >= 500 && httpErr.Status() < 600
}
msg := err.Error()
for i := 500; i < 600; i++ {
if strings.Contains(msg, fmt.Sprintf("status %d", i)) ||
strings.Contains(msg, fmt.Sprintf("%d", i)) {
return true
}
}
return false
}
+150
View File
@@ -0,0 +1,150 @@
package file
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
const MaxFileSize = 50 * 1024 * 1024
var AllowedMimeTypes = []string{
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"text/plain",
"text/markdown",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
var DangerousExtensions = []string{
".exe", ".sh", ".bat", ".cmd", ".com", ".scr", ".pif", ".vbs",
".js", ".jse", ".wsf", ".wsh", ".ps1", ".ps2", ".msc",
".dll", ".so", ".dylib", ".bin",
}
func ValidatePath(base, target string) (string, error) {
cleanTarget := filepath.Clean(target)
if filepath.IsAbs(cleanTarget) {
return "", fmt.Errorf("absolute paths not allowed: %s", target)
}
fullPath := filepath.Join(base, cleanTarget)
realBase, err := filepath.Abs(base)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for base: %w", err)
}
realTarget, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for target: %w", err)
}
if !filepath.HasPrefix(realTarget, realBase) {
return "", fmt.Errorf("path traversal detected: %s", target)
}
return realTarget, nil
}
func Exists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
func IsValidPath(path string) bool {
cleanPath := filepath.Clean(path)
if strings.Contains(cleanPath, "..") {
return false
}
return true
}
func SanitizePath(path string) (string, error) {
if !IsValidPath(path) {
return "", fmt.Errorf("invalid path: path traversal detected: %s", path)
}
return filepath.Clean(path), nil
}
func ValidateFileSize(size int64) error {
if size > MaxFileSize {
return fmt.Errorf("file size %d bytes exceeds maximum allowed size of %d bytes (50MB)", size, MaxFileSize)
}
return nil
}
func IsAllowedMimeType(mimeType string) bool {
baseMimeType := strings.Split(mimeType, ";")[0]
baseMimeType = strings.TrimSpace(baseMimeType)
for _, allowed := range AllowedMimeTypes {
if strings.EqualFold(baseMimeType, allowed) {
return true
}
}
if strings.HasPrefix(baseMimeType, "application/vnd.openxmlformats-officedocument") {
return true
}
return false
}
func DetectMimeType(data []byte) string {
return http.DetectContentType(data)
}
func ValidateMimeType(data []byte) (string, error) {
mimeType := DetectMimeType(data)
if !IsAllowedMimeType(mimeType) {
return "", fmt.Errorf("MIME type %q is not in the allowed whitelist", mimeType)
}
return mimeType, nil
}
func IsDangerousExtension(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
for _, dangerous := range DangerousExtensions {
if ext == dangerous {
return true
}
}
return false
}
func ValidateFilename(filename string) error {
if filename == "" {
return fmt.Errorf("filename cannot be empty")
}
if IsDangerousExtension(filename) {
return fmt.Errorf("file type %q is not allowed for security reasons", filepath.Ext(filename))
}
if strings.Contains(filename, "\x00") {
return fmt.Errorf("filename contains invalid characters")
}
return nil
}
func GetFileInfo(path string) (os.FileInfo, error) {
return os.Stat(path)
}
func CheckDiskSpace(path string, requiredBytes int64) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
testFile := filepath.Join(dir, ".write_test_"+fmt.Sprintf("%d", os.Getpid()))
f, err := os.Create(testFile)
if err != nil {
return fmt.Errorf("insufficient permissions or disk space at %s: %w", dir, err)
}
f.Close()
os.Remove(testFile)
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package flag
var (
Version = "dev"
ReadOnly = false
Debug = false
ShowVersion = false
Insecure = false
Token = ""
BotToken = ""
PAT = ""
Host = ""
Team = ""
)
+85
View File
@@ -0,0 +1,85 @@
package log
import (
"context"
"fmt"
"sync"
"time"
)
type contextKey string
const (
correlationIDKey contextKey = "correlation_id"
operationKey contextKey = "operation"
startTimeKey contextKey = "start_time"
)
var (
correlationIDGenerator = &idGenerator{}
)
type idGenerator struct {
mu sync.Mutex
seq uint64
}
func (g *idGenerator) Generate() string {
g.mu.Lock()
defer g.mu.Unlock()
g.seq++
return time.Now().Format("20060102-150405") + "-" + fmt.Sprint(g.seq)
}
func WithCorrelationID(ctx context.Context, id string) context.Context {
if id == "" {
id = correlationIDGenerator.Generate()
}
return context.WithValue(ctx, correlationIDKey, id)
}
func WithOperation(ctx context.Context, operation string) context.Context {
return context.WithValue(ctx, operationKey, operation)
}
func WithStartTime(ctx context.Context) context.Context {
return context.WithValue(ctx, startTimeKey, time.Now())
}
func GetCorrelationID(ctx context.Context) string {
if ctx == nil {
return ""
}
if id, ok := ctx.Value(correlationIDKey).(string); ok {
return id
}
return ""
}
func GetOperation(ctx context.Context) string {
if ctx == nil {
return ""
}
if op, ok := ctx.Value(operationKey).(string); ok {
return op
}
return ""
}
func GetStartTime(ctx context.Context) time.Time {
if ctx == nil {
return time.Time{}
}
if t, ok := ctx.Value(startTimeKey).(time.Time); ok {
return t
}
return time.Time{}
}
func Duration(ctx context.Context) time.Duration {
start := GetStartTime(ctx)
if start.IsZero() {
return 0
}
return time.Since(start)
}
+139
View File
@@ -0,0 +1,139 @@
package log
import (
"context"
"os"
"sync"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
defaultLoggerOnce sync.Once
defaultLogger *zap.Logger
)
func Default() *zap.Logger {
defaultLoggerOnce.Do(func() {
if defaultLogger != nil {
return
}
ec := zap.NewProductionEncoderConfig()
ec.EncodeTime = zapcore.TimeEncoderOfLayout(time.DateTime)
ec.EncodeLevel = zapcore.CapitalLevelEncoder
var ws zapcore.WriteSyncer
var wss []zapcore.WriteSyncer
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
logDir := home + "/.mattermost-mcp"
if err := os.MkdirAll(logDir, 0o700); err != nil {
logDir = os.TempDir()
}
wss = append(wss, zapcore.AddSync(&lumberjack.Logger{
Filename: logDir + "/mattermost-mcp.log",
MaxSize: 10,
MaxBackups: 3,
MaxAge: 28,
}))
ws = zapcore.NewMultiWriteSyncer(wss...)
enc := zapcore.NewConsoleEncoder(ec)
core := zapcore.NewCore(enc, ws, zapcore.InfoLevel)
options := []zap.Option{
zap.AddStacktrace(zapcore.DPanicLevel),
zap.AddCaller(),
zap.AddCallerSkip(1),
}
defaultLogger = zap.New(core, options...)
})
return defaultLogger
}
func SetDefault(logger *zap.Logger) {
if logger != nil {
defaultLogger = logger
}
}
type Logger struct {
*zap.Logger
ctx context.Context
}
func New() *Logger {
return WithContext(context.Background())
}
func WithContext(ctx context.Context) *Logger {
return &Logger{
Logger: Default(),
ctx: ctx,
}
}
func Debug(msg string, fields ...zap.Field) {
Default().Debug(msg, fields...)
}
func Info(msg string, fields ...zap.Field) {
Default().Info(msg, fields...)
}
func Warn(msg string, fields ...zap.Field) {
Default().Warn(msg, fields...)
}
func Error(msg string, fields ...zap.Field) {
Default().Error(msg, fields...)
}
func Panic(msg string, fields ...zap.Field) {
Default().Panic(msg, fields...)
}
func Debugf(format string, args ...any) {
Default().Sugar().Debugf(format, args...)
}
func Infof(format string, args ...any) {
Default().Sugar().Infof(format, args...)
}
func Warnf(format string, args ...any) {
Default().Sugar().Warnf(format, args...)
}
func Errorf(format string, args ...any) {
Default().Sugar().Errorf(format, args...)
}
func Fatalf(format string, args ...any) {
Default().Sugar().Fatalf(format, args...)
}
func Initialize(level string) error {
// Logging is already initialized in Default()
return nil
}
func Sync() {
Default().Sync()
}
type Field = zap.Field
func String(key string, value string) Field {
return zap.String(key, value)
}
+889
View File
@@ -0,0 +1,889 @@
// Package mattermost provides a wrapper around the Mattermost Go client
// with dual token support, timeout handling, retry logic, and error categorization.
package mattermost
import (
"context"
"fmt"
"math"
"net/http"
"strings"
"time"
"github.com/karti-ai/mattermost-mcp-server/pkg/errors"
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
"github.com/mattermost/mattermost-server/v6/model"
"go.uber.org/zap"
)
const (
// DefaultTimeout is the default timeout for all API requests
DefaultTimeout = 30 * time.Second
// MaxRetries is the maximum number of retries for 5xx errors
MaxRetries = 3
// InitialBackoff is the initial backoff duration for retries
InitialBackoff = 500 * time.Millisecond
)
// Client wraps the Mattermost Go client with enhanced functionality
type Client struct {
client *model.Client4
botToken string
pat string
host string
}
// Global client instance for use by operation handlers
var globalClient *Client
// SetGlobalClient sets the global client instance
func SetGlobalClient(c *Client) {
globalClient = c
}
// GetGlobalClient returns the global client instance
func GetGlobalClient() *Client {
return globalClient
}
// NewClient creates a new Mattermost client with the given configuration
func NewClient(host, botToken, pat string) *Client {
c := model.NewAPIv4Client(host)
// Initialize HTTPHeader map to store custom headers
c.HTTPHeader = make(map[string]string)
return &Client{
client: c,
botToken: botToken,
pat: pat,
host: host,
}
}
// getToken returns the appropriate token based on operation type
// Uses BotToken for read operations (GET), PAT for write operations (POST/PUT/DELETE)
func (c *Client) getToken(isWrite bool) string {
if isWrite && c.pat != "" {
return c.pat
}
if c.botToken != "" {
return c.botToken
}
return c.pat
}
// setToken sets the appropriate token on the client based on HTTP method
func (c *Client) setToken(method string) {
isWrite := method != http.MethodGet && method != http.MethodHead
token := c.getToken(isWrite)
if token != "" {
c.client.HTTPHeader["Authorization"] = "Bearer " + token
}
}
// isRetryableError checks if an error warrants a retry
func isRetryableError(resp *model.Response, err error) bool {
if resp == nil {
return true // Network errors should be retried
}
return resp.StatusCode >= 500 && resp.StatusCode < 600
}
// calculateBackoff calculates the backoff duration for retry attempts
func calculateBackoff(attempt int) time.Duration {
// Exponential backoff: 500ms, 1s, 2s
backoff := InitialBackoff * time.Duration(math.Pow(2, float64(attempt)))
// Add jitter
return backoff + time.Duration(time.Now().UnixNano()%100)*time.Millisecond
}
// logRequest logs API request details (NEVER logs tokens)
func (c *Client) logRequest(ctx context.Context, method, path string) {
logger := log.WithContext(ctx)
logger.Debug("sending API request",
zap.String("method", method),
zap.String("path", path),
zap.String("host", c.host),
)
}
// logResponse logs API response details
func (c *Client) logResponse(ctx context.Context, method, path string, statusCode int, duration time.Duration, err error) {
logger := log.WithContext(ctx)
if err != nil {
logger.Error("API request failed",
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", statusCode),
zap.Duration("duration", duration),
zap.Error(err),
)
} else {
logger.Debug("API request completed",
zap.String("method", method),
zap.String("path", path),
zap.Int("status_code", statusCode),
zap.Duration("duration", duration),
)
}
}
// mapError converts Mattermost errors to categorized errors
func (c *Client) mapError(resp *model.Response, err error, operation string) error {
if err == nil {
return nil
}
var category errors.ErrorCategory
var translated string
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
}
switch statusCode {
case 401:
category = errors.CategoryAuth
translated = "Authentication failed - check your access token"
case 403:
category = errors.CategoryAuth
translated = "Permission denied - you don't have access to this resource"
case 404:
category = errors.CategoryUnknown
translated = "Resource not found"
case 429:
category = errors.CategoryNetwork
translated = "Rate limited - too many requests, please try again later"
case 500, 502, 503, 504:
category = errors.CategoryNetwork
translated = "Server error - the Mattermost server encountered a problem"
default:
if resp == nil {
category = errors.CategoryNetwork
translated = "Connection failed - unable to reach Mattermost server"
} else {
// Try to extract error message from Mattermost API error
if appErr, ok := err.(*model.AppError); ok && appErr.Message != "" {
translated = appErr.Message
category = c.categorizeAppError(appErr)
} else {
translated = err.Error()
category = errors.CategoryUnknown
}
}
}
enhanced := errors.NewEnhancedError(err, translated, category)
enhanced.WithOperation(operation)
return enhanced
}
// categorizeAppError determines error category from Mattermost AppError
func (c *Client) categorizeAppError(appErr *model.AppError) errors.ErrorCategory {
id := strings.ToLower(appErr.Id)
message := strings.ToLower(appErr.Message)
// Check error ID patterns
switch {
case strings.Contains(id, "authentication") || strings.Contains(id, "auth"):
return errors.CategoryAuth
case strings.Contains(id, "channel"):
return errors.CategoryChannel
case strings.Contains(id, "user"):
return errors.CategoryUser
case strings.Contains(id, "post") || strings.Contains(id, "message"):
return errors.CategoryMessage
case strings.Contains(id, "file"):
return errors.CategoryFile
}
// Check message patterns
switch {
case strings.Contains(message, "channel") && strings.Contains(message, "not found"):
return errors.CategoryChannel
case strings.Contains(message, "user") && strings.Contains(message, "not found"):
return errors.CategoryUser
case strings.Contains(message, "post") && strings.Contains(message, "not found"):
return errors.CategoryMessage
case strings.Contains(message, "file") && strings.Contains(message, "not found"):
return errors.CategoryFile
case strings.Contains(message, "permission") || strings.Contains(message, "unauthorized"):
return errors.CategoryAuth
}
return errors.CategoryUnknown
}
// executeWithRetry executes an API call with retry logic for 5xx errors
func (c *Client) executeWithRetry(
ctx context.Context,
operation string,
method string,
path string,
fn func() (*model.Response, error),
) (*model.Response, error) {
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
c.setToken(method)
c.logRequest(ctx, method, path)
start := time.Now()
var lastErr error
var resp *model.Response
for attempt := 0; attempt <= MaxRetries; attempt++ {
if attempt > 0 {
backoff := calculateBackoff(attempt - 1)
log.WithContext(ctx).Debug("retrying request",
zap.String("operation", operation),
zap.Int("attempt", attempt),
zap.Duration("backoff", backoff),
)
time.Sleep(backoff)
}
resp, lastErr = fn()
duration := time.Since(start)
if lastErr == nil {
if resp != nil {
c.logResponse(ctx, method, path, resp.StatusCode, duration, nil)
}
return resp, nil
}
if !isRetryableError(resp, lastErr) {
c.logResponse(ctx, method, path, 0, duration, lastErr)
return resp, c.mapError(resp, lastErr, operation)
}
// Log retryable error
if resp != nil {
log.WithContext(ctx).Warn("retryable error occurred",
zap.String("operation", operation),
zap.Int("attempt", attempt),
zap.Int("status_code", resp.StatusCode),
zap.Error(lastErr),
)
}
}
// All retries exhausted
duration := time.Since(start)
c.logResponse(ctx, method, path, 0, duration, fmt.Errorf("max retries exceeded: %w", lastErr))
return resp, c.mapError(resp, fmt.Errorf("max retries exceeded: %w", lastErr), operation)
}
// ==================== User Operations ====================
// GetMe retrieves the current authenticated user
func (c *Client) GetMe(ctx context.Context) (*model.User, error) {
c.setToken(http.MethodGet)
user, resp, err := c.client.GetMe("")
if err != nil {
return nil, c.mapError(resp, err, "GetMe")
}
return user, nil
}
// GetUser retrieves a user by ID
func (c *Client) GetUser(ctx context.Context, userID string) (*model.User, error) {
c.setToken(http.MethodGet)
user, resp, err := c.client.GetUser(userID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetUser")
}
return user, nil
}
// GetUserByUsername retrieves a user by username
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*model.User, error) {
c.setToken(http.MethodGet)
user, resp, err := c.client.GetUserByUsername(username, "")
if err != nil {
return nil, c.mapError(resp, err, "GetUserByUsername")
}
return user, nil
}
// SearchUsers searches for users based on search criteria
func (c *Client) SearchUsers(ctx context.Context, search *model.UserSearch) ([]*model.User, error) {
c.setToken(http.MethodPost)
users, resp, err := c.client.SearchUsers(search)
if err != nil {
return nil, c.mapError(resp, err, "SearchUsers")
}
return users, nil
}
// GetUserStatus retrieves the status of a user (online, away, offline, dnd)
func (c *Client) GetUserStatus(ctx context.Context, userID string) (*model.Status, error) {
c.setToken(http.MethodGet)
status, resp, err := c.client.GetUserStatus(userID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetUserStatus")
}
return status, nil
}
// ==================== Channel Operations ====================
// GetChannel retrieves a channel by ID
func (c *Client) GetChannel(ctx context.Context, channelID string) (*model.Channel, error) {
c.setToken(http.MethodGet)
channel, resp, err := c.client.GetChannel(channelID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetChannel")
}
return channel, nil
}
// GetChannelByName retrieves a channel by name in a team
func (c *Client) GetChannelByName(ctx context.Context, teamID, channelName string) (*model.Channel, error) {
c.setToken(http.MethodGet)
channel, resp, err := c.client.GetChannelByName(channelName, teamID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetChannelByName")
}
return channel, nil
}
// GetChannelsForTeamForUser retrieves channels for a user in a team
func (c *Client) GetChannelsForTeamForUser(ctx context.Context, teamID, userID string, includeDeleted bool) ([]*model.Channel, error) {
c.setToken(http.MethodGet)
channels, resp, err := c.client.GetChannelsForTeamForUser(teamID, userID, includeDeleted, "")
if err != nil {
return nil, c.mapError(resp, err, "GetChannelsForTeamForUser")
}
return channels, nil
}
// CreateDirectChannel creates a direct message channel between two users
func (c *Client) CreateDirectChannel(ctx context.Context, userID1, userID2 string) (*model.Channel, error) {
c.setToken(http.MethodPost)
channel, resp, err := c.client.CreateDirectChannel(userID1, userID2)
if err != nil {
return nil, c.mapError(resp, err, "CreateDirectChannel")
}
return channel, nil
}
// GetTeamUnread retrieves unread counts for a team
func (c *Client) GetTeamUnread(ctx context.Context, userID, teamID string) (*model.TeamUnread, error) {
c.setToken(http.MethodGet)
unread, resp, err := c.client.GetTeamUnread(userID, teamID)
if err != nil {
return nil, c.mapError(resp, err, "GetTeamUnread")
}
return unread, nil
}
// GetChannelMembersForUser retrieves channel members for a user in a team
func (c *Client) GetChannelMembersForUser(ctx context.Context, userID, teamID string) (model.ChannelMembers, error) {
c.setToken(http.MethodGet)
members, resp, err := c.client.GetChannelMembersForUser(userID, teamID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetChannelMembersForUser")
}
return members, nil
}
// MarkChannelAsRead marks a channel as read for a user
func (c *Client) MarkChannelAsRead(ctx context.Context, channelID, userID string) (*model.ChannelViewResponse, error) {
c.setToken(http.MethodPut)
viewResp, resp, err := c.client.ViewChannel(userID, &model.ChannelView{ChannelId: channelID, PrevChannelId: ""})
if err != nil {
return nil, c.mapError(resp, err, "MarkChannelAsRead")
}
return viewResp, nil
}
// ==================== Post Operations ====================
// GetPostsForChannel retrieves posts for a channel
func (c *Client) GetPostsForChannel(ctx context.Context, channelID string, page, perPage int, collapsedThreads bool) (*model.PostList, error) {
c.setToken(http.MethodGet)
posts, resp, err := c.client.GetPostsForChannel(channelID, page, perPage, "", collapsedThreads)
if err != nil {
return nil, c.mapError(resp, err, "GetPostsForChannel")
}
return posts, nil
}
// SearchPosts searches for posts using terms
func (c *Client) SearchPosts(ctx context.Context, teamID, terms string, isOrSearch bool) (*model.PostList, error) {
c.setToken(http.MethodPost)
results, resp, err := c.client.SearchPosts(teamID, terms, isOrSearch)
if err != nil {
return nil, c.mapError(resp, err, "SearchPosts")
}
return results, nil
}
// CreatePost creates a new post in a channel
func (c *Client) CreatePost(ctx context.Context, post *model.Post) (*model.Post, error) {
c.setToken(http.MethodPost)
created, resp, err := c.client.CreatePost(post)
if err != nil {
return nil, c.mapError(resp, err, "CreatePost")
}
return created, nil
}
// UpdatePost updates an existing post
func (c *Client) UpdatePost(ctx context.Context, postID string, post *model.Post) (*model.Post, error) {
c.setToken(http.MethodPut)
updated, resp, err := c.client.UpdatePost(postID, post)
if err != nil {
return nil, c.mapError(resp, err, "UpdatePost")
}
return updated, nil
}
// DeletePost deletes a post by ID
func (c *Client) DeletePost(ctx context.Context, postID string) error {
_, err := c.executeWithRetry(
ctx,
"DeletePost",
http.MethodDelete,
fmt.Sprintf("/api/v4/posts/%s", postID),
func() (*model.Response, error) {
r, e := c.client.DeletePost(postID)
return r, e
},
)
return err
}
// ==================== Reaction Operations ====================
// GetReactions retrieves all reactions for a post
func (c *Client) GetReactions(ctx context.Context, postID string) ([]*model.Reaction, error) {
c.setToken(http.MethodGet)
reactions, resp, err := c.client.GetReactions(postID)
if err != nil {
return nil, c.mapError(resp, err, "GetReactions")
}
return reactions, nil
}
// SaveReaction adds a reaction to a post
func (c *Client) SaveReaction(ctx context.Context, reaction *model.Reaction) (*model.Reaction, error) {
c.setToken(http.MethodPost)
saved, resp, err := c.client.SaveReaction(reaction)
if err != nil {
return nil, c.mapError(resp, err, "SaveReaction")
}
return saved, nil
}
// DeleteReaction removes a reaction from a post
func (c *Client) DeleteReaction(ctx context.Context, reaction *model.Reaction) error {
_, err := c.executeWithRetry(
ctx,
"DeleteReaction",
http.MethodDelete,
"/api/v4/reactions",
func() (*model.Response, error) {
r, e := c.client.DeleteReaction(reaction)
return r, e
},
)
return err
}
// ==================== File Operations ====================
// UploadFile uploads a file to a channel
func (c *Client) UploadFile(ctx context.Context, data []byte, channelID, filename string) (*model.FileUploadResponse, error) {
c.setToken(http.MethodPost)
uploadResp, resp, err := c.client.UploadFile(data, channelID, filename)
if err != nil {
return nil, c.mapError(resp, err, "UploadFile")
}
return uploadResp, nil
}
// GetFile retrieves a file by ID
func (c *Client) GetFile(ctx context.Context, fileID string) ([]byte, *model.Response, error) {
c.setToken(http.MethodGet)
data, resp, err := c.client.GetFile(fileID)
if err != nil {
return nil, resp, c.mapError(resp, err, "GetFile")
}
return data, resp, nil
}
// ==================== Team Operations ====================
// GetTeamsForUser retrieves all teams a user is a member of
func (c *Client) GetTeamsForUser(ctx context.Context, userID string) ([]*model.Team, error) {
c.setToken(http.MethodGet)
teams, resp, err := c.client.GetTeamsForUser(userID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetTeamsForUser")
}
return teams, nil
}
// GetChannelPosts retrieves posts for a channel with pagination support
func (c *Client) GetChannelPosts(ctx context.Context, channelID string, limit int, before, after string) (*model.PostList, error) {
c.setToken(http.MethodGet)
var posts *model.PostList
var resp *model.Response
var err error
if before != "" {
posts, resp, err = c.client.GetPostsBefore(channelID, before, 0, limit, "", false)
} else if after != "" {
posts, resp, err = c.client.GetPostsAfter(channelID, after, 0, limit, "", false)
} else {
posts, resp, err = c.client.GetPostsForChannel(channelID, 0, limit, "", false)
}
if err != nil {
return nil, c.mapError(resp, err, "GetChannelPosts")
}
return posts, nil
}
// GetPostThread retrieves all posts in a thread (parent + replies)
func (c *Client) GetPostThread(ctx context.Context, postID string) (*model.PostList, error) {
c.setToken(http.MethodGet)
posts, resp, err := c.client.GetPostThread(postID, "", false)
if err != nil {
return nil, c.mapError(resp, err, "GetPostThread")
}
return posts, nil
}
// GetChannelMembers retrieves all members of a channel
func (c *Client) GetChannelMembers(ctx context.Context, channelID string, page, perPage int) (model.ChannelMembers, error) {
c.setToken(http.MethodGet)
members, resp, err := c.client.GetChannelMembers(channelID, page, perPage, "")
if err != nil {
return nil, c.mapError(resp, err, "GetChannelMembers")
}
return members, nil
}
// CreateGroupChannel creates a group DM channel with multiple users
func (c *Client) CreateGroupChannel(ctx context.Context, userIDs []string) (*model.Channel, error) {
c.setToken(http.MethodPost)
channel, resp, err := c.client.CreateGroupChannel(userIDs)
if err != nil {
return nil, c.mapError(resp, err, "CreateGroupChannel")
}
return channel, nil
}
// UpdateUserStatus updates the current user's status (online, away, dnd, offline)
func (c *Client) UpdateUserStatus(ctx context.Context, userID string, status string) (*model.Status, error) {
c.setToken(http.MethodPut)
userStatus := &model.Status{
UserId: userID,
Status: status,
}
updated, resp, err := c.client.UpdateUserStatus(userID, userStatus)
if err != nil {
return nil, c.mapError(resp, err, "UpdateUserStatus")
}
return updated, nil
}
// ==================== Admin Channel Operations ====================
// CreateChannel creates a new channel in a team
func (c *Client) CreateChannel(ctx context.Context, channel *model.Channel) (*model.Channel, error) {
c.setToken(http.MethodPost)
created, resp, err := c.client.CreateChannel(channel)
if err != nil {
return nil, c.mapError(resp, err, "CreateChannel")
}
return created, nil
}
// AddChannelMember adds a user to a channel
func (c *Client) AddChannelMember(ctx context.Context, channelID, userID string) (*model.ChannelMember, error) {
c.setToken(http.MethodPost)
member, resp, err := c.client.AddChannelMember(channelID, userID)
if err != nil {
return nil, c.mapError(resp, err, "AddChannelMember")
}
return member, nil
}
// DeleteChannel deletes/archival a channel (soft delete by default)
func (c *Client) DeleteChannel(ctx context.Context, channelID string) error {
_, err := c.executeWithRetry(
ctx,
"DeleteChannel",
http.MethodDelete,
fmt.Sprintf("/api/v4/channels/%s", channelID),
func() (*model.Response, error) {
r, e := c.client.DeleteChannel(channelID)
return r, e
},
)
return err
}
// GetChannelStats retrieves statistics for a channel including member count
func (c *Client) GetChannelStats(ctx context.Context, channelID string) (*model.ChannelStats, error) {
c.setToken(http.MethodGet)
stats, resp, err := c.client.GetChannelStats(channelID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetChannelStats")
}
return stats, nil
}
// RemoveChannelMember removes a user from a channel
func (c *Client) RemoveChannelMember(ctx context.Context, channelID, userID string) error {
_, err := c.executeWithRetry(
ctx,
"RemoveChannelMember",
http.MethodDelete,
fmt.Sprintf("/api/v4/channels/%s/members/%s", channelID, userID),
func() (*model.Response, error) {
r, e := c.client.RemoveUserFromChannel(channelID, userID)
return r, e
},
)
return err
}
// ==================== Pin Operations ====================
// PinPost pins a post to a channel
func (c *Client) PinPost(ctx context.Context, postID string) error {
c.setToken(http.MethodPost)
_, err := c.client.PinPost(postID)
if err != nil {
return c.mapError(nil, err, "PinPost")
}
return nil
}
// UnpinPost unpins a post from a channel
func (c *Client) UnpinPost(ctx context.Context, postID string) error {
c.setToken(http.MethodPost)
_, err := c.client.UnpinPost(postID)
if err != nil {
return c.mapError(nil, err, "UnpinPost")
}
return nil
}
// GetPinnedPosts retrieves all pinned posts in a channel
func (c *Client) GetPinnedPosts(ctx context.Context, channelID string) (*model.PostList, error) {
c.setToken(http.MethodGet)
posts, resp, err := c.client.GetPinnedPosts(channelID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetPinnedPosts")
}
return posts, nil
}
// ==================== Bulk Status Operations ====================
// GetUsersStatus retrieves status for multiple users by IDs
func (c *Client) GetUsersStatus(ctx context.Context, userIDs []string) ([]*model.Status, error) {
c.setToken(http.MethodPost)
statuses, resp, err := c.client.GetUsersStatusesByIds(userIDs)
if err != nil {
return nil, c.mapError(resp, err, "GetUsersStatus")
}
return statuses, nil
}
// ==================== Webhook Operations ====================
// CreateIncomingWebhook creates an incoming webhook for a channel
func (c *Client) CreateIncomingWebhook(ctx context.Context, channelID string, displayName string) (*model.IncomingWebhook, error) {
c.setToken(http.MethodPost)
// Get current user for webhook creation
me, err := c.GetMe(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get current user: %w", err)
}
hook := &model.IncomingWebhook{
ChannelId: channelID,
DisplayName: displayName,
UserId: me.Id,
}
created, resp, err := c.client.CreateIncomingWebhook(hook)
if err != nil {
return nil, c.mapError(resp, err, "CreateIncomingWebhook")
}
return created, nil
}
// ListIncomingWebhooks lists incoming webhooks for a team
func (c *Client) ListIncomingWebhooks(ctx context.Context, teamID string, page, perPage int) ([]*model.IncomingWebhook, error) {
c.setToken(http.MethodGet)
hooks, resp, err := c.client.GetIncomingWebhooksForTeam(teamID, page, perPage, "")
if err != nil {
return nil, c.mapError(resp, err, "ListIncomingWebhooks")
}
return hooks, nil
}
// DeleteIncomingWebhook deletes an incoming webhook
func (c *Client) DeleteIncomingWebhook(ctx context.Context, hookID string) error {
_, err := c.executeWithRetry(
ctx,
"DeleteIncomingWebhook",
http.MethodDelete,
fmt.Sprintf("/api/v4/hooks/incoming/%s", hookID),
func() (*model.Response, error) {
r, e := c.client.DeleteIncomingWebhook(hookID)
return r, e
},
)
return err
}
// ==================== Slash Commands ====================
// ExecuteSlashCommand runs a slash command in a channel
func (c *Client) ExecuteSlashCommand(ctx context.Context, channelID string, command string) (*model.CommandResponse, error) {
c.setToken(http.MethodPost)
resp, _, err := c.client.ExecuteCommand(channelID, command)
if err != nil {
return nil, c.mapError(nil, err, "ExecuteSlashCommand")
}
return resp, nil
}
// ==================== Team Administration ====================
// InviteUserToTeam adds a user to a team
func (c *Client) InviteUserToTeam(ctx context.Context, teamID, userID string) (*model.TeamMember, error) {
c.setToken(http.MethodPost)
member, resp, err := c.client.AddTeamMember(teamID, userID)
if err != nil {
return nil, c.mapError(resp, err, "InviteUserToTeam")
}
return member, nil
}
// RemoveUserFromTeam removes a user from a team
func (c *Client) RemoveUserFromTeam(ctx context.Context, teamID, userID string) error {
_, err := c.executeWithRetry(
ctx,
"RemoveUserFromTeam",
http.MethodDelete,
fmt.Sprintf("/api/v4/teams/%s/members/%s", teamID, userID),
func() (*model.Response, error) {
r, e := c.client.RemoveTeamMember(teamID, userID)
return r, e
},
)
return err
}
// ListTeamMembers gets members of a team
func (c *Client) ListTeamMembers(ctx context.Context, teamID string, page, perPage int) ([]*model.TeamMember, error) {
c.setToken(http.MethodGet)
members, resp, err := c.client.GetTeamMembers(teamID, page, perPage, "")
if err != nil {
return nil, c.mapError(resp, err, "ListTeamMembers")
}
return members, nil
}
// GetTeamStats gets statistics for a team
func (c *Client) GetTeamStats(ctx context.Context, teamID string) (*model.TeamStats, error) {
c.setToken(http.MethodGet)
stats, resp, err := c.client.GetTeamStats(teamID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetTeamStats")
}
return stats, nil
}
// ==================== Post Operations ====================
// GetPost retrieves a single post by ID
func (c *Client) GetPost(ctx context.Context, postID string) (*model.Post, error) {
c.setToken(http.MethodGet)
post, resp, err := c.client.GetPost(postID, "")
if err != nil {
return nil, c.mapError(resp, err, "GetPost")
}
return post, nil
}
// ==================== Outgoing Webhooks ====================
// CreateOutgoingWebhook creates an outgoing webhook
func (c *Client) CreateOutgoingWebhook(ctx context.Context, teamID string, displayName string, triggerWords []string, callbackURL string) (*model.OutgoingWebhook, error) {
c.setToken(http.MethodPost)
hook := &model.OutgoingWebhook{
TeamId: teamID,
DisplayName: displayName,
TriggerWords: triggerWords,
CallbackURLs: []string{callbackURL},
}
created, resp, err := c.client.CreateOutgoingWebhook(hook)
if err != nil {
return nil, c.mapError(resp, err, "CreateOutgoingWebhook")
}
return created, nil
}
// ListOutgoingWebhooks lists outgoing webhooks for a team
func (c *Client) ListOutgoingWebhooks(ctx context.Context, teamID string, page, perPage int) ([]*model.OutgoingWebhook, error) {
c.setToken(http.MethodGet)
hooks, resp, err := c.client.GetOutgoingWebhooksForTeam(teamID, page, perPage, "")
if err != nil {
return nil, c.mapError(resp, err, "ListOutgoingWebhooks")
}
return hooks, nil
}
// DeleteOutgoingWebhook deletes an outgoing webhook
func (c *Client) DeleteOutgoingWebhook(ctx context.Context, hookID string) error {
_, err := c.executeWithRetry(
ctx,
"DeleteOutgoingWebhook",
http.MethodDelete,
fmt.Sprintf("/api/v4/hooks/outgoing/%s", hookID),
func() (*model.Response, error) {
r, e := c.client.DeleteOutgoingWebhook(hookID)
return r, e
},
)
return err
}
// ==================== System & Config ====================
// GetSystemLogs retrieves system logs (requires admin)
func (c *Client) GetSystemLogs(ctx context.Context, page, perPage int) ([]string, *model.Response, error) {
c.setToken(http.MethodGet)
logs, resp, err := c.client.GetLogs(page, perPage)
if err != nil {
return nil, resp, c.mapError(resp, err, "GetSystemLogs")
}
return logs, resp, nil
}
// GetConfig retrieves server configuration (requires admin)
func (c *Client) GetConfig(ctx context.Context) (*model.Config, error) {
c.setToken(http.MethodGet)
config, resp, err := c.client.GetConfig()
if err != nil {
return nil, c.mapError(resp, err, "GetConfig")
}
return config, nil
}
+39
View File
@@ -0,0 +1,39 @@
package mattermost
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewClient(t *testing.T) {
client := NewClient("https://test.mattermost.com", "bot-token", "pat-token")
assert.NotNil(t, client)
assert.Equal(t, "https://test.mattermost.com", client.host)
assert.Equal(t, "bot-token", client.botToken)
assert.Equal(t, "pat-token", client.pat)
}
func TestGetToken_ReadOperation(t *testing.T) {
client := NewClient("https://test.mattermost.com", "bot-token", "pat-token")
token := client.getToken(false)
assert.Equal(t, "bot-token", token)
}
func TestGetToken_WriteOperation(t *testing.T) {
client := NewClient("https://test.mattermost.com", "bot-token", "pat-token")
token := client.getToken(true)
assert.Equal(t, "pat-token", token)
}
func TestGetToken_FallbackToPAT(t *testing.T) {
client := NewClient("https://test.mattermost.com", "", "pat-token")
token := client.getToken(false)
assert.Equal(t, "pat-token", token)
}
func TestSetGlobalClient(t *testing.T) {
client := NewClient("https://test.mattermost.com", "bot-token", "pat-token")
SetGlobalClient(client)
assert.Equal(t, client, GetGlobalClient())
}
+122
View File
@@ -0,0 +1,122 @@
package params
import (
"fmt"
"strconv"
)
func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string)
if !ok {
return "", fmt.Errorf("%s is required", key)
}
return val, nil
}
func GetOptionalString(args map[string]any, key, defaultVal string) string {
if val, ok := args[key].(string); ok {
return val
}
return defaultVal
}
func GetStringSlice(args map[string]any, key string) []string {
val, ok := args[key]
if !ok {
return nil
}
sliceVal, ok := val.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(sliceVal))
for _, item := range sliceVal {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "perPage", defaultPageSize))
}
func ToInt64(val any) (int64, bool) {
switch v := val.(type) {
case float64:
return int64(v), true
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
func GetIndex(args map[string]any, key string) (int64, error) {
val, exists := args[key]
if !exists {
return 0, fmt.Errorf("%s is required", key)
}
if i, ok := ToInt64(val); ok {
return i, nil
}
if s, ok := val.(string); ok {
return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s)
}
return 0, fmt.Errorf("%s must be a number or numeric string", key)
}
func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
raw, ok := args[key].([]any)
if !ok {
return nil, fmt.Errorf("%s (array of IDs) is required", key)
}
out := make([]int64, 0, len(raw))
for _, v := range raw {
id, ok := ToInt64(v)
if !ok {
return nil, fmt.Errorf("invalid ID in %s array", key)
}
out = append(out, id)
}
return out, nil
}
func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
val, exists := args[key]
if !exists {
return defaultVal
}
if i, ok := ToInt64(val); ok {
return i
}
return defaultVal
}
func GetOptionalBool(args map[string]any, key string, defaultVal bool) bool {
val, exists := args[key]
if !exists {
return defaultVal
}
switch v := val.(type) {
case bool:
return v
case float64:
return v != 0
case string:
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return defaultVal
}
+46
View File
@@ -0,0 +1,46 @@
package to
import (
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
)
func Result(data interface{}) *mcp.CallToolResult {
content, err := json.MarshalIndent(data, "", " ")
if err != nil {
return Error(fmt.Errorf("failed to marshal result: %w", err))
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(content),
},
},
}
}
func Text(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: text,
},
},
}
}
func Error(err error) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: err.Error(),
},
},
IsError: true,
}
}
+45
View File
@@ -0,0 +1,45 @@
package tool
import (
"github.com/karti-ai/mattermost-mcp-server/pkg/flag"
"github.com/mark3labs/mcp-go/server"
)
type Tool struct {
write []server.ServerTool
read []server.ServerTool
}
func New() *Tool {
return &Tool{
write: make([]server.ServerTool, 0, 100),
read: make([]server.ServerTool, 0, 100),
}
}
func (t *Tool) RegisterWrite(s server.ServerTool) {
t.write = append(t.write, s)
}
func (t *Tool) RegisterRead(s server.ServerTool) {
t.read = append(t.read, s)
}
func (t *Tool) Tools() []server.ServerTool {
tools := make([]server.ServerTool, 0, len(t.write)+len(t.read))
if flag.ReadOnly {
tools = append(tools, t.read...)
return tools
}
tools = append(tools, t.write...)
tools = append(tools, t.read...)
return tools
}
func (t *Tool) ReadTools() []server.ServerTool {
return t.read
}
func (t *Tool) WriteTools() []server.ServerTool {
return t.write
}