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:
+59
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,351 @@
|
||||
# Mattermost MCP Server
|
||||
|
||||
[](LICENSE)
|
||||
[](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
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var AdminTool = tool.New()
|
||||
|
||||
const (
|
||||
CreateChannelToolName = "mattermost_create_channel"
|
||||
InviteToChannelToolName = "mattermost_invite_to_channel"
|
||||
DeleteChannelToolName = "mattermost_delete_channel"
|
||||
LeaveChannelToolName = "mattermost_leave_channel"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateChannelTool = mcp.NewTool(
|
||||
CreateChannelToolName,
|
||||
mcp.WithDescription("Create a new channel (public or private) in a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team to create channel in")),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description("Channel name (lowercase, no spaces, 2-64 characters)")),
|
||||
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the channel (2-64 characters)")),
|
||||
mcp.WithString("type", mcp.Required(), mcp.Description("Channel type: 'O' for public, 'P' for private")),
|
||||
mcp.WithString("purpose", mcp.Description("Channel description/purpose (optional)")),
|
||||
)
|
||||
|
||||
InviteToChannelTool = mcp.NewTool(
|
||||
InviteToChannelToolName,
|
||||
mcp.WithDescription("Invite a user to a channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to invite user to")),
|
||||
mcp.WithString("user_id", mcp.Required(), mcp.Description("User to invite")),
|
||||
)
|
||||
|
||||
DeleteChannelTool = mcp.NewTool(
|
||||
DeleteChannelToolName,
|
||||
mcp.WithDescription("Delete/archive a channel (soft delete by default)"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to delete")),
|
||||
mcp.WithBoolean("permanent", mcp.Description("Permanently delete instead of archive (default: false)")),
|
||||
)
|
||||
|
||||
LeaveChannelTool = mcp.NewTool(
|
||||
LeaveChannelToolName,
|
||||
mcp.WithDescription("Remove self from a channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel to leave")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerAdminTools()
|
||||
}
|
||||
|
||||
func registerAdminTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: CreateChannelTool, Handler: CreateChannelFn},
|
||||
{Tool: InviteToChannelTool, Handler: InviteToChannelFn},
|
||||
{Tool: DeleteChannelTool, Handler: DeleteChannelFn},
|
||||
{Tool: LeaveChannelTool, Handler: LeaveChannelFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
AdminTool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called CreateChannelFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
name, err := params.GetString(args, "name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[name] %v", err)), nil
|
||||
}
|
||||
|
||||
displayName, err := params.GetString(args, "display_name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
|
||||
}
|
||||
|
||||
channelType, err := params.GetString(args, "type")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[type] %v", err)), nil
|
||||
}
|
||||
|
||||
if channelType != "O" && channelType != "P" {
|
||||
return to.Error(fmt.Errorf("[type] must be 'O' (public) or 'P' (private)")), nil
|
||||
}
|
||||
|
||||
purpose := params.GetOptionalString(args, "purpose", "")
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
channel := &model.Channel{
|
||||
TeamId: teamID,
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Type: model.ChannelType(channelType),
|
||||
Purpose: purpose,
|
||||
}
|
||||
|
||||
created, err := client.CreateChannel(ctx, channel)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to create channel: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimChannel(created)), nil
|
||||
}
|
||||
|
||||
func InviteToChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called InviteToChannelFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
userID, err := params.GetString(args, "user_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
member, err := client.AddChannelMember(ctx, channelID, userID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to invite user to channel: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"channel_id": channelID,
|
||||
"user_id": userID,
|
||||
"member": member,
|
||||
"message": "User invited to channel successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func DeleteChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called DeleteChannelFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
_ = params.GetOptionalBool(args, "permanent", false)
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.DeleteChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to delete channel: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"channel_id": channelID,
|
||||
"message": "Channel deleted successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func LeaveChannelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called LeaveChannelFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
user, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
err = client.RemoveChannelMember(ctx, channelID, user.Id)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to leave channel: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"channel_id": channelID,
|
||||
"user_id": user.Id,
|
||||
"message": "Successfully left the channel",
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
ListChannelsToolName = "mattermost_list_channels"
|
||||
GetChannelByNameToolName = "mattermost_get_channel_by_name"
|
||||
GetChannelInfoToolName = "mattermost_get_channel_info"
|
||||
ListChannelMembersToolName = "mattermost_list_channel_members"
|
||||
)
|
||||
|
||||
var (
|
||||
ListChannelsTool = mcp.NewTool(
|
||||
ListChannelsToolName,
|
||||
mcp.WithDescription("List accessible channels for the authenticated user in a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list channels from")),
|
||||
mcp.WithNumber("limit", mcp.Description("Maximum number of results to return (default 30)")),
|
||||
)
|
||||
|
||||
GetChannelByNameTool = mcp.NewTool(
|
||||
GetChannelByNameToolName,
|
||||
mcp.WithDescription("Get a channel by name in a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to search in")),
|
||||
mcp.WithString("channel_name", mcp.Required(), mcp.Description("Channel name to find (e.g., \"general\", \"social\", \"trading-desk\")")),
|
||||
)
|
||||
|
||||
GetChannelInfoTool = mcp.NewTool(
|
||||
GetChannelInfoToolName,
|
||||
mcp.WithDescription("Get detailed channel information including member count, purpose, etc."),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to get info for")),
|
||||
)
|
||||
|
||||
ListChannelMembersTool = mcp.NewTool(
|
||||
ListChannelMembersToolName,
|
||||
mcp.WithDescription("List all members of a channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to list members for")),
|
||||
mcp.WithNumber("page", mcp.Description("Page number for pagination (default 0)")),
|
||||
mcp.WithNumber("per_page", mcp.Description("Members per page (default 60, max 200)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: ListChannelsTool, Handler: ListChannelsFn},
|
||||
{Tool: GetChannelByNameTool, Handler: GetChannelByNameFn},
|
||||
{Tool: GetChannelInfoTool, Handler: GetChannelInfoFn},
|
||||
{Tool: ListChannelMembersTool, Handler: ListChannelMembersFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func ListChannelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called ListChannelsFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
limit := params.GetOptionalInt(args, "limit", 30)
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
user, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
channels, err := client.GetChannelsForTeamForUser(ctx, teamID, user.Id, false)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channels] failed to list channels: %v", err)), nil
|
||||
}
|
||||
|
||||
if len(channels) > int(limit) {
|
||||
channels = channels[:limit]
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
results = append(results, SlimChannel(ch))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"channels": results,
|
||||
"count": len(results),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetChannelByNameFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called GetChannelByNameFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
channelName, err := params.GetString(args, "channel_name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_name] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
channel, err := client.GetChannelByName(ctx, teamID, channelName)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to get channel: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimChannel(channel)), nil
|
||||
}
|
||||
|
||||
func GetChannelInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called GetChannelInfoFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
channel, err := client.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to get channel: %v", err)), nil
|
||||
}
|
||||
|
||||
stats, err := client.GetChannelStats(ctx, channelID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to get channel stats: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(DetailedChannel(channel, stats.MemberCount)), nil
|
||||
}
|
||||
|
||||
func ListChannelMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called ListChannelMembersFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
page := params.GetOptionalInt(args, "page", 0)
|
||||
perPage := params.GetOptionalInt(args, "per_page", 60)
|
||||
if perPage > 200 {
|
||||
perPage = 200
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
members, err := client.GetChannelMembers(ctx, channelID, int(page), int(perPage))
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to list members: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(members))
|
||||
for _, m := range members {
|
||||
results = append(results, SlimChannelMember(m))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"members": results,
|
||||
"count": len(results),
|
||||
"channel_id": channelID,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimChannel(t *testing.T) {
|
||||
c := &model.Channel{
|
||||
Id: "channel123",
|
||||
Name: "general",
|
||||
DisplayName: "General",
|
||||
Type: "O",
|
||||
TeamId: "team456",
|
||||
}
|
||||
|
||||
slim := SlimChannel(c)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Equal(t, "channel123", slim["id"])
|
||||
assert.Equal(t, "general", slim["name"])
|
||||
assert.Equal(t, "General", slim["display_name"])
|
||||
assert.Equal(t, "O", slim["type"])
|
||||
assert.Equal(t, "team456", slim["team_id"])
|
||||
}
|
||||
|
||||
func TestSlimChannel_Nil(t *testing.T) {
|
||||
slim := SlimChannel(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestSlimChannel_DifferentTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chType model.ChannelType
|
||||
expected string
|
||||
}{
|
||||
{"Open channel", model.ChannelTypeOpen, "O"},
|
||||
{"Private channel", model.ChannelTypePrivate, "P"},
|
||||
{"Direct message", model.ChannelTypeDirect, "D"},
|
||||
{"Group message", model.ChannelTypeGroup, "G"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &model.Channel{
|
||||
Id: "channel123",
|
||||
Name: "test-channel",
|
||||
DisplayName: "Test Channel",
|
||||
Type: tt.chType,
|
||||
TeamId: "team456",
|
||||
}
|
||||
slim := SlimChannel(c)
|
||||
assert.Equal(t, tt.expected, slim["type"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
tools := Tool.Tools()
|
||||
assert.Len(t, tools, 5)
|
||||
|
||||
toolNames := make(map[string]bool)
|
||||
for _, t := range tools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, toolNames[ListChannelsToolName], "ListChannels tool should be registered")
|
||||
assert.True(t, toolNames[GetChannelByNameToolName], "GetChannelByName tool should be registered")
|
||||
assert.True(t, toolNames[GetChannelInfoToolName], "GetChannelInfo tool should be registered")
|
||||
assert.True(t, toolNames[GetUnreadCountToolName], "GetUnreadCount tool should be registered")
|
||||
assert.True(t, toolNames[MarkChannelReadToolName], "MarkChannelRead tool should be registered")
|
||||
}
|
||||
|
||||
func TestListChannelsFn_MissingTeamId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: ListChannelsToolName,
|
||||
Arguments: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ListChannelsFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestListChannelsFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: ListChannelsToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"team_id": "team123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ListChannelsFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package channel
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimChannel(c *model.Channel) map[string]interface{} {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": c.Id,
|
||||
"name": c.Name,
|
||||
"display_name": c.DisplayName,
|
||||
"type": string(c.Type),
|
||||
"team_id": c.TeamId,
|
||||
}
|
||||
}
|
||||
|
||||
func DetailedChannel(c *model.Channel, memberCount int64) map[string]interface{} {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": c.Id,
|
||||
"name": c.Name,
|
||||
"display_name": c.DisplayName,
|
||||
"type": string(c.Type),
|
||||
"team_id": c.TeamId,
|
||||
"purpose": c.Purpose,
|
||||
"header": c.Header,
|
||||
"creator_id": c.CreatorId,
|
||||
"create_at": c.CreateAt,
|
||||
"member_count": memberCount,
|
||||
}
|
||||
}
|
||||
|
||||
func SlimChannelMember(m model.ChannelMember) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"user_id": m.UserId,
|
||||
"channel_id": m.ChannelId,
|
||||
"roles": m.Roles,
|
||||
"last_viewed": m.LastViewedAt,
|
||||
"msg_count": m.MsgCount,
|
||||
"mention_count": m.MentionCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
GetUnreadCountToolName = "mattermost_get_unread_count"
|
||||
MarkChannelReadToolName = "mattermost_mark_channel_read"
|
||||
)
|
||||
|
||||
var (
|
||||
GetUnreadCountTool = mcp.NewTool(
|
||||
GetUnreadCountToolName,
|
||||
mcp.WithDescription("Get unread message counts for all channels in a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to get unread counts for")),
|
||||
)
|
||||
|
||||
MarkChannelReadTool = mcp.NewTool(
|
||||
MarkChannelReadToolName,
|
||||
mcp.WithDescription("Mark a channel as read (clear unread notifications)"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to mark as read")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerUnreadTools()
|
||||
}
|
||||
|
||||
func registerUnreadTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: GetUnreadCountTool, Handler: GetUnreadCountFn},
|
||||
{Tool: MarkChannelReadTool, Handler: MarkChannelReadFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func GetUnreadCountFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called GetUnreadCountFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
user, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
members, err := client.GetChannelMembersForUser(ctx, user.Id, teamID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channels] failed to get channel members: %v", err)), nil
|
||||
}
|
||||
|
||||
memberResults := make([]map[string]interface{}, 0, len(members))
|
||||
for _, member := range members {
|
||||
memberResults = append(memberResults, map[string]interface{}{
|
||||
"channel_id": member.ChannelId,
|
||||
"user_id": member.UserId,
|
||||
"unread_messages": member.MsgCount,
|
||||
"unread_mentions": member.MentionCount,
|
||||
"last_viewed_at": member.LastViewedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"channels": memberResults,
|
||||
"count": len(memberResults),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func MarkChannelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Channel] Called MarkChannelReadFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
user, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
_, err = client.MarkChannelAsRead(ctx, channelID, user.Id)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to mark channel as read: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"channel_id": channelID,
|
||||
"message": "Channel marked as read successfully",
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
ExecuteSlashCommandToolName = "mattermost_execute_slash_command"
|
||||
)
|
||||
|
||||
var (
|
||||
ExecuteSlashCommandTool = mcp.NewTool(
|
||||
ExecuteSlashCommandToolName,
|
||||
mcp.WithDescription("Execute a slash command in a channel (e.g., /remind, /poll)"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to execute command in")),
|
||||
mcp.WithString("command", mcp.Required(), mcp.Description("Slash command to execute (e.g., /remind @channel meeting in 10 minutes)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: ExecuteSlashCommandTool, Handler: ExecuteSlashCommandFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
|
||||
func ExecuteSlashCommandFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Command] Called ExecuteSlashCommandFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
command, err := params.GetString(args, "command")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[command] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
resp, err := client.ExecuteSlashCommand(ctx, channelID, command)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[command] failed to execute: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"response": resp.ResponseType,
|
||||
"text": resp.Text,
|
||||
"goto_location": resp.GotoLocation,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package dm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
CreateDMToolName = "mattermost_create_dm"
|
||||
CreateGroupDMToolName = "mattermost_create_group_dm"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateDMTool = mcp.NewTool(
|
||||
CreateDMToolName,
|
||||
mcp.WithDescription("Create direct message channel with user"),
|
||||
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to DM with")),
|
||||
)
|
||||
|
||||
CreateGroupDMTool = mcp.NewTool(
|
||||
CreateGroupDMToolName,
|
||||
mcp.WithDescription("Create group DM channel with multiple users"),
|
||||
mcp.WithString("user_ids", mcp.Required(), mcp.Description("Comma-separated list of user IDs to include in group DM")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: CreateDMTool, Handler: CreateDMFn},
|
||||
{Tool: CreateGroupDMTool, Handler: CreateGroupDMFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateDMFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[DM] Called CreateDMFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
userId, err := params.GetString(args, "user_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
// Get current user to get our own user ID
|
||||
me, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[me] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
// Create direct message channel between current user and target user
|
||||
channel, err := client.CreateDirectChannel(ctx, me.Id, userId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to create DM: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimChannel(channel)), nil
|
||||
}
|
||||
|
||||
func CreateGroupDMFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[DM] Called CreateGroupDMFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
userIDsStr, err := params.GetString(args, "user_ids")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_ids] %v", err)), nil
|
||||
}
|
||||
|
||||
// Parse comma-separated user IDs
|
||||
userIDs := strings.Split(userIDsStr, ",")
|
||||
if len(userIDs) < 2 {
|
||||
return to.Error(fmt.Errorf("[user_ids] at least 2 user IDs required for group DM (got %d)", len(userIDs))), nil
|
||||
}
|
||||
|
||||
// Trim whitespace from each ID
|
||||
for i, id := range userIDs {
|
||||
userIDs[i] = strings.TrimSpace(id)
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
// Create group DM channel
|
||||
channel, err := client.CreateGroupChannel(ctx, userIDs)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to create group DM: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimChannel(channel)), nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimChannel(t *testing.T) {
|
||||
c := &model.Channel{
|
||||
Id: "channel123",
|
||||
Name: "user1__user2",
|
||||
DisplayName: "user1, user2",
|
||||
Type: model.ChannelTypeDirect,
|
||||
TeamId: "",
|
||||
}
|
||||
|
||||
slim := SlimChannel(c)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Equal(t, "channel123", slim["id"])
|
||||
assert.Equal(t, "user1__user2", slim["name"])
|
||||
assert.Equal(t, "user1, user2", slim["display_name"])
|
||||
assert.Equal(t, model.ChannelTypeDirect, slim["type"])
|
||||
assert.Equal(t, "", slim["team_id"])
|
||||
}
|
||||
|
||||
func TestSlimChannel_Nil(t *testing.T) {
|
||||
slim := SlimChannel(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
tools := Tool.Tools()
|
||||
assert.Len(t, tools, 1)
|
||||
|
||||
toolNames := make(map[string]bool)
|
||||
for _, t := range tools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, toolNames[CreateDMToolName], "CreateDM tool should be registered")
|
||||
}
|
||||
|
||||
func TestCreateDMFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: CreateDMToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"user_id": "user123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := CreateDMFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestCreateDMFn_MissingUserId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: CreateDMToolName,
|
||||
Arguments: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := CreateDMFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dm
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimChannel(c *model.Channel) map[string]interface{} {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": c.Id,
|
||||
"name": c.Name,
|
||||
"display_name": c.DisplayName,
|
||||
"type": c.Type,
|
||||
"team_id": c.TeamId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/file"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
UploadFileToolName = "mattermost_upload_file"
|
||||
DownloadFileToolName = "mattermost_download_file"
|
||||
)
|
||||
|
||||
var (
|
||||
UploadFileTool = mcp.NewTool(
|
||||
UploadFileToolName,
|
||||
mcp.WithDescription("Upload file to channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to upload file to")),
|
||||
mcp.WithString("file_path", mcp.Required(), mcp.Description("Local file path to upload")),
|
||||
mcp.WithString("message", mcp.Description("Message to post with file (optional)")),
|
||||
)
|
||||
|
||||
DownloadFileTool = mcp.NewTool(
|
||||
DownloadFileToolName,
|
||||
mcp.WithDescription("Download file from Mattermost"),
|
||||
mcp.WithString("file_id", mcp.Required(), mcp.Description("File ID to download")),
|
||||
mcp.WithString("download_path", mcp.Required(), mcp.Description("Local path where file should be saved")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: UploadFileTool, Handler: UploadFileFn},
|
||||
{Tool: DownloadFileTool, Handler: DownloadFileFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
|
||||
func UploadFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[File] Called UploadFileFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelId, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
filePath, err := params.GetString(args, "file_path")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
|
||||
}
|
||||
|
||||
message := params.GetOptionalString(args, "message", "")
|
||||
|
||||
if !file.IsValidPath(filePath) {
|
||||
return to.Error(fmt.Errorf("[file_path] path traversal detected: %s", filePath)), nil
|
||||
}
|
||||
|
||||
fileInfo, err := file.GetFileInfo(filePath)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[file_path] failed to access file: %v", err)), nil
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
return to.Error(fmt.Errorf("[file_path] path is a directory, not a file: %s", filePath)), nil
|
||||
}
|
||||
|
||||
if err := file.ValidateFileSize(fileInfo.Size()); err != nil {
|
||||
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
|
||||
}
|
||||
|
||||
if err := file.ValidateFilename(fileInfo.Name()); err != nil {
|
||||
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[file_path] failed to read file: %v", err)), nil
|
||||
}
|
||||
|
||||
if _, err := file.ValidateMimeType(data); err != nil {
|
||||
return to.Error(fmt.Errorf("[file_path] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
filename := filepath.Base(filePath)
|
||||
uploadResp, err := client.UploadFile(ctx, data, channelId, filename)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[upload] failed to upload file: %v", err)), nil
|
||||
}
|
||||
|
||||
if message != "" && len(uploadResp.FileInfos) > 0 {
|
||||
post := &model.Post{
|
||||
ChannelId: channelId,
|
||||
Message: message,
|
||||
FileIds: []string{uploadResp.FileInfos[0].Id},
|
||||
}
|
||||
_, err := client.CreatePost(ctx, post)
|
||||
if err != nil {
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"file_id": uploadResp.FileInfos[0].Id,
|
||||
"file_infos": SlimFileInfos(uploadResp.FileInfos),
|
||||
"warning": fmt.Sprintf("File uploaded but message failed to post: %v", err),
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"file_id": uploadResp.FileInfos[0].Id,
|
||||
"file_infos": SlimFileInfos(uploadResp.FileInfos),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func DownloadFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[File] Called DownloadFileFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
fileId, err := params.GetString(args, "file_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[file_id] %v", err)), nil
|
||||
}
|
||||
|
||||
downloadPath, err := params.GetString(args, "download_path")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[download_path] %v", err)), nil
|
||||
}
|
||||
|
||||
if !file.IsValidPath(downloadPath) {
|
||||
return to.Error(fmt.Errorf("[download_path] path traversal detected: %s", downloadPath)), nil
|
||||
}
|
||||
|
||||
if file.Exists(downloadPath) {
|
||||
return to.Error(fmt.Errorf("[download_path] file already exists: %s", downloadPath)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
data, _, err := client.GetFile(ctx, fileId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[download] failed to download file: %v", err)), nil
|
||||
}
|
||||
|
||||
if int64(len(data)) > file.MaxFileSize {
|
||||
return to.Error(fmt.Errorf("[download] downloaded file size %d exceeds maximum allowed %d", len(data), file.MaxFileSize)), nil
|
||||
}
|
||||
|
||||
if err := file.CheckDiskSpace(downloadPath, int64(len(data))); err != nil {
|
||||
return to.Error(fmt.Errorf("[download_path] %v", err)), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(downloadPath, data, 0644); err != nil {
|
||||
return to.Error(fmt.Errorf("[download_path] failed to save file: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"file_id": fileId,
|
||||
"download_path": downloadPath,
|
||||
"size": len(data),
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/file"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimFileInfo(t *testing.T) {
|
||||
f := &model.FileInfo{
|
||||
Id: "file123",
|
||||
Name: "test.pdf",
|
||||
Extension: "pdf",
|
||||
Size: 1024,
|
||||
MimeType: "application/pdf",
|
||||
ChannelId: "channel456",
|
||||
CreateAt: 1234567890000,
|
||||
}
|
||||
|
||||
slim := SlimFileInfo(f)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Equal(t, "file123", slim["id"])
|
||||
assert.Equal(t, "test.pdf", slim["name"])
|
||||
assert.Equal(t, "pdf", slim["extension"])
|
||||
assert.Equal(t, int64(1024), slim["size"])
|
||||
assert.Equal(t, "application/pdf", slim["mime_type"])
|
||||
assert.Equal(t, "channel456", slim["channel_id"])
|
||||
assert.Equal(t, int64(1234567890000), slim["create_at"])
|
||||
}
|
||||
|
||||
func TestSlimFileInfo_Nil(t *testing.T) {
|
||||
slim := SlimFileInfo(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestSlimFileInfos(t *testing.T) {
|
||||
infos := []*model.FileInfo{
|
||||
{
|
||||
Id: "file1",
|
||||
Name: "test1.pdf",
|
||||
Extension: "pdf",
|
||||
Size: 1024,
|
||||
MimeType: "application/pdf",
|
||||
ChannelId: "channel1",
|
||||
CreateAt: 1234567890000,
|
||||
},
|
||||
{
|
||||
Id: "file2",
|
||||
Name: "test2.png",
|
||||
Extension: "png",
|
||||
Size: 2048,
|
||||
MimeType: "image/png",
|
||||
ChannelId: "channel2",
|
||||
CreateAt: 1234567890001,
|
||||
},
|
||||
}
|
||||
|
||||
slim := SlimFileInfos(infos)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Len(t, slim, 2)
|
||||
assert.Equal(t, "file1", slim[0]["id"])
|
||||
assert.Equal(t, "file2", slim[1]["id"])
|
||||
}
|
||||
|
||||
func TestSlimFileInfos_Nil(t *testing.T) {
|
||||
slim := SlimFileInfos(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestSlimFileInfos_WithNilItem(t *testing.T) {
|
||||
infos := []*model.FileInfo{
|
||||
{
|
||||
Id: "file1",
|
||||
Name: "test1.pdf",
|
||||
Extension: "pdf",
|
||||
Size: 1024,
|
||||
MimeType: "application/pdf",
|
||||
ChannelId: "channel1",
|
||||
CreateAt: 1234567890000,
|
||||
},
|
||||
nil,
|
||||
{
|
||||
Id: "file2",
|
||||
Name: "test2.png",
|
||||
Extension: "png",
|
||||
Size: 2048,
|
||||
MimeType: "image/png",
|
||||
ChannelId: "channel2",
|
||||
CreateAt: 1234567890001,
|
||||
},
|
||||
}
|
||||
|
||||
slim := SlimFileInfos(infos)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Len(t, slim, 2)
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
tools := Tool.Tools()
|
||||
assert.Len(t, tools, 2)
|
||||
|
||||
toolNames := make(map[string]bool)
|
||||
for _, t := range tools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, toolNames[UploadFileToolName], "UploadFile tool should be registered")
|
||||
assert.True(t, toolNames[DownloadFileToolName], "DownloadFile tool should be registered")
|
||||
}
|
||||
|
||||
func TestUploadFileFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"file_path": "/tmp/test.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestUploadFileFn_MissingChannelId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"file_path": "/tmp/test.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestUploadFileFn_MissingFilePath(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestUploadFileFn_PathTraversal(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"file_path": "../../../etc/passwd",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "path traversal")
|
||||
}
|
||||
|
||||
func TestUploadFileFn_AbsolutePath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"file_path": testFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "client not initialized")
|
||||
}
|
||||
|
||||
func TestUploadFileFn_DangerousExtension(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dangerousFile := filepath.Join(tmpDir, "malicious.sh")
|
||||
err := os.WriteFile(dangerousFile, []byte("#!/bin/bash\necho 'pwned'"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"file_path": dangerousFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "not allowed")
|
||||
}
|
||||
|
||||
func TestUploadFileFn_NonExistentFile(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"file_path": "/tmp/nonexistent_file_12345.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "failed to access")
|
||||
}
|
||||
|
||||
func TestUploadFileFn_DirectoryInsteadOfFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: UploadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"file_path": tmpDir,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := UploadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "directory")
|
||||
}
|
||||
|
||||
func TestDownloadFileFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DownloadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"file_id": "file123",
|
||||
"download_path": "/tmp/downloaded.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DownloadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestDownloadFileFn_MissingFileId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DownloadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"download_path": "/tmp/downloaded.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DownloadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestDownloadFileFn_MissingDownloadPath(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DownloadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"file_id": "file123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DownloadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestDownloadFileFn_PathTraversal(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DownloadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"file_id": "file123",
|
||||
"download_path": "../../../etc/passwd",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DownloadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "path traversal")
|
||||
}
|
||||
|
||||
func TestDownloadFileFn_FileAlreadyExists(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
existingFile := filepath.Join(tmpDir, "exists.txt")
|
||||
err := os.WriteFile(existingFile, []byte("existing content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DownloadFileToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"file_id": "file123",
|
||||
"download_path": existingFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DownloadFileFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "already exists")
|
||||
}
|
||||
|
||||
func TestFileSecurityValidation(t *testing.T) {
|
||||
t.Run("IsValidPath allows safe relative paths", func(t *testing.T) {
|
||||
assert.True(t, file.IsValidPath("document.pdf"))
|
||||
assert.True(t, file.IsValidPath("subdir/file.png"))
|
||||
assert.True(t, file.IsValidPath("./file.txt"))
|
||||
})
|
||||
|
||||
t.Run("IsValidPath blocks path traversal", func(t *testing.T) {
|
||||
assert.False(t, file.IsValidPath("../file.txt"))
|
||||
assert.False(t, file.IsValidPath("../../etc/passwd"))
|
||||
assert.False(t, file.IsValidPath("subdir/../../../etc/passwd"))
|
||||
})
|
||||
|
||||
t.Run("IsValidPath allows absolute paths without traversal", func(t *testing.T) {
|
||||
assert.True(t, file.IsValidPath("/etc/passwd"))
|
||||
assert.True(t, file.IsValidPath("/tmp/file.txt"))
|
||||
})
|
||||
|
||||
t.Run("IsDangerousExtension blocks dangerous types", func(t *testing.T) {
|
||||
assert.True(t, file.IsDangerousExtension("file.exe"))
|
||||
assert.True(t, file.IsDangerousExtension("script.sh"))
|
||||
assert.True(t, file.IsDangerousExtension("run.bat"))
|
||||
assert.True(t, file.IsDangerousExtension("malicious.js"))
|
||||
})
|
||||
|
||||
t.Run("IsDangerousExtension allows safe types", func(t *testing.T) {
|
||||
assert.False(t, file.IsDangerousExtension("document.pdf"))
|
||||
assert.False(t, file.IsDangerousExtension("image.png"))
|
||||
assert.False(t, file.IsDangerousExtension("notes.txt"))
|
||||
})
|
||||
|
||||
t.Run("ValidateFileSize blocks oversized files", func(t *testing.T) {
|
||||
err := file.ValidateFileSize(100 * 1024 * 1024) // 100MB
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeds maximum")
|
||||
})
|
||||
|
||||
t.Run("ValidateFileSize allows files under limit", func(t *testing.T) {
|
||||
err := file.ValidateFileSize(10 * 1024 * 1024) // 10MB
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("IsAllowedMimeType allows whitelist types", func(t *testing.T) {
|
||||
assert.True(t, file.IsAllowedMimeType("image/jpeg"))
|
||||
assert.True(t, file.IsAllowedMimeType("image/png"))
|
||||
assert.True(t, file.IsAllowedMimeType("application/pdf"))
|
||||
assert.True(t, file.IsAllowedMimeType("text/plain"))
|
||||
})
|
||||
|
||||
t.Run("IsAllowedMimeType blocks non-whitelist types", func(t *testing.T) {
|
||||
assert.False(t, file.IsAllowedMimeType("application/x-executable"))
|
||||
assert.False(t, file.IsAllowedMimeType("application/x-sh"))
|
||||
assert.False(t, file.IsAllowedMimeType("text/html"))
|
||||
})
|
||||
|
||||
t.Run("DetectMimeType detects file types correctly", func(t *testing.T) {
|
||||
pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
assert.Equal(t, "image/png", file.DetectMimeType(pngData))
|
||||
|
||||
textData := []byte("Hello, World!")
|
||||
assert.Equal(t, "text/plain; charset=utf-8", file.DetectMimeType(textData))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
t.Run("SanitizePath returns clean path for valid input", func(t *testing.T) {
|
||||
path, err := file.SanitizePath("subdir//file.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "subdir/file.txt", path)
|
||||
})
|
||||
|
||||
t.Run("SanitizePath rejects traversal attempts", func(t *testing.T) {
|
||||
_, err := file.SanitizePath("../file.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path traversal")
|
||||
})
|
||||
|
||||
t.Run("SanitizePath allows absolute paths without traversal", func(t *testing.T) {
|
||||
path, err := file.SanitizePath("/etc/passwd")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/etc/passwd", path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateFilename(t *testing.T) {
|
||||
t.Run("ValidateFilename rejects empty filename", func(t *testing.T) {
|
||||
err := file.ValidateFilename("")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("ValidateFilename rejects dangerous extensions", func(t *testing.T) {
|
||||
err := file.ValidateFilename("malicious.exe")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not allowed")
|
||||
})
|
||||
|
||||
t.Run("ValidateFilename accepts safe filenames", func(t *testing.T) {
|
||||
err := file.ValidateFilename("document.pdf")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("ValidateFilename rejects null bytes", func(t *testing.T) {
|
||||
err := file.ValidateFilename("file\x00.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid characters")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckDiskSpace(t *testing.T) {
|
||||
t.Run("CheckDiskSpace succeeds for writable directory", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testPath := filepath.Join(tmpDir, "subdir", "file.txt")
|
||||
err := file.CheckDiskSpace(testPath, 1024)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMimeType(t *testing.T) {
|
||||
t.Run("ValidateMimeType accepts PNG images", func(t *testing.T) {
|
||||
pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52}
|
||||
mimeType, err := file.ValidateMimeType(pngData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "image/png", mimeType)
|
||||
})
|
||||
|
||||
t.Run("ValidateMimeType accepts plain text", func(t *testing.T) {
|
||||
textData := []byte("Hello, World! This is plain text.")
|
||||
mimeType, err := file.ValidateMimeType(textData)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, mimeType, "text/plain")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package file
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimFileInfo(f *model.FileInfo) map[string]interface{} {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": f.Id,
|
||||
"name": f.Name,
|
||||
"extension": f.Extension,
|
||||
"size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
"channel_id": f.ChannelId,
|
||||
"create_at": f.CreateAt,
|
||||
}
|
||||
}
|
||||
|
||||
func SlimFileInfos(infos []*model.FileInfo) []map[string]interface{} {
|
||||
if infos == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]map[string]interface{}, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
if slim := SlimFileInfo(info); slim != nil {
|
||||
result = append(result, slim)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
GetChannelMessagesToolName = "mattermost_get_channel_messages"
|
||||
)
|
||||
|
||||
var (
|
||||
GetChannelMessagesTool = mcp.NewTool(
|
||||
GetChannelMessagesToolName,
|
||||
mcp.WithDescription("Read message history from a channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to read from")),
|
||||
mcp.WithNumber("limit", mcp.Description("Number of messages to return (default 60, max 200)")),
|
||||
mcp.WithString("before", mcp.Description("Get messages before this post ID (for pagination)")),
|
||||
mcp.WithString("after", mcp.Description("Get messages after this post ID (for pagination)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerGetChannelMessagesTool()
|
||||
}
|
||||
|
||||
func registerGetChannelMessagesTool() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: GetChannelMessagesTool, Handler: GetChannelMessagesFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func GetChannelMessagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called GetChannelMessagesFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
limit := params.GetOptionalInt(args, "limit", 60)
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 60
|
||||
}
|
||||
|
||||
before := params.GetOptionalString(args, "before", "")
|
||||
after := params.GetOptionalString(args, "after", "")
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
posts, err := client.GetChannelPosts(ctx, channelID, int(limit), before, after)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[posts] failed to get channel messages: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(posts.Posts))
|
||||
for _, post := range posts.Posts {
|
||||
results = append(results, SlimPost(post))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"posts": results,
|
||||
"count": len(results),
|
||||
"channel_id": channelID,
|
||||
"has_more": len(results) == int(limit),
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
SendMessageToolName = "mattermost_send_message"
|
||||
EditMessageToolName = "mattermost_edit_message"
|
||||
DeleteMessageToolName = "mattermost_delete_message"
|
||||
BulkDeleteMessagesToolName = "mattermost_bulk_delete_messages"
|
||||
PinPostToolName = "mattermost_pin_post"
|
||||
UnpinPostToolName = "mattermost_unpin_post"
|
||||
GetPinnedPostsToolName = "mattermost_get_pinned_posts"
|
||||
GetPostToolName = "mattermost_get_post"
|
||||
)
|
||||
|
||||
var (
|
||||
SendMessageTool = mcp.NewTool(
|
||||
SendMessageToolName,
|
||||
mcp.WithDescription("Send a message to a Mattermost channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to send the message to")),
|
||||
mcp.WithString("message", mcp.Required(), mcp.Description("Message content to send")),
|
||||
mcp.WithString("thread_id", mcp.Description("Thread ID for replies (optional). If provided, message will be posted as a reply in the thread")),
|
||||
)
|
||||
|
||||
EditMessageTool = mcp.NewTool(
|
||||
EditMessageToolName,
|
||||
mcp.WithDescription("Edit an existing message in Mattermost"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID of the message to edit")),
|
||||
mcp.WithString("message", mcp.Required(), mcp.Description("New message content")),
|
||||
)
|
||||
|
||||
DeleteMessageTool = mcp.NewTool(
|
||||
DeleteMessageToolName,
|
||||
mcp.WithDescription("Delete a message from Mattermost"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID of the message to delete")),
|
||||
)
|
||||
|
||||
BulkDeleteMessagesTool = mcp.NewTool(
|
||||
BulkDeleteMessagesToolName,
|
||||
mcp.WithDescription("Delete multiple messages at once (up to 100)"),
|
||||
mcp.WithString("post_ids", mcp.Required(), mcp.Description("Comma-separated list of post IDs to delete (max 100)")),
|
||||
)
|
||||
|
||||
PinPostTool = mcp.NewTool(
|
||||
PinPostToolName,
|
||||
mcp.WithDescription("Pin a post to a channel"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to pin")),
|
||||
)
|
||||
|
||||
UnpinPostTool = mcp.NewTool(
|
||||
UnpinPostToolName,
|
||||
mcp.WithDescription("Unpin a post from a channel"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to unpin")),
|
||||
)
|
||||
|
||||
GetPinnedPostsTool = mcp.NewTool(
|
||||
GetPinnedPostsToolName,
|
||||
mcp.WithDescription("Get all pinned posts in a channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to get pinned posts from")),
|
||||
)
|
||||
|
||||
GetPostTool = mcp.NewTool(
|
||||
GetPostToolName,
|
||||
mcp.WithDescription("Get a single post by its ID"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to retrieve")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: SendMessageTool, Handler: SendMessageFn},
|
||||
{Tool: EditMessageTool, Handler: EditMessageFn},
|
||||
{Tool: DeleteMessageTool, Handler: DeleteMessageFn},
|
||||
{Tool: BulkDeleteMessagesTool, Handler: BulkDeleteMessagesFn},
|
||||
{Tool: PinPostTool, Handler: PinPostFn},
|
||||
{Tool: UnpinPostTool, Handler: UnpinPostFn},
|
||||
{Tool: GetPinnedPostsTool, Handler: GetPinnedPostsFn},
|
||||
{Tool: GetPostTool, Handler: GetPostFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.Tool.Name == GetPinnedPostsToolName || t.Tool.Name == GetPostToolName {
|
||||
Tool.RegisterRead(t)
|
||||
} else {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SendMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called SendMessageFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelId, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
message, err := params.GetString(args, "message")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[message] %v", err)), nil
|
||||
}
|
||||
|
||||
threadId := params.GetOptionalString(args, "thread_id", "")
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: channelId,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if threadId != "" {
|
||||
post.RootId = threadId
|
||||
}
|
||||
|
||||
result, err := client.CreatePost(ctx, post)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post] failed to send message: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimPost(result)), nil
|
||||
}
|
||||
|
||||
func EditMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called EditMessageFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
message, err := params.GetString(args, "message")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[message] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
Message: message,
|
||||
}
|
||||
|
||||
result, err := client.UpdatePost(ctx, postId, post)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post] failed to edit message: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimPost(result)), nil
|
||||
}
|
||||
|
||||
func DeleteMessageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called DeleteMessageFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.DeletePost(ctx, postId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post] failed to delete message: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"post_id": postId,
|
||||
"message": "Message deleted successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func BulkDeleteMessagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called BulkDeleteMessagesFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postIDsStr, err := params.GetString(args, "post_ids")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_ids] %v", err)), nil
|
||||
}
|
||||
|
||||
postIDs := strings.Split(postIDsStr, ",")
|
||||
if len(postIDs) > 100 {
|
||||
return to.Error(fmt.Errorf("[post_ids] too many post IDs (max 100, got %d)", len(postIDs))), nil
|
||||
}
|
||||
|
||||
for i, id := range postIDs {
|
||||
postIDs[i] = strings.TrimSpace(id)
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
failed := 0
|
||||
var failedIDs []string
|
||||
var lastError string
|
||||
|
||||
for _, postID := range postIDs {
|
||||
err := client.DeletePost(ctx, postID)
|
||||
if err != nil {
|
||||
failed++
|
||||
failedIDs = append(failedIDs, postID)
|
||||
lastError = err.Error()
|
||||
} else {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"total": len(postIDs),
|
||||
"deleted": deleted,
|
||||
"failed": failed,
|
||||
"successful": deleted == len(postIDs),
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
result["failed_ids"] = failedIDs
|
||||
result["last_error"] = lastError
|
||||
return to.Result(result), nil
|
||||
}
|
||||
|
||||
return to.Result(result), nil
|
||||
}
|
||||
|
||||
func PinPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called PinPostFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.PinPost(ctx, postId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post] failed to pin: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"post_id": postId,
|
||||
"message": "Post pinned successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func UnpinPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called UnpinPostFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.UnpinPost(ctx, postId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post] failed to unpin: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"post_id": postId,
|
||||
"message": "Post unpinned successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetPinnedPostsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called GetPinnedPostsFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelId, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
posts, err := client.GetPinnedPosts(ctx, channelId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel] failed to get pinned posts: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(posts.Posts))
|
||||
for _, post := range posts.Posts {
|
||||
results = append(results, SlimPost(post))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"posts": results,
|
||||
"count": len(results),
|
||||
"channel_id": channelId,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetPostFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called GetPostFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
post, err := client.GetPost(ctx, postId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post] failed to get: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimPost(post)), nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimPost(t *testing.T) {
|
||||
p := &model.Post{
|
||||
Id: "abc123",
|
||||
ChannelId: "channel456",
|
||||
UserId: "user789",
|
||||
Message: "Hello World",
|
||||
CreateAt: 1234567890000,
|
||||
UpdateAt: 1234567890001,
|
||||
}
|
||||
|
||||
slim := SlimPost(p)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Equal(t, "abc123", slim["id"])
|
||||
assert.Equal(t, "channel456", slim["channel_id"])
|
||||
assert.Equal(t, "user789", slim["user_id"])
|
||||
assert.Equal(t, "Hello World", slim["message"])
|
||||
assert.Equal(t, int64(1234567890000), slim["create_at"])
|
||||
assert.Equal(t, int64(1234567890001), slim["update_at"])
|
||||
}
|
||||
|
||||
func TestSlimPost_Nil(t *testing.T) {
|
||||
slim := SlimPost(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
writeTools := Tool.WriteTools()
|
||||
assert.Len(t, writeTools, 3)
|
||||
|
||||
readTools := Tool.ReadTools()
|
||||
assert.Len(t, readTools, 3)
|
||||
|
||||
toolNames := make(map[string]bool)
|
||||
for _, t := range writeTools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
for _, t := range readTools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, toolNames[SendMessageToolName], "SendMessage tool should be registered")
|
||||
assert.True(t, toolNames[EditMessageToolName], "EditMessage tool should be registered")
|
||||
assert.True(t, toolNames[DeleteMessageToolName], "DeleteMessage tool should be registered")
|
||||
assert.True(t, toolNames[GetChannelMessagesToolName], "GetChannelMessages tool should be registered")
|
||||
assert.True(t, toolNames[GetThreadToolName], "GetThread tool should be registered")
|
||||
assert.True(t, toolNames[SearchPostsToolName], "SearchPosts tool should be registered")
|
||||
}
|
||||
|
||||
func TestSendMessageFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: SendMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
"message": "Test message",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := SendMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestEditMessageFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: EditMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"post_id": "post123",
|
||||
"message": "Updated message",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := EditMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestDeleteMessageFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DeleteMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"post_id": "post123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DeleteMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestSendMessageFn_MissingChannelId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: SendMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"message": "Test message",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := SendMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestSendMessageFn_MissingMessage(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: SendMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"channel_id": "channel123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := SendMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestEditMessageFn_MissingPostId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: EditMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"message": "Updated message",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := EditMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestEditMessageFn_MissingMessage(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: EditMessageToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"post_id": "post123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := EditMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestDeleteMessageFn_MissingPostId(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: DeleteMessageToolName,
|
||||
Arguments: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := DeleteMessageFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
SearchPostsToolName = "mattermost_search_posts"
|
||||
)
|
||||
|
||||
var (
|
||||
SearchPostsTool = mcp.NewTool(
|
||||
SearchPostsToolName,
|
||||
mcp.WithDescription("Search for posts/messages in a team using search terms"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to search in")),
|
||||
mcp.WithString("terms", mcp.Required(), mcp.Description("Search terms (e.g., \"BTCUSD\", \"error\", \"meeting\")")),
|
||||
mcp.WithBoolean("is_or_search", mcp.Description("Use OR logic instead of AND (default: false)")),
|
||||
mcp.WithNumber("limit", mcp.Description("Maximum number of results to return (default 30)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerSearchTools()
|
||||
}
|
||||
|
||||
func registerSearchTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: SearchPostsTool, Handler: SearchPostsFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func SearchPostsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called SearchPostsFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
terms, err := params.GetString(args, "terms")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[terms] %v", err)), nil
|
||||
}
|
||||
|
||||
isOrSearch := params.GetOptionalBool(args, "is_or_search", false)
|
||||
limit := params.GetOptionalInt(args, "limit", 30)
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
results, err := client.SearchPosts(ctx, teamID, terms, isOrSearch)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[search] failed to search posts: %v", err)), nil
|
||||
}
|
||||
|
||||
posts := results.ToSlice()
|
||||
if len(posts) > int(limit) {
|
||||
posts = posts[:limit]
|
||||
}
|
||||
|
||||
postResults := make([]map[string]interface{}, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
postResults = append(postResults, SlimPost(post))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"posts": postResults,
|
||||
"count": len(postResults),
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package messaging
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimPost(p *model.Post) map[string]interface{} {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": p.Id,
|
||||
"channel_id": p.ChannelId,
|
||||
"user_id": p.UserId,
|
||||
"message": p.Message,
|
||||
"create_at": p.CreateAt,
|
||||
"update_at": p.UpdateAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
GetThreadToolName = "mattermost_get_thread"
|
||||
)
|
||||
|
||||
var (
|
||||
GetThreadTool = mcp.NewTool(
|
||||
GetThreadToolName,
|
||||
mcp.WithDescription("Read all messages in a thread conversation"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("The root post ID of the thread")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerGetThreadTool()
|
||||
}
|
||||
|
||||
func registerGetThreadTool() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: GetThreadTool, Handler: GetThreadFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func GetThreadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Messaging] Called GetThreadFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postID, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
thread, err := client.GetPostThread(ctx, postID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[thread] failed to get thread: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(thread.Posts))
|
||||
for _, post := range thread.Posts {
|
||||
results = append(results, SlimPost(post))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"posts": results,
|
||||
"count": len(results),
|
||||
"root_id": postID,
|
||||
"has_replies": len(results) > 1,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/channel"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/command"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/dm"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/file"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/messaging"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/outgoing"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/reaction"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/system"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/team"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/user"
|
||||
"github.com/karti-ai/mattermost-mcp-server/operation/webhook"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/flag"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
func Register() []server.ServerTool {
|
||||
log.Infof("Registering tools for Mattermost MCP server %s", flag.Version)
|
||||
|
||||
tools := tool.New()
|
||||
|
||||
for _, t := range messaging.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range file.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range dm.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range reaction.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range channel.AdminTool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range messaging.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range file.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range channel.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range user.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range team.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range dm.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range reaction.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range webhook.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range webhook.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range command.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range outgoing.Tool.WriteTools() {
|
||||
tools.RegisterWrite(t)
|
||||
}
|
||||
|
||||
for _, t := range outgoing.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
for _, t := range system.Tool.ReadTools() {
|
||||
tools.RegisterRead(t)
|
||||
}
|
||||
|
||||
return tools.Tools()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package outgoing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
CreateOutgoingWebhookToolName = "mattermost_create_outgoing_webhook"
|
||||
ListOutgoingWebhooksToolName = "mattermost_list_outgoing_webhooks"
|
||||
DeleteOutgoingWebhookToolName = "mattermost_delete_outgoing_webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateOutgoingWebhookTool = mcp.NewTool(
|
||||
CreateOutgoingWebhookToolName,
|
||||
mcp.WithDescription("Create an outgoing webhook that triggers on specific words"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID for the webhook")),
|
||||
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the webhook")),
|
||||
mcp.WithString("trigger_words", mcp.Required(), mcp.Description("Comma-separated list of words that trigger the webhook")),
|
||||
mcp.WithString("callback_url", mcp.Required(), mcp.Description("URL to POST to when triggered")),
|
||||
)
|
||||
|
||||
ListOutgoingWebhooksTool = mcp.NewTool(
|
||||
ListOutgoingWebhooksToolName,
|
||||
mcp.WithDescription("List outgoing webhooks for a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list webhooks for")),
|
||||
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
|
||||
mcp.WithNumber("per_page", mcp.Description("Items per page (default 20, max 100)")),
|
||||
)
|
||||
|
||||
DeleteOutgoingWebhookTool = mcp.NewTool(
|
||||
DeleteOutgoingWebhookToolName,
|
||||
mcp.WithDescription("Delete an outgoing webhook"),
|
||||
mcp.WithString("webhook_id", mcp.Required(), mcp.Description("Webhook ID to delete")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: CreateOutgoingWebhookTool, Handler: CreateOutgoingWebhookFn},
|
||||
{Tool: ListOutgoingWebhooksTool, Handler: ListOutgoingWebhooksFn},
|
||||
{Tool: DeleteOutgoingWebhookTool, Handler: DeleteOutgoingWebhookFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.Tool.Name == ListOutgoingWebhooksToolName {
|
||||
Tool.RegisterRead(t)
|
||||
} else {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CreateOutgoingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Outgoing] Called CreateOutgoingWebhookFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
displayName, err := params.GetString(args, "display_name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
|
||||
}
|
||||
|
||||
triggerWordsStr, err := params.GetString(args, "trigger_words")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[trigger_words] %v", err)), nil
|
||||
}
|
||||
triggerWords := strings.Split(triggerWordsStr, ",")
|
||||
for i, word := range triggerWords {
|
||||
triggerWords[i] = strings.TrimSpace(word)
|
||||
}
|
||||
|
||||
callbackURL, err := params.GetString(args, "callback_url")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[callback_url] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
hook, err := client.CreateOutgoingWebhook(ctx, teamID, displayName, triggerWords, callbackURL)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook] failed to create: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"id": hook.Id,
|
||||
"team_id": hook.TeamId,
|
||||
"display_name": hook.DisplayName,
|
||||
"trigger_words": hook.TriggerWords,
|
||||
"callback_url": callbackURL,
|
||||
"message": "Outgoing webhook created successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func ListOutgoingWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Outgoing] Called ListOutgoingWebhooksFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
page := params.GetOptionalInt(args, "page", 0)
|
||||
perPage := params.GetOptionalInt(args, "per_page", 20)
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
hooks, err := client.ListOutgoingWebhooks(ctx, teamID, int(page), int(perPage))
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook] failed to list: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(hooks))
|
||||
for _, hook := range hooks {
|
||||
results = append(results, map[string]interface{}{
|
||||
"id": hook.Id,
|
||||
"team_id": hook.TeamId,
|
||||
"display_name": hook.DisplayName,
|
||||
"trigger_words": hook.TriggerWords,
|
||||
})
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"webhooks": results,
|
||||
"count": len(results),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func DeleteOutgoingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Outgoing] Called DeleteOutgoingWebhookFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
webhookID, err := params.GetString(args, "webhook_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.DeleteOutgoingWebhook(ctx, webhookID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook] failed to delete: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"webhook_id": webhookID,
|
||||
"message": "Outgoing webhook deleted successfully",
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package reaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
AddReactionToolName = "mattermost_add_reaction"
|
||||
RemoveReactionToolName = "mattermost_remove_reaction"
|
||||
ListReactionsToolName = "mattermost_list_reactions"
|
||||
)
|
||||
|
||||
var (
|
||||
AddReactionTool = mcp.NewTool(
|
||||
AddReactionToolName,
|
||||
mcp.WithDescription("Add emoji reaction to post"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to add reaction to")),
|
||||
mcp.WithString("emoji_name", mcp.Required(), mcp.Description("Emoji name without colons (e.g., thumbsup, not :thumbsup:)")),
|
||||
)
|
||||
|
||||
RemoveReactionTool = mcp.NewTool(
|
||||
RemoveReactionToolName,
|
||||
mcp.WithDescription("Remove emoji reaction from post"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to remove reaction from")),
|
||||
mcp.WithString("emoji_name", mcp.Required(), mcp.Description("Emoji name without colons")),
|
||||
)
|
||||
|
||||
ListReactionsTool = mcp.NewTool(
|
||||
ListReactionsToolName,
|
||||
mcp.WithDescription("List all emoji reactions on a post"),
|
||||
mcp.WithString("post_id", mcp.Required(), mcp.Description("Post ID to get reactions for")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: AddReactionTool, Handler: AddReactionFn},
|
||||
{Tool: RemoveReactionTool, Handler: RemoveReactionFn},
|
||||
{Tool: ListReactionsTool, Handler: ListReactionsFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.Tool.Name == ListReactionsToolName {
|
||||
Tool.RegisterRead(t)
|
||||
} else {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func AddReactionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Reaction] Called AddReactionFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
emojiName, err := params.GetString(args, "emoji_name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[emoji_name] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
// Get current user ID
|
||||
me, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
reaction := &model.Reaction{
|
||||
UserId: me.Id,
|
||||
PostId: postId,
|
||||
EmojiName: emojiName,
|
||||
}
|
||||
|
||||
result, err := client.SaveReaction(ctx, reaction)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[reaction] failed to add reaction: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimReaction(result)), nil
|
||||
}
|
||||
|
||||
func RemoveReactionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Reaction] Called RemoveReactionFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
emojiName, err := params.GetString(args, "emoji_name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[emoji_name] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
// Get current user ID
|
||||
me, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
reaction := &model.Reaction{
|
||||
UserId: me.Id,
|
||||
PostId: postId,
|
||||
EmojiName: emojiName,
|
||||
}
|
||||
|
||||
err = client.DeleteReaction(ctx, reaction)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[reaction] failed to remove reaction: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"post_id": postId,
|
||||
"emoji_name": emojiName,
|
||||
"message": "Reaction removed successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func ListReactionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Reaction] Called ListReactionsFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
postId, err := params.GetString(args, "post_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[post_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
reactions, err := client.GetReactions(ctx, postId)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[reaction] failed to list reactions: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(reactions))
|
||||
for _, r := range reactions {
|
||||
results = append(results, SlimReaction(r))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"reactions": results,
|
||||
"count": len(results),
|
||||
"post_id": postId,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package reaction
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimReaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reaction *model.Reaction
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "valid reaction",
|
||||
reaction: &model.Reaction{
|
||||
UserId: "user123",
|
||||
PostId: "post456",
|
||||
EmojiName: "thumbsup",
|
||||
CreateAt: 1234567890,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"user_id": "user123",
|
||||
"post_id": "post456",
|
||||
"emoji_name": "thumbsup",
|
||||
"create_at": int64(1234567890),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil reaction",
|
||||
reaction: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "reaction with different emoji",
|
||||
reaction: &model.Reaction{
|
||||
UserId: "user789",
|
||||
PostId: "post012",
|
||||
EmojiName: "rocket",
|
||||
CreateAt: 9876543210,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"user_id": "user789",
|
||||
"post_id": "post012",
|
||||
"emoji_name": "rocket",
|
||||
"create_at": int64(9876543210),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SlimReaction(tt.reaction)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolConstants(t *testing.T) {
|
||||
assert.Equal(t, "mattermost_add_reaction", AddReactionToolName)
|
||||
assert.Equal(t, "mattermost_remove_reaction", RemoveReactionToolName)
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
// Verify tools are registered
|
||||
tools := Tool.Tools()
|
||||
assert.Len(t, tools, 2)
|
||||
|
||||
toolNames := make([]string, len(tools))
|
||||
for i, t := range tools {
|
||||
toolNames[i] = t.Tool.Name
|
||||
}
|
||||
|
||||
assert.Contains(t, toolNames, AddReactionToolName)
|
||||
assert.Contains(t, toolNames, RemoveReactionToolName)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package reaction
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimReaction(r *model.Reaction) map[string]interface{} {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"user_id": r.UserId,
|
||||
"post_id": r.PostId,
|
||||
"emoji_name": r.EmojiName,
|
||||
"create_at": r.CreateAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
GetSystemLogsToolName = "mattermost_get_system_logs"
|
||||
GetServerConfigToolName = "mattermost_get_server_config"
|
||||
)
|
||||
|
||||
var (
|
||||
GetSystemLogsTool = mcp.NewTool(
|
||||
GetSystemLogsToolName,
|
||||
mcp.WithDescription("Get system logs (requires admin privileges)"),
|
||||
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
|
||||
mcp.WithNumber("per_page", mcp.Description("Log lines per page (default 100, max 500)")),
|
||||
)
|
||||
|
||||
GetServerConfigTool = mcp.NewTool(
|
||||
GetServerConfigToolName,
|
||||
mcp.WithDescription("Get server configuration (requires admin privileges)"),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: GetSystemLogsTool, Handler: GetSystemLogsFn},
|
||||
{Tool: GetServerConfigTool, Handler: GetServerConfigFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func GetSystemLogsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[System] Called GetSystemLogsFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
page := params.GetOptionalInt(args, "page", 0)
|
||||
perPage := params.GetOptionalInt(args, "per_page", 100)
|
||||
if perPage > 500 {
|
||||
perPage = 500
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
logs, _, err := client.GetSystemLogs(ctx, int(page), int(perPage))
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[system] failed to get logs: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetServerConfigFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[System] Called GetServerConfigFn")
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
config, err := client.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[system] failed to get config: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"site_name": config.TeamSettings.SiteName,
|
||||
"max_notifications_per_channel": config.TeamSettings.MaxNotificationsPerChannel,
|
||||
"enable_custom_emoji": config.ServiceSettings.EnableCustomEmoji,
|
||||
"enable_link_previews": config.ServiceSettings.EnableLinkPreviews,
|
||||
"enable_public_channels": config.TeamSettings.EnableOpenServer,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package team
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimTeam(t *model.Team) map[string]interface{} {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"display_name": t.DisplayName,
|
||||
"description": t.Description,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
ListTeamsToolName = "mattermost_list_teams"
|
||||
ListTeamMembersToolName = "mattermost_list_team_members"
|
||||
InviteUserToTeamToolName = "mattermost_invite_user_to_team"
|
||||
RemoveUserFromTeamToolName = "mattermost_remove_user_from_team"
|
||||
GetTeamStatsToolName = "mattermost_get_team_stats"
|
||||
)
|
||||
|
||||
var (
|
||||
ListTeamsTool = mcp.NewTool(
|
||||
ListTeamsToolName,
|
||||
mcp.WithDescription("List all teams the bot has access to"),
|
||||
)
|
||||
|
||||
ListTeamMembersTool = mcp.NewTool(
|
||||
ListTeamMembersToolName,
|
||||
mcp.WithDescription("List all members of a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list members for")),
|
||||
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
|
||||
mcp.WithNumber("per_page", mcp.Description("Members per page (default 60, max 200)")),
|
||||
)
|
||||
|
||||
InviteUserToTeamTool = mcp.NewTool(
|
||||
InviteUserToTeamToolName,
|
||||
mcp.WithDescription("Invite/add a user to a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to invite user to")),
|
||||
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to invite")),
|
||||
)
|
||||
|
||||
RemoveUserFromTeamTool = mcp.NewTool(
|
||||
RemoveUserFromTeamToolName,
|
||||
mcp.WithDescription("Remove a user from a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to remove user from")),
|
||||
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to remove")),
|
||||
)
|
||||
|
||||
GetTeamStatsTool = mcp.NewTool(
|
||||
GetTeamStatsToolName,
|
||||
mcp.WithDescription("Get statistics for a team (member count, etc.)"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to get stats for")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: ListTeamsTool, Handler: ListTeamsFn},
|
||||
{Tool: ListTeamMembersTool, Handler: ListTeamMembersFn},
|
||||
{Tool: GetTeamStatsTool, Handler: GetTeamStatsFn},
|
||||
{Tool: InviteUserToTeamTool, Handler: InviteUserToTeamFn},
|
||||
{Tool: RemoveUserFromTeamTool, Handler: RemoveUserFromTeamFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.Tool.Name == InviteUserToTeamToolName || t.Tool.Name == RemoveUserFromTeamToolName {
|
||||
Tool.RegisterWrite(t)
|
||||
} else {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ListTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Team] Called ListTeamsFn")
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
user, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
teams, err := client.GetTeamsForUser(ctx, user.Id)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[teams] failed to list teams: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
results = append(results, SlimTeam(t))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"teams": results,
|
||||
"count": len(results),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func ListTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Team] Called ListTeamMembersFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
page := params.GetOptionalInt(args, "page", 0)
|
||||
perPage := params.GetOptionalInt(args, "per_page", 60)
|
||||
if perPage > 200 {
|
||||
perPage = 200
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
members, err := client.ListTeamMembers(ctx, teamID, int(page), int(perPage))
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team] failed to list members: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(members))
|
||||
for _, m := range members {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": m.UserId,
|
||||
"roles": m.Roles,
|
||||
})
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"members": results,
|
||||
"count": len(results),
|
||||
"team_id": teamID,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func InviteUserToTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Team] Called InviteUserToTeamFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
userID, err := params.GetString(args, "user_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
_, err = client.InviteUserToTeam(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team] failed to invite user: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"team_id": teamID,
|
||||
"user_id": userID,
|
||||
"message": "User invited to team successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func RemoveUserFromTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Team] Called RemoveUserFromTeamFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
userID, err := params.GetString(args, "user_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.RemoveUserFromTeam(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team] failed to remove user: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"team_id": teamID,
|
||||
"user_id": userID,
|
||||
"message": "User removed from team successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetTeamStatsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Team] Called GetTeamStatsFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
stats, err := client.GetTeamStats(ctx, teamID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team] failed to get stats: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"team_id": teamID,
|
||||
"total_members": stats.TotalMemberCount,
|
||||
"active_members": stats.ActiveMemberCount,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimTeam(t *testing.T) {
|
||||
team := &model.Team{
|
||||
Id: "team123",
|
||||
Name: "my-team",
|
||||
DisplayName: "My Team",
|
||||
Description: "A test team for unit testing",
|
||||
}
|
||||
|
||||
slim := SlimTeam(team)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Equal(t, "team123", slim["id"])
|
||||
assert.Equal(t, "my-team", slim["name"])
|
||||
assert.Equal(t, "My Team", slim["display_name"])
|
||||
assert.Equal(t, "A test team for unit testing", slim["description"])
|
||||
}
|
||||
|
||||
func TestSlimTeam_Nil(t *testing.T) {
|
||||
slim := SlimTeam(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
tools := Tool.Tools()
|
||||
assert.Len(t, tools, 1)
|
||||
|
||||
toolNames := make(map[string]bool)
|
||||
for _, t := range tools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, toolNames[ListTeamsToolName], "ListTeams tool should be registered")
|
||||
}
|
||||
|
||||
func TestListTeamsFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: ListTeamsToolName,
|
||||
Arguments: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ListTeamsFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package user
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
func SlimUser(u *model.User) map[string]interface{} {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": u.Id,
|
||||
"username": u.Username,
|
||||
"email": u.Email,
|
||||
"first_name": u.FirstName,
|
||||
"last_name": u.LastName,
|
||||
"roles": u.Roles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
GetUserStatusToolName = "mattermost_get_user_status"
|
||||
UpdateUserStatusToolName = "mattermost_update_user_status"
|
||||
GetUsersStatusBulkToolName = "mattermost_get_users_status_bulk"
|
||||
)
|
||||
|
||||
var (
|
||||
GetUserStatusTool = mcp.NewTool(
|
||||
GetUserStatusToolName,
|
||||
mcp.WithDescription("Get the online status of a user (online, away, dnd, offline)"),
|
||||
mcp.WithString("user_id", mcp.Required(), mcp.Description("User ID to check status for")),
|
||||
)
|
||||
|
||||
UpdateUserStatusTool = mcp.NewTool(
|
||||
UpdateUserStatusToolName,
|
||||
mcp.WithDescription("Update your status (online, away, dnd, offline)"),
|
||||
mcp.WithString("status", mcp.Required(), mcp.Description("Status to set: online, away, dnd, or offline")),
|
||||
)
|
||||
|
||||
GetUsersStatusBulkTool = mcp.NewTool(
|
||||
GetUsersStatusBulkToolName,
|
||||
mcp.WithDescription("Get status for multiple users at once (up to 100)"),
|
||||
mcp.WithString("user_ids", mcp.Required(), mcp.Description("Comma-separated list of user IDs (max 100)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerStatusTools()
|
||||
}
|
||||
|
||||
func registerStatusTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: GetUserStatusTool, Handler: GetUserStatusFn},
|
||||
{Tool: UpdateUserStatusTool, Handler: UpdateUserStatusFn},
|
||||
{Tool: GetUsersStatusBulkTool, Handler: GetUsersStatusBulkFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.Tool.Name == UpdateUserStatusToolName {
|
||||
Tool.RegisterWrite(t)
|
||||
} else {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[User] Called GetUserStatusFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
userID, err := params.GetString(args, "user_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
status, err := client.GetUserStatus(ctx, userID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[status] failed to get user status: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"user_id": status.UserId,
|
||||
"status": status.Status,
|
||||
"manual": status.Manual,
|
||||
"last_activity_at": status.LastActivityAt,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func UpdateUserStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[User] Called UpdateUserStatusFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
status, err := params.GetString(args, "status")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[status] %v", err)), nil
|
||||
}
|
||||
|
||||
// Validate status value
|
||||
validStatuses := map[string]bool{"online": true, "away": true, "dnd": true, "offline": true}
|
||||
if !validStatuses[status] {
|
||||
return to.Error(fmt.Errorf("[status] invalid status '%s', must be one of: online, away, dnd, offline", status)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
// Get current user ID
|
||||
me, err := client.GetMe(ctx)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[me] failed to get current user: %v", err)), nil
|
||||
}
|
||||
|
||||
updatedStatus, err := client.UpdateUserStatus(ctx, me.Id, status)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[status] failed to update status: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"user_id": updatedStatus.UserId,
|
||||
"status": updatedStatus.Status,
|
||||
"message": "Status updated successfully",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetUsersStatusBulkFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[User] Called GetUsersStatusBulkFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
userIDsStr, err := params.GetString(args, "user_ids")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user_ids] %v", err)), nil
|
||||
}
|
||||
|
||||
userIDs := strings.Split(userIDsStr, ",")
|
||||
if len(userIDs) > 100 {
|
||||
return to.Error(fmt.Errorf("[user_ids] too many user IDs (max 100, got %d)", len(userIDs))), nil
|
||||
}
|
||||
|
||||
for i, id := range userIDs {
|
||||
userIDs[i] = strings.TrimSpace(id)
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
statuses, err := client.GetUsersStatus(ctx, userIDs)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[status] failed to get users status: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
results = append(results, map[string]interface{}{
|
||||
"user_id": status.UserId,
|
||||
"status": status.Status,
|
||||
"manual": status.Manual,
|
||||
"last_activity_at": status.LastActivityAt,
|
||||
})
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"statuses": results,
|
||||
"count": len(results),
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
SearchUsersToolName = "mattermost_search_users"
|
||||
GetUserToolName = "mattermost_get_user"
|
||||
)
|
||||
|
||||
var (
|
||||
SearchUsersTool = mcp.NewTool(
|
||||
SearchUsersToolName,
|
||||
mcp.WithDescription("Search users by term"),
|
||||
mcp.WithString("term", mcp.Required(), mcp.Description("Search term (username, email, name)")),
|
||||
mcp.WithString("team_id", mcp.Description("Limit to team (optional)")),
|
||||
mcp.WithNumber("limit", mcp.Description("Max results (default 30)")),
|
||||
)
|
||||
|
||||
GetUserTool = mcp.NewTool(
|
||||
GetUserToolName,
|
||||
mcp.WithDescription("Get a specific user by ID or username"),
|
||||
mcp.WithString("user_id", mcp.Description("User ID to look up (optional if username provided)")),
|
||||
mcp.WithString("username", mcp.Description("Username to look up (optional if user_id provided)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: SearchUsersTool, Handler: SearchUsersFn},
|
||||
{Tool: GetUserTool, Handler: GetUserFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
Tool.RegisterRead(t)
|
||||
}
|
||||
}
|
||||
|
||||
func SearchUsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[User] Called SearchUsersFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
term, err := params.GetString(args, "term")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[term] %v", err)), nil
|
||||
}
|
||||
|
||||
teamID := params.GetOptionalString(args, "team_id", "")
|
||||
limit := params.GetOptionalInt(args, "limit", 30)
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
search := &model.UserSearch{
|
||||
Term: term,
|
||||
Limit: int(limit),
|
||||
}
|
||||
|
||||
if teamID != "" {
|
||||
search.TeamId = teamID
|
||||
}
|
||||
|
||||
users, err := client.SearchUsers(ctx, search)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[users] failed to search users: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
results = append(results, SlimUser(u))
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"users": results,
|
||||
"count": len(results),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func GetUserFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[User] Called GetUserFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
userID := params.GetOptionalString(args, "user_id", "")
|
||||
username := params.GetOptionalString(args, "username", "")
|
||||
|
||||
if userID == "" && username == "" {
|
||||
return to.Error(fmt.Errorf("[user] either user_id or username must be provided")), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
var user *model.User
|
||||
var err error
|
||||
|
||||
if userID != "" {
|
||||
user, err = client.GetUser(ctx, userID)
|
||||
} else {
|
||||
user, err = client.GetUserByUsername(ctx, username)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[user] failed to get user: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(SlimUser(user)), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlimUser(t *testing.T) {
|
||||
u := &model.User{
|
||||
Id: "user123",
|
||||
Username: "johndoe",
|
||||
Email: "john@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
Roles: "system_user",
|
||||
}
|
||||
|
||||
slim := SlimUser(u)
|
||||
assert.NotNil(t, slim)
|
||||
assert.Equal(t, "user123", slim["id"])
|
||||
assert.Equal(t, "johndoe", slim["username"])
|
||||
assert.Equal(t, "john@example.com", slim["email"])
|
||||
assert.Equal(t, "John", slim["first_name"])
|
||||
assert.Equal(t, "Doe", slim["last_name"])
|
||||
assert.Equal(t, "system_user", slim["roles"])
|
||||
}
|
||||
|
||||
func TestSlimUser_Nil(t *testing.T) {
|
||||
slim := SlimUser(nil)
|
||||
assert.Nil(t, slim)
|
||||
}
|
||||
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
tools := Tool.Tools()
|
||||
assert.Len(t, tools, 2)
|
||||
|
||||
toolNames := make(map[string]bool)
|
||||
for _, t := range tools {
|
||||
toolNames[t.Tool.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, toolNames[SearchUsersToolName], "SearchUsers tool should be registered")
|
||||
assert.True(t, toolNames[GetUserStatusToolName], "GetUserStatus tool should be registered")
|
||||
}
|
||||
|
||||
func TestSearchUsersFn_ClientNotInitialized(t *testing.T) {
|
||||
mattermost.SetGlobalClient(nil)
|
||||
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: SearchUsersToolName,
|
||||
Arguments: map[string]interface{}{
|
||||
"term": "john",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := SearchUsersFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestSearchUsersFn_MissingTerm(t *testing.T) {
|
||||
req := mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: SearchUsersToolName,
|
||||
Arguments: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := SearchUsersFn(nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/log"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/params"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/to"
|
||||
"github.com/karti-ai/mattermost-mcp-server/pkg/tool"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
CreateIncomingWebhookToolName = "mattermost_create_incoming_webhook"
|
||||
ListIncomingWebhooksToolName = "mattermost_list_incoming_webhooks"
|
||||
DeleteIncomingWebhookToolName = "mattermost_delete_incoming_webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateIncomingWebhookTool = mcp.NewTool(
|
||||
CreateIncomingWebhookToolName,
|
||||
mcp.WithDescription("Create an incoming webhook for a channel"),
|
||||
mcp.WithString("channel_id", mcp.Required(), mcp.Description("Channel ID to create webhook for")),
|
||||
mcp.WithString("display_name", mcp.Required(), mcp.Description("Display name for the webhook")),
|
||||
)
|
||||
|
||||
ListIncomingWebhooksTool = mcp.NewTool(
|
||||
ListIncomingWebhooksToolName,
|
||||
mcp.WithDescription("List incoming webhooks for a team"),
|
||||
mcp.WithString("team_id", mcp.Required(), mcp.Description("Team ID to list webhooks for")),
|
||||
mcp.WithNumber("page", mcp.Description("Page number (default 0)")),
|
||||
mcp.WithNumber("per_page", mcp.Description("Items per page (default 20, max 100)")),
|
||||
)
|
||||
|
||||
DeleteIncomingWebhookTool = mcp.NewTool(
|
||||
DeleteIncomingWebhookToolName,
|
||||
mcp.WithDescription("Delete an incoming webhook"),
|
||||
mcp.WithString("webhook_id", mcp.Required(), mcp.Description("Webhook ID to delete")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerTools()
|
||||
}
|
||||
|
||||
func registerTools() {
|
||||
tools := []server.ServerTool{
|
||||
{Tool: CreateIncomingWebhookTool, Handler: CreateIncomingWebhookFn},
|
||||
{Tool: ListIncomingWebhooksTool, Handler: ListIncomingWebhooksFn},
|
||||
{Tool: DeleteIncomingWebhookTool, Handler: DeleteIncomingWebhookFn},
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.Tool.Name == ListIncomingWebhooksToolName {
|
||||
Tool.RegisterRead(t)
|
||||
} else {
|
||||
Tool.RegisterWrite(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CreateIncomingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Webhook] Called CreateIncomingWebhookFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
channelID, err := params.GetString(args, "channel_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[channel_id] %v", err)), nil
|
||||
}
|
||||
|
||||
displayName, err := params.GetString(args, "display_name")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[display_name] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
hook, err := client.CreateIncomingWebhook(ctx, channelID, displayName)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook] failed to create: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"id": hook.Id,
|
||||
"channel_id": hook.ChannelId,
|
||||
"display_name": hook.DisplayName,
|
||||
"message": "Webhook created successfully - retrieve URL from Mattermost UI",
|
||||
}), nil
|
||||
}
|
||||
|
||||
func ListIncomingWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Webhook] Called ListIncomingWebhooksFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
teamID, err := params.GetString(args, "team_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[team_id] %v", err)), nil
|
||||
}
|
||||
|
||||
page := params.GetOptionalInt(args, "page", 0)
|
||||
perPage := params.GetOptionalInt(args, "per_page", 20)
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
hooks, err := client.ListIncomingWebhooks(ctx, teamID, int(page), int(perPage))
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook] failed to list: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(hooks))
|
||||
for _, hook := range hooks {
|
||||
results = append(results, map[string]interface{}{
|
||||
"id": hook.Id,
|
||||
"channel_id": hook.ChannelId,
|
||||
"display_name": hook.DisplayName,
|
||||
"create_at": hook.CreateAt,
|
||||
})
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"webhooks": results,
|
||||
"count": len(results),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func DeleteIncomingWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("[Webhook] Called DeleteIncomingWebhookFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
webhookID, err := params.GetString(args, "webhook_id")
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook_id] %v", err)), nil
|
||||
}
|
||||
|
||||
client := mattermost.GetGlobalClient()
|
||||
if client == nil {
|
||||
return to.Error(fmt.Errorf("[internal] client not initialized")), nil
|
||||
}
|
||||
|
||||
err = client.DeleteIncomingWebhook(ctx, webhookID)
|
||||
if err != nil {
|
||||
return to.Error(fmt.Errorf("[webhook] failed to delete: %v", err)), nil
|
||||
}
|
||||
|
||||
return to.Result(map[string]interface{}{
|
||||
"success": true,
|
||||
"webhook_id": webhookID,
|
||||
"message": "Webhook deleted successfully",
|
||||
}), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package flag
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
ReadOnly = false
|
||||
Debug = false
|
||||
ShowVersion = false
|
||||
Insecure = false
|
||||
Token = ""
|
||||
BotToken = ""
|
||||
PAT = ""
|
||||
Host = ""
|
||||
Team = ""
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user