feat: initial release with 48 Mattermost MCP tools
- 27 base tools: channels, messaging, users, reactions, files, DMs - Team management: invite/remove users, get stats, list members - Slash commands: execute /remind, /poll, etc. - Webhook management: incoming and outgoing webhooks - System tools: server config, logs, bulk status updates - Channel admin: create, invite, leave, delete channels - Read-only mode for safe exploration - Dual token support (bot + PAT) for enhanced security - Apache 2.0 licensed
This commit is contained in:
@@ -0,0 +1,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