├── trigger-monitoring.sh ├── run-monitoring.sh ├── config.json ├── run-monitoring-http.sh ├── .gitignore ├── tsconfig.json ├── package.json ├── src ├── tools │ ├── monitoring.ts │ ├── index.ts │ ├── users.ts │ ├── channels.ts │ └── messages.ts ├── monitor │ ├── scheduler.ts │ ├── analyzer.ts │ └── index.ts ├── config.ts ├── types.ts ├── client.ts └── index.ts ├── get-last-message.js ├── view-channel-messages.js ├── analyze-channel.js └── README.md /trigger-monitoring.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script starts a new server instance with the --run-monitoring flag 4 | # It will run the monitoring process immediately and then exit 5 | 6 | echo "Starting a new server instance with --run-monitoring flag..." 7 | node build/index.js --run-monitoring --exit-after-monitoring 8 | -------------------------------------------------------------------------------- /run-monitoring.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Kill any existing server process 4 | pkill -f "node build/index.js" 5 | 6 | # Build the project 7 | echo "Building the project..." 8 | npm run build 9 | 10 | # Start the server with the --run-monitoring flag 11 | echo "Starting the server with --run-monitoring flag..." 12 | node build/index.js --run-monitoring 13 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mattermostUrl": "https://your-mattermost-instance.com/api/v4", 3 | "token": "your-mattermost-token", 4 | "teamId": "your-team-id", 5 | "monitoring": { 6 | "enabled": false, 7 | "schedule": "*/15 * * * *", 8 | "channels": ["town-square", "off-topic"], 9 | "topics": ["tv series", "champions league"], 10 | "messageLimit": 50 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /run-monitoring-http.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script triggers the monitoring process via the HTTP endpoint 4 | # This is the most reliable way to trigger monitoring without restarting the server 5 | 6 | echo "Triggering monitoring process via HTTP..." 7 | curl -s http://localhost:3456/run-monitoring 8 | 9 | echo -e "\nMonitoring process triggered. Check the server logs for results." 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | 7 | # Build 8 | build/ 9 | dist/ 10 | *.tsbuildinfo 11 | 12 | # Environment 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | config.local.json 19 | 20 | # IDE 21 | .idea/ 22 | .vscode/ 23 | *.swp 24 | *.swo 25 | 26 | # OS 27 | .DS_Store 28 | Thumbs.db 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mattermost-mcp", 3 | "version": "1.0.0", 4 | "description": "Mattermost MCP Server", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "bin": { 8 | "mattermost-mcp": "./build/index.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "postbuild": "chmod +x build/index.js", 13 | "start": "node build/index.js", 14 | "dev": "tsc && node build/index.js" 15 | }, 16 | "keywords": [ 17 | "mattermost", 18 | "mcp", 19 | "model-context-protocol" 20 | ], 21 | "author": "", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@modelcontextprotocol/sdk": "^0.7.0", 25 | "node-cron": "^3.0.3", 26 | "node-fetch": "^3.3.2" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^20.11.0", 30 | "@types/node-cron": "^3.0.11", 31 | "typescript": "^5.3.3" 32 | }, 33 | "files": [ 34 | "build" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/tools/monitoring.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MattermostClient } from "../client.js"; 3 | import { TopicMonitor } from "../monitor/index.js"; 4 | import { loadConfig } from "../config.js"; 5 | 6 | // Global reference to the TopicMonitor instance 7 | let topicMonitorInstance: TopicMonitor | null = null; 8 | 9 | // Set the TopicMonitor instance 10 | export function setTopicMonitorInstance(instance: TopicMonitor): void { 11 | topicMonitorInstance = instance; 12 | } 13 | 14 | // Tool definition for running monitoring immediately 15 | export const runMonitoringTool: Tool = { 16 | name: "mattermost_run_monitoring", 17 | description: "Run the topic monitoring process immediately", 18 | inputSchema: { 19 | type: "object", 20 | properties: {}, 21 | required: [] 22 | } 23 | }; 24 | 25 | // Handler for the run monitoring tool 26 | export async function handleRunMonitoring(client: MattermostClient, args: any) { 27 | try { 28 | if (!topicMonitorInstance) { 29 | // If no instance is set, create a new one 30 | const config = loadConfig(); 31 | if (!config.monitoring?.enabled) { 32 | return { 33 | content: [ 34 | { 35 | type: "text", 36 | text: JSON.stringify({ 37 | error: "Topic monitoring is disabled in configuration", 38 | }), 39 | }, 40 | ], 41 | isError: true, 42 | }; 43 | } 44 | 45 | topicMonitorInstance = new TopicMonitor(client, config.monitoring); 46 | await topicMonitorInstance.start(); 47 | } 48 | 49 | // Run the monitoring process 50 | await topicMonitorInstance.runNow(); 51 | 52 | return { 53 | content: [ 54 | { 55 | type: "text", 56 | text: JSON.stringify({ 57 | message: "Topic monitoring process executed successfully", 58 | }), 59 | }, 60 | ], 61 | }; 62 | } catch (error) { 63 | return { 64 | content: [ 65 | { 66 | type: "text", 67 | text: JSON.stringify({ 68 | error: error instanceof Error ? error.message : String(error), 69 | }), 70 | }, 71 | ], 72 | isError: true, 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { 3 | listChannelsTool, 4 | getChannelHistoryTool, 5 | handleListChannels, 6 | handleGetChannelHistory 7 | } from "./channels.js"; 8 | import { 9 | postMessageTool, 10 | replyToThreadTool, 11 | addReactionTool, 12 | getThreadRepliesTool, 13 | handlePostMessage, 14 | handleReplyToThread, 15 | handleAddReaction, 16 | handleGetThreadReplies 17 | } from "./messages.js"; 18 | import { 19 | getUsersTool, 20 | getUserProfileTool, 21 | handleGetUsers, 22 | handleGetUserProfile 23 | } from "./users.js"; 24 | import { 25 | runMonitoringTool, 26 | handleRunMonitoring, 27 | setTopicMonitorInstance 28 | } from "./monitoring.js"; 29 | import { MattermostClient } from "../client.js"; 30 | 31 | // Export all tool definitions 32 | export const tools: Tool[] = [ 33 | listChannelsTool, 34 | getChannelHistoryTool, 35 | postMessageTool, 36 | replyToThreadTool, 37 | addReactionTool, 38 | getThreadRepliesTool, 39 | getUsersTool, 40 | getUserProfileTool, 41 | runMonitoringTool 42 | ]; 43 | 44 | // Export the setTopicMonitorInstance function 45 | export { setTopicMonitorInstance }; 46 | 47 | // Tool handler map 48 | export const toolHandlers: Record = { 49 | mattermost_list_channels: handleListChannels, 50 | mattermost_get_channel_history: handleGetChannelHistory, 51 | mattermost_post_message: handlePostMessage, 52 | mattermost_reply_to_thread: handleReplyToThread, 53 | mattermost_add_reaction: handleAddReaction, 54 | mattermost_get_thread_replies: handleGetThreadReplies, 55 | mattermost_get_users: handleGetUsers, 56 | mattermost_get_user_profile: handleGetUserProfile, 57 | mattermost_run_monitoring: handleRunMonitoring 58 | }; 59 | 60 | // Execute a tool with the given name and arguments 61 | export async function executeTool( 62 | client: MattermostClient, 63 | toolName: string, 64 | args: any 65 | ) { 66 | const handler = toolHandlers[toolName]; 67 | 68 | if (!handler) { 69 | return { 70 | content: [ 71 | { 72 | type: "text", 73 | text: JSON.stringify({ 74 | error: `Unknown tool: ${toolName}`, 75 | }), 76 | }, 77 | ], 78 | isError: true, 79 | }; 80 | } 81 | 82 | try { 83 | return await handler(client, args); 84 | } catch (error) { 85 | console.error(`Error executing tool ${toolName}:`, error); 86 | return { 87 | content: [ 88 | { 89 | type: "text", 90 | text: JSON.stringify({ 91 | error: error instanceof Error ? error.message : String(error), 92 | }), 93 | }, 94 | ], 95 | isError: true, 96 | }; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /get-last-message.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { MattermostClient } from './build/client.js'; 4 | import { loadConfig } from './build/config.js'; 5 | 6 | // Get command line arguments 7 | const args = process.argv.slice(2); 8 | 9 | if (args.length === 0) { 10 | console.log("Usage: node get-last-message.js "); 11 | console.log("Example: node get-last-message.js town-square"); 12 | process.exit(1); 13 | } 14 | 15 | const channelName = args[0]; 16 | 17 | async function main() { 18 | try { 19 | // Initialize Mattermost client 20 | const client = new MattermostClient(); 21 | console.log("Successfully initialized Mattermost client"); 22 | 23 | // Get channels to find the specified channel ID 24 | console.log("Fetching channels..."); 25 | const channelsResponse = await client.getChannels(100, 0); 26 | 27 | // Find the specified channel 28 | let channelId = null; 29 | for (const channel of channelsResponse.channels) { 30 | if (channel.name === channelName) { 31 | channelId = channel.id; 32 | console.log(`Found channel ${channelName}: ${channel.id}`); 33 | break; 34 | } 35 | } 36 | 37 | if (!channelId) { 38 | console.error(`Could not find channel: ${channelName}`); 39 | console.log("Available channels:"); 40 | channelsResponse.channels.forEach(channel => { 41 | console.log(`- ${channel.name} (${channel.display_name})`); 42 | }); 43 | process.exit(1); 44 | } 45 | 46 | // Get posts from the channel 47 | console.log(`Fetching posts from ${channelName} channel...`); 48 | const postsResponse = await client.getPostsForChannel(channelId, 1, 0); 49 | 50 | // Get the last post 51 | const posts = Object.values(postsResponse.posts || {}); 52 | if (posts.length === 0) { 53 | console.log(`No posts found in ${channelName} channel`); 54 | process.exit(0); 55 | } 56 | 57 | // Sort posts by create_at (newest first) 58 | posts.sort((a, b) => b.create_at - a.create_at); 59 | 60 | // Display the last post 61 | const lastPost = posts[0]; 62 | console.log(`\n=== Last Message in ${channelName} Channel ===`); 63 | console.log(`From: ${lastPost.user_id}`); 64 | console.log(`Time: ${new Date(lastPost.create_at).toLocaleString()}`); 65 | console.log(`Message: ${lastPost.message}`); 66 | console.log("==========================================\n"); 67 | 68 | // Try to get the username for the user ID 69 | try { 70 | const userProfile = await client.getUserProfile(lastPost.user_id); 71 | console.log(`Username: ${userProfile.username}`); 72 | } catch (error) { 73 | console.error("Could not get username for user ID:", error); 74 | } 75 | 76 | } catch (error) { 77 | console.error("Error:", error); 78 | process.exit(1); 79 | } 80 | } 81 | 82 | main(); 83 | -------------------------------------------------------------------------------- /src/monitor/scheduler.ts: -------------------------------------------------------------------------------- 1 | import cron from 'node-cron'; 2 | import { MonitoringConfig } from '../config.js'; 3 | 4 | type TaskFunction = () => Promise; 5 | 6 | /** 7 | * Scheduler class for managing cron jobs 8 | */ 9 | export class Scheduler { 10 | private task: cron.ScheduledTask | null = null; 11 | private config: MonitoringConfig; 12 | private taskFn: TaskFunction; 13 | 14 | /** 15 | * Creates a new scheduler 16 | * @param config Monitoring configuration 17 | * @param taskFn Function to execute on schedule 18 | */ 19 | constructor(config: MonitoringConfig, taskFn: TaskFunction) { 20 | this.config = config; 21 | this.taskFn = taskFn; 22 | } 23 | 24 | /** 25 | * Starts the scheduled task 26 | */ 27 | start(): void { 28 | if (this.task) { 29 | console.error('Task is already running'); 30 | return; 31 | } 32 | 33 | try { 34 | // Validate cron expression 35 | if (!cron.validate(this.config.schedule)) { 36 | throw new Error(`Invalid cron expression: ${this.config.schedule}`); 37 | } 38 | 39 | // Schedule the task 40 | this.task = cron.schedule(this.config.schedule, async () => { 41 | try { 42 | console.error(`[${new Date().toISOString()}] Running scheduled monitoring task`); 43 | await this.taskFn(); 44 | } catch (error) { 45 | console.error('Error in scheduled task:', error); 46 | } 47 | }); 48 | 49 | console.error(`Monitoring scheduler started with schedule: ${this.config.schedule}`); 50 | } catch (error) { 51 | console.error('Failed to start scheduler:', error); 52 | throw error; 53 | } 54 | } 55 | 56 | /** 57 | * Stops the scheduled task 58 | */ 59 | stop(): void { 60 | if (!this.task) { 61 | console.error('No task is running'); 62 | return; 63 | } 64 | 65 | this.task.stop(); 66 | this.task = null; 67 | console.error('Monitoring scheduler stopped'); 68 | } 69 | 70 | /** 71 | * Updates the scheduler configuration 72 | * @param config New monitoring configuration 73 | */ 74 | updateConfig(config: MonitoringConfig): void { 75 | this.config = config; 76 | 77 | // Restart the task if it's running 78 | if (this.task) { 79 | this.stop(); 80 | this.start(); 81 | } 82 | } 83 | 84 | /** 85 | * Checks if the scheduler is running 86 | * @returns True if the scheduler is running, false otherwise 87 | */ 88 | isRunning(): boolean { 89 | return this.task !== null; 90 | } 91 | 92 | /** 93 | * Runs the task immediately, regardless of schedule 94 | */ 95 | async runNow(): Promise { 96 | try { 97 | console.error(`[${new Date().toISOString()}] Running monitoring task manually`); 98 | await this.taskFn(); 99 | } catch (error) { 100 | console.error('Error in manual task execution:', error); 101 | throw error; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | // Get the directory name of the current module 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | export interface MonitoringConfig { 10 | enabled: boolean; 11 | schedule: string; // Cron format 12 | channels: string[]; // Channel names to monitor 13 | topics: string[]; // Topics to look for 14 | messageLimit: number; // Number of recent messages to analyze per check 15 | notificationChannelId?: string; // Where to send notifications (optional, will use DM if not provided) 16 | userId?: string; // User ID for mentions (optional, will be auto-detected if not provided) 17 | } 18 | 19 | export interface Config { 20 | mattermostUrl: string; 21 | token: string; 22 | teamId: string; 23 | monitoring?: MonitoringConfig; 24 | } 25 | 26 | export function loadConfig(): Config { 27 | try { 28 | // First try to load from config.local.json 29 | const localConfigPath = path.resolve(__dirname, '../config.local.json'); 30 | 31 | // Check if local config exists 32 | if (fs.existsSync(localConfigPath)) { 33 | const configData = fs.readFileSync(localConfigPath, 'utf8'); 34 | const config = JSON.parse(configData) as Config; 35 | 36 | // Validate required fields 37 | validateConfig(config); 38 | return config; 39 | } 40 | 41 | // Fall back to config.json 42 | const configPath = path.resolve(__dirname, '../config.json'); 43 | const configData = fs.readFileSync(configPath, 'utf8'); 44 | const config = JSON.parse(configData) as Config; 45 | 46 | // Validate required fields 47 | validateConfig(config); 48 | return config; 49 | } catch (error) { 50 | console.error('Error loading configuration:', error); 51 | throw new Error('Failed to load configuration. Please ensure config.json or config.local.json exists and contains valid data.'); 52 | } 53 | } 54 | 55 | // Helper function to validate config 56 | function validateConfig(config: Config): void { 57 | if (!config.mattermostUrl) { 58 | throw new Error('Missing mattermostUrl in configuration'); 59 | } 60 | if (!config.token) { 61 | throw new Error('Missing token in configuration'); 62 | } 63 | if (!config.teamId) { 64 | throw new Error('Missing teamId in configuration'); 65 | } 66 | 67 | // Validate monitoring config if enabled 68 | if (config.monitoring?.enabled) { 69 | if (!config.monitoring.schedule) { 70 | throw new Error('Missing schedule in monitoring configuration'); 71 | } 72 | if (!config.monitoring.channels || config.monitoring.channels.length === 0) { 73 | throw new Error('No channels specified in monitoring configuration'); 74 | } 75 | if (!config.monitoring.topics || config.monitoring.topics.length === 0) { 76 | throw new Error('No topics specified in monitoring configuration'); 77 | } 78 | // userId and notificationChannelId are now optional 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for Mattermost API responses and MCP tool arguments 2 | 3 | // Tool argument types 4 | export interface ListChannelsArgs { 5 | limit?: number; 6 | page?: number; 7 | } 8 | 9 | export interface PostMessageArgs { 10 | channel_id: string; 11 | message: string; 12 | } 13 | 14 | export interface ReplyToThreadArgs { 15 | channel_id: string; 16 | post_id: string; 17 | message: string; 18 | } 19 | 20 | export interface AddReactionArgs { 21 | channel_id: string; 22 | post_id: string; 23 | emoji_name: string; 24 | } 25 | 26 | export interface GetChannelHistoryArgs { 27 | channel_id: string; 28 | limit?: number; 29 | page?: number; 30 | } 31 | 32 | export interface GetThreadRepliesArgs { 33 | channel_id: string; 34 | post_id: string; 35 | } 36 | 37 | export interface GetUsersArgs { 38 | page?: number; 39 | limit?: number; 40 | } 41 | 42 | export interface GetUserProfileArgs { 43 | user_id: string; 44 | } 45 | 46 | // Mattermost API response types 47 | export interface Channel { 48 | id: string; 49 | team_id: string; 50 | display_name: string; 51 | name: string; 52 | type: string; 53 | header: string; 54 | purpose: string; 55 | create_at: number; 56 | update_at: number; 57 | delete_at: number; 58 | total_msg_count: number; 59 | creator_id: string; 60 | } 61 | 62 | export interface Post { 63 | id: string; 64 | create_at: number; 65 | update_at: number; 66 | delete_at: number; 67 | edit_at: number; 68 | user_id: string; 69 | channel_id: string; 70 | root_id: string; 71 | original_id: string; 72 | message: string; 73 | type: string; 74 | props: Record; 75 | hashtags: string; 76 | pending_post_id: string; 77 | reply_count: number; 78 | metadata: Record; 79 | } 80 | 81 | export interface User { 82 | id: string; 83 | username: string; 84 | email: string; 85 | first_name: string; 86 | last_name: string; 87 | nickname: string; 88 | position: string; 89 | roles: string; 90 | locale: string; 91 | timezone: Record; 92 | is_bot: boolean; 93 | bot_description: string; 94 | create_at: number; 95 | update_at: number; 96 | delete_at: number; 97 | } 98 | 99 | export interface UserProfile extends User { 100 | last_picture_update: number; 101 | auth_service: string; 102 | email_verified: boolean; 103 | notify_props: Record; 104 | props: Record; 105 | terms_of_service_id: string; 106 | terms_of_service_create_at: number; 107 | } 108 | 109 | export interface Reaction { 110 | user_id: string; 111 | post_id: string; 112 | emoji_name: string; 113 | create_at: number; 114 | } 115 | 116 | export interface PostsResponse { 117 | posts: Record; 118 | order: string[]; 119 | next_post_id: string; 120 | prev_post_id: string; 121 | } 122 | 123 | export interface ChannelsResponse { 124 | channels: Channel[]; 125 | total_count: number; 126 | } 127 | 128 | export interface UsersResponse { 129 | users: User[]; 130 | total_count: number; 131 | } 132 | -------------------------------------------------------------------------------- /src/monitor/analyzer.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '../types.js'; 2 | 3 | /** 4 | * Analyzes a message to determine if it contains any of the specified topics 5 | * @param post The post to analyze 6 | * @param topics Array of topics to look for 7 | * @returns True if the post contains any of the topics, false otherwise 8 | */ 9 | export function analyzePost(post: Post, topics: string[]): boolean { 10 | const message = post.message.toLowerCase(); 11 | 12 | // Check if any of the topics are mentioned in the message 13 | return topics.some(topic => { 14 | const topicLower = topic.toLowerCase(); 15 | 16 | // Check for exact match or as part of a word 17 | if (message.includes(topicLower)) { 18 | return true; 19 | } 20 | 21 | // Special handling for TV series 22 | if (topicLower === 'tv series') { 23 | // List of popular TV series to check for 24 | const tvSeries = [ 25 | 'breaking bad', 'game of thrones', 'stranger things', 'the office', 26 | 'friends', 'the mandalorian', 'westworld', 'the witcher', 'the crown', 27 | 'black mirror', 'the walking dead', 'better call saul', 'ozark', 28 | 'house of cards', 'narcos', 'peaky blinders', 'the boys', 'succession' 29 | ]; 30 | 31 | return tvSeries.some(series => message.includes(series)); 32 | } 33 | 34 | // Special handling for Champions League 35 | if (topicLower === 'champions league') { 36 | // List of Champions League teams to check for 37 | const teams = [ 38 | 'barcelona', 'real madrid', 'bayern', 'manchester', 'liverpool', 39 | 'juventus', 'psg', 'chelsea', 'dortmund', 'atletico', 'inter', 40 | 'milan', 'arsenal', 'benfica', 'porto', 'ajax', 'napoli' 41 | ]; 42 | 43 | return teams.some(team => message.includes(team)); 44 | } 45 | 46 | return false; 47 | }); 48 | } 49 | 50 | /** 51 | * Analyzes a batch of posts to find those that match the specified topics 52 | * @param posts Array of posts to analyze 53 | * @param topics Array of topics to look for 54 | * @returns Array of posts that match the topics 55 | */ 56 | export function findRelevantPosts(posts: Post[], topics: string[]): Post[] { 57 | return posts.filter(post => analyzePost(post, topics)); 58 | } 59 | 60 | /** 61 | * Creates a notification message for relevant posts 62 | * @param relevantPosts Array of posts that match the topics 63 | * @param channelName Name of the channel where the posts were found 64 | * @param username Username to mention in the notification 65 | * @returns Formatted notification message 66 | */ 67 | export function createNotificationMessage( 68 | relevantPosts: Post[], 69 | channelName: string, 70 | username: string 71 | ): string { 72 | if (relevantPosts.length === 0) { 73 | return ''; 74 | } 75 | 76 | const mention = `@${username}`; 77 | let message = `${mention} I found discussion about topics you're interested in!\n\n`; 78 | message += `**Channel:** ${channelName}\n\n`; 79 | 80 | // Add information about each relevant post 81 | relevantPosts.forEach((post, index) => { 82 | // Format the timestamp 83 | const timestamp = new Date(post.create_at).toLocaleString(); 84 | 85 | message += `**Message ${index + 1}** (${timestamp}):\n`; 86 | message += `${post.message}\n\n`; 87 | }); 88 | 89 | return message; 90 | } 91 | -------------------------------------------------------------------------------- /view-channel-messages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { MattermostClient } from './build/client.js'; 4 | 5 | // Get command line arguments 6 | const args = process.argv.slice(2); 7 | 8 | if (args.length === 0) { 9 | console.log("Usage: node view-channel-messages.js [message-count]"); 10 | console.log("Example: node view-channel-messages.js town-square 10"); 11 | console.log("\nAvailable options:"); 12 | console.log(" : Name of the channel to view messages from (required)"); 13 | console.log(" [message-count]: Number of messages to retrieve (default: 5)"); 14 | process.exit(1); 15 | } 16 | 17 | const channelName = args[0]; 18 | const messageCount = parseInt(args[1] || '5', 10); // Default to 5 messages if not specified 19 | 20 | async function main() { 21 | try { 22 | // Initialize Mattermost client 23 | const client = new MattermostClient(); 24 | console.log(`Viewing last ${messageCount} messages from "${channelName}" channel...\n`); 25 | 26 | // Get channels to find the specified channel ID 27 | const channelsResponse = await client.getChannels(100, 0); 28 | 29 | // Find the specified channel 30 | let targetChannelId = null; 31 | let targetChannel = null; 32 | 33 | for (const channel of channelsResponse.channels) { 34 | if (channel.name === channelName) { 35 | targetChannelId = channel.id; 36 | targetChannel = channel; 37 | break; 38 | } 39 | } 40 | 41 | if (!targetChannelId) { 42 | console.error(`Could not find channel: ${channelName}`); 43 | console.log("Available channels:"); 44 | channelsResponse.channels.forEach(channel => { 45 | console.log(`- ${channel.name} (${channel.display_name})`); 46 | }); 47 | process.exit(1); 48 | } 49 | 50 | console.log(`Channel: ${targetChannel.display_name} (${targetChannel.name})`); 51 | if (targetChannel.purpose) { 52 | console.log(`Purpose: ${targetChannel.purpose}`); 53 | } 54 | console.log(`Total messages: ${targetChannel.total_msg_count}`); 55 | console.log("-------------------------------------------\n"); 56 | 57 | // Get posts from the channel 58 | const postsResponse = await client.getPostsForChannel(targetChannelId, messageCount, 0); 59 | 60 | // Get the posts 61 | const posts = Object.values(postsResponse.posts || {}); 62 | if (posts.length === 0) { 63 | console.log(`No posts found in ${channelName} channel`); 64 | process.exit(0); 65 | } 66 | 67 | // Sort posts by create_at (newest first) 68 | posts.sort((a, b) => b.create_at - a.create_at); 69 | 70 | // Create a map to store usernames 71 | const usernames = new Map(); 72 | 73 | // Get usernames for all user IDs 74 | for (const post of posts) { 75 | if (!usernames.has(post.user_id)) { 76 | try { 77 | const userProfile = await client.getUserProfile(post.user_id); 78 | usernames.set(post.user_id, userProfile.username); 79 | } catch (error) { 80 | usernames.set(post.user_id, post.user_id); // Use ID as fallback 81 | } 82 | } 83 | } 84 | 85 | // Display the posts 86 | for (let i = 0; i < posts.length; i++) { 87 | const post = posts[i]; 88 | const username = usernames.get(post.user_id) || post.user_id; 89 | 90 | console.log(`[${i + 1}] @${username} - ${new Date(post.create_at).toLocaleString()}`); 91 | console.log(post.message); 92 | console.log("-------------------------------------------\n"); 93 | } 94 | 95 | } catch (error) { 96 | console.error("Error:", error); 97 | process.exit(1); 98 | } 99 | } 100 | 101 | main(); 102 | -------------------------------------------------------------------------------- /src/tools/users.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MattermostClient } from "../client.js"; 3 | import { GetUsersArgs, GetUserProfileArgs } from "../types.js"; 4 | 5 | // Tool definition for getting users 6 | export const getUsersTool: Tool = { 7 | name: "mattermost_get_users", 8 | description: "Get a list of users in the Mattermost workspace with pagination", 9 | inputSchema: { 10 | type: "object", 11 | properties: { 12 | limit: { 13 | type: "number", 14 | description: "Maximum number of users to return (default 100, max 200)", 15 | default: 100, 16 | }, 17 | page: { 18 | type: "number", 19 | description: "Page number for pagination (starting from 0)", 20 | default: 0, 21 | }, 22 | }, 23 | }, 24 | }; 25 | 26 | // Tool definition for getting user profile 27 | export const getUserProfileTool: Tool = { 28 | name: "mattermost_get_user_profile", 29 | description: "Get detailed profile information for a specific user", 30 | inputSchema: { 31 | type: "object", 32 | properties: { 33 | user_id: { 34 | type: "string", 35 | description: "The ID of the user", 36 | }, 37 | }, 38 | required: ["user_id"], 39 | }, 40 | }; 41 | 42 | // Tool handler for getting users 43 | export async function handleGetUsers( 44 | client: MattermostClient, 45 | args: GetUsersArgs 46 | ) { 47 | const limit = args.limit || 100; 48 | const page = args.page || 0; 49 | 50 | try { 51 | const response = await client.getUsers(limit, page); 52 | 53 | // Format the response for better readability 54 | const formattedUsers = response.users.map(user => ({ 55 | id: user.id, 56 | username: user.username, 57 | email: user.email, 58 | first_name: user.first_name, 59 | last_name: user.last_name, 60 | nickname: user.nickname, 61 | position: user.position, 62 | roles: user.roles, 63 | is_bot: user.is_bot, 64 | })); 65 | 66 | return { 67 | content: [ 68 | { 69 | type: "text", 70 | text: JSON.stringify({ 71 | users: formattedUsers, 72 | total_count: response.total_count, 73 | page: page, 74 | per_page: limit, 75 | }, null, 2), 76 | }, 77 | ], 78 | }; 79 | } catch (error) { 80 | console.error("Error getting users:", error); 81 | return { 82 | content: [ 83 | { 84 | type: "text", 85 | text: JSON.stringify({ 86 | error: error instanceof Error ? error.message : String(error), 87 | }), 88 | }, 89 | ], 90 | isError: true, 91 | }; 92 | } 93 | } 94 | 95 | // Tool handler for getting user profile 96 | export async function handleGetUserProfile( 97 | client: MattermostClient, 98 | args: GetUserProfileArgs 99 | ) { 100 | const { user_id } = args; 101 | 102 | try { 103 | const user = await client.getUserProfile(user_id); 104 | 105 | // Format the response for better readability 106 | const formattedUser = { 107 | id: user.id, 108 | username: user.username, 109 | email: user.email, 110 | first_name: user.first_name, 111 | last_name: user.last_name, 112 | nickname: user.nickname, 113 | position: user.position, 114 | roles: user.roles, 115 | locale: user.locale, 116 | timezone: user.timezone, 117 | is_bot: user.is_bot, 118 | bot_description: user.bot_description, 119 | last_picture_update: user.last_picture_update, 120 | create_at: new Date(user.create_at).toISOString(), 121 | update_at: new Date(user.update_at).toISOString(), 122 | }; 123 | 124 | return { 125 | content: [ 126 | { 127 | type: "text", 128 | text: JSON.stringify(formattedUser, null, 2), 129 | }, 130 | ], 131 | }; 132 | } catch (error) { 133 | console.error("Error getting user profile:", error); 134 | return { 135 | content: [ 136 | { 137 | type: "text", 138 | text: JSON.stringify({ 139 | error: error instanceof Error ? error.message : String(error), 140 | }), 141 | }, 142 | ], 143 | isError: true, 144 | }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /analyze-channel.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { MattermostClient } from './build/client.js'; 4 | 5 | // Get command line arguments 6 | const args = process.argv.slice(2); 7 | 8 | if (args.length === 0) { 9 | console.log("Usage: node analyze-channel.js [message-count]"); 10 | console.log("Example: node analyze-channel.js town-square 20"); 11 | process.exit(1); 12 | } 13 | 14 | const channelName = args[0]; 15 | const messageCount = parseInt(args[1] || '50', 10); // Default to 50 messages 16 | 17 | async function main() { 18 | try { 19 | // Initialize Mattermost client 20 | const client = new MattermostClient(); 21 | console.log(`Analyzing channel: "${channelName}"\n`); 22 | 23 | // Get channels to find the specified channel ID 24 | const channelsResponse = await client.getChannels(100, 0); 25 | 26 | // Find the specified channel 27 | let targetChannelId = null; 28 | let targetChannel = null; 29 | 30 | for (const channel of channelsResponse.channels) { 31 | if (channel.name === channelName) { 32 | targetChannelId = channel.id; 33 | targetChannel = channel; 34 | break; 35 | } 36 | } 37 | 38 | if (!targetChannelId) { 39 | console.error(`Could not find channel: ${channelName}`); 40 | console.log("Available channels:"); 41 | channelsResponse.channels.forEach(channel => { 42 | console.log(`- ${channel.name} (${channel.display_name})`); 43 | }); 44 | process.exit(1); 45 | } 46 | 47 | console.log(`Channel: ${targetChannel.display_name} (${targetChannel.name})`); 48 | if (targetChannel.purpose) { 49 | console.log(`Purpose: ${targetChannel.purpose}`); 50 | } 51 | console.log(`Reported message count: ${targetChannel.total_msg_count}`); 52 | console.log("-------------------------------------------\n"); 53 | 54 | // Get posts from the channel 55 | const postsResponse = await client.getPostsForChannel(targetChannelId, messageCount, 0); 56 | 57 | // Get the posts 58 | const posts = Object.values(postsResponse.posts || {}); 59 | if (posts.length === 0) { 60 | console.log(`No posts found in ${channelName} channel`); 61 | process.exit(0); 62 | } 63 | 64 | // Sort posts by create_at (newest first) 65 | posts.sort((a, b) => b.create_at - a.create_at); 66 | 67 | // Create a map to store usernames 68 | const usernames = new Map(); 69 | 70 | // Get usernames for all user IDs 71 | for (const post of posts) { 72 | if (!usernames.has(post.user_id)) { 73 | try { 74 | const userProfile = await client.getUserProfile(post.user_id); 75 | usernames.set(post.user_id, userProfile.username); 76 | } catch (error) { 77 | usernames.set(post.user_id, post.user_id); // Use ID as fallback 78 | } 79 | } 80 | } 81 | 82 | // Analyze posts 83 | const userMessages = posts.filter(post => !post.message.includes('joined the channel')); 84 | const systemMessages = posts.filter(post => post.message.includes('joined the channel')); 85 | const messagesByUser = new Map(); 86 | 87 | // Count messages by user 88 | for (const post of userMessages) { 89 | const username = usernames.get(post.user_id) || post.user_id; 90 | if (!messagesByUser.has(username)) { 91 | messagesByUser.set(username, []); 92 | } 93 | messagesByUser.get(username).push(post); 94 | } 95 | 96 | // Display statistics 97 | console.log(`Total messages found: ${posts.length}`); 98 | console.log(`User messages: ${userMessages.length}`); 99 | console.log(`System messages: ${systemMessages.length}`); 100 | console.log("\nMessages by user:"); 101 | 102 | for (const [username, userPosts] of messagesByUser.entries()) { 103 | console.log(`- @${username}: ${userPosts.length} messages`); 104 | } 105 | 106 | console.log("\nLatest messages:"); 107 | console.log("-------------------------------------------"); 108 | 109 | // Display the latest 5 posts 110 | for (let i = 0; i < Math.min(5, posts.length); i++) { 111 | const post = posts[i]; 112 | const username = usernames.get(post.user_id) || post.user_id; 113 | 114 | console.log(`[${i + 1}] @${username} - ${new Date(post.create_at).toLocaleString()}`); 115 | console.log(post.message); 116 | console.log("-------------------------------------------"); 117 | } 118 | 119 | } catch (error) { 120 | console.error("Error:", error); 121 | process.exit(1); 122 | } 123 | } 124 | 125 | main(); 126 | -------------------------------------------------------------------------------- /src/tools/channels.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MattermostClient } from "../client.js"; 3 | import { ListChannelsArgs, GetChannelHistoryArgs } from "../types.js"; 4 | 5 | // Tool definition for listing channels 6 | export const listChannelsTool: Tool = { 7 | name: "mattermost_list_channels", 8 | description: "List public channels in the Mattermost workspace with pagination", 9 | inputSchema: { 10 | type: "object", 11 | properties: { 12 | limit: { 13 | type: "number", 14 | description: "Maximum number of channels to return (default 100, max 200)", 15 | default: 100, 16 | }, 17 | page: { 18 | type: "number", 19 | description: "Page number for pagination (starting from 0)", 20 | default: 0, 21 | }, 22 | }, 23 | }, 24 | }; 25 | 26 | // Tool definition for getting channel history 27 | export const getChannelHistoryTool: Tool = { 28 | name: "mattermost_get_channel_history", 29 | description: "Get recent messages from a Mattermost channel", 30 | inputSchema: { 31 | type: "object", 32 | properties: { 33 | channel_id: { 34 | type: "string", 35 | description: "The ID of the channel", 36 | }, 37 | limit: { 38 | type: "number", 39 | description: "Number of messages to retrieve (default 30)", 40 | default: 30, 41 | }, 42 | page: { 43 | type: "number", 44 | description: "Page number for pagination (starting from 0)", 45 | default: 0, 46 | }, 47 | }, 48 | required: ["channel_id"], 49 | }, 50 | }; 51 | 52 | // Tool handler for listing channels 53 | export async function handleListChannels( 54 | client: MattermostClient, 55 | args: ListChannelsArgs 56 | ) { 57 | const limit = args.limit || 100; 58 | const page = args.page || 0; 59 | 60 | try { 61 | const response = await client.getChannels(limit, page); 62 | 63 | // Check if response.channels exists 64 | if (!response || !response.channels) { 65 | console.error("API response missing channels array:", response); 66 | return { 67 | content: [ 68 | { 69 | type: "text", 70 | text: JSON.stringify({ 71 | error: "API response missing channels array", 72 | raw_response: response 73 | }, null, 2), 74 | }, 75 | ], 76 | isError: true, 77 | }; 78 | } 79 | 80 | // Format the response for better readability 81 | const formattedChannels = response.channels.map(channel => ({ 82 | id: channel.id, 83 | name: channel.name, 84 | display_name: channel.display_name, 85 | type: channel.type, 86 | purpose: channel.purpose, 87 | header: channel.header, 88 | total_msg_count: channel.total_msg_count, 89 | })); 90 | 91 | return { 92 | content: [ 93 | { 94 | type: "text", 95 | text: JSON.stringify({ 96 | channels: formattedChannels, 97 | total_count: response.total_count || 0, 98 | page: page, 99 | per_page: limit, 100 | }, null, 2), 101 | }, 102 | ], 103 | }; 104 | } catch (error) { 105 | console.error("Error listing channels:", error); 106 | return { 107 | content: [ 108 | { 109 | type: "text", 110 | text: JSON.stringify({ 111 | error: error instanceof Error ? error.message : String(error), 112 | }), 113 | }, 114 | ], 115 | isError: true, 116 | }; 117 | } 118 | } 119 | 120 | // Tool handler for getting channel history 121 | export async function handleGetChannelHistory( 122 | client: MattermostClient, 123 | args: GetChannelHistoryArgs 124 | ) { 125 | const { channel_id, limit = 30, page = 0 } = args; 126 | 127 | try { 128 | const response = await client.getPostsForChannel(channel_id, limit, page); 129 | 130 | // Format the posts for better readability 131 | const formattedPosts = response.order.map(postId => { 132 | const post = response.posts[postId]; 133 | return { 134 | id: post.id, 135 | user_id: post.user_id, 136 | message: post.message, 137 | create_at: new Date(post.create_at).toISOString(), 138 | reply_count: post.reply_count, 139 | root_id: post.root_id || null, 140 | }; 141 | }); 142 | 143 | return { 144 | content: [ 145 | { 146 | type: "text", 147 | text: JSON.stringify({ 148 | posts: formattedPosts, 149 | has_next: !!response.next_post_id, 150 | has_prev: !!response.prev_post_id, 151 | page: page, 152 | per_page: limit, 153 | }, null, 2), 154 | }, 155 | ], 156 | }; 157 | } catch (error) { 158 | console.error("Error getting channel history:", error); 159 | return { 160 | content: [ 161 | { 162 | type: "text", 163 | text: JSON.stringify({ 164 | error: error instanceof Error ? error.message : String(error), 165 | }), 166 | }, 167 | ], 168 | isError: true, 169 | }; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { loadConfig } from './config.js'; 3 | import { 4 | Channel, 5 | Post, 6 | User, 7 | UserProfile, 8 | Reaction, 9 | PostsResponse, 10 | ChannelsResponse, 11 | UsersResponse 12 | } from './types.js'; 13 | 14 | export class MattermostClient { 15 | private baseUrl: string; 16 | private headers: Record; 17 | private teamId: string; 18 | 19 | constructor() { 20 | const config = loadConfig(); 21 | this.baseUrl = config.mattermostUrl; 22 | this.teamId = config.teamId; 23 | this.headers = { 24 | 'Authorization': `Bearer ${config.token}`, 25 | 'Content-Type': 'application/json' 26 | }; 27 | } 28 | 29 | // Channel-related methods 30 | async getChannels(limit: number = 100, page: number = 0): Promise { 31 | const url = new URL(`${this.baseUrl}/teams/${this.teamId}/channels`); 32 | url.searchParams.append('page', page.toString()); 33 | url.searchParams.append('per_page', limit.toString()); 34 | 35 | console.error(`Fetching channels from URL: ${url.toString()}`); 36 | console.error(`Using headers: ${JSON.stringify(this.headers)}`); 37 | 38 | try { 39 | const response = await fetch(url.toString(), { headers: this.headers }); 40 | 41 | console.error(`Response status: ${response.status} ${response.statusText}`); 42 | 43 | if (!response.ok) { 44 | const errorText = await response.text(); 45 | console.error(`Error response body: ${errorText}`); 46 | throw new Error(`Failed to get channels: ${response.status} ${response.statusText} - ${errorText}`); 47 | } 48 | 49 | // The API returns an array of channels, but our ChannelsResponse type expects an object 50 | // with a channels property, so we need to transform the response 51 | const channelsArray = await response.json(); 52 | 53 | console.error(`Response data type: ${typeof channelsArray}, isArray: ${Array.isArray(channelsArray)}`); 54 | 55 | // Check if the response is an array (as expected from the API) 56 | if (Array.isArray(channelsArray)) { 57 | return { 58 | channels: channelsArray, 59 | total_count: channelsArray.length 60 | }; 61 | } 62 | 63 | // If it's already in the expected format, return it as is 64 | return channelsArray as ChannelsResponse; 65 | } catch (error) { 66 | console.error(`Error fetching channels: ${error instanceof Error ? error.message : String(error)}`); 67 | throw error; 68 | } 69 | } 70 | 71 | async getChannel(channelId: string): Promise { 72 | const url = `${this.baseUrl}/channels/${channelId}`; 73 | const response = await fetch(url, { headers: this.headers }); 74 | 75 | if (!response.ok) { 76 | throw new Error(`Failed to get channel: ${response.status} ${response.statusText}`); 77 | } 78 | 79 | return response.json() as Promise; 80 | } 81 | 82 | // Post-related methods 83 | async createPost(channelId: string, message: string, rootId?: string): Promise { 84 | const url = `${this.baseUrl}/posts`; 85 | const body = { 86 | channel_id: channelId, 87 | message, 88 | root_id: rootId || '' 89 | }; 90 | 91 | const response = await fetch(url, { 92 | method: 'POST', 93 | headers: this.headers, 94 | body: JSON.stringify(body) 95 | }); 96 | 97 | if (!response.ok) { 98 | throw new Error(`Failed to create post: ${response.status} ${response.statusText}`); 99 | } 100 | 101 | return response.json() as Promise; 102 | } 103 | 104 | async getPostsForChannel(channelId: string, limit: number = 30, page: number = 0): Promise { 105 | const url = new URL(`${this.baseUrl}/channels/${channelId}/posts`); 106 | url.searchParams.append('page', page.toString()); 107 | url.searchParams.append('per_page', limit.toString()); 108 | 109 | const response = await fetch(url.toString(), { headers: this.headers }); 110 | 111 | if (!response.ok) { 112 | throw new Error(`Failed to get posts: ${response.status} ${response.statusText}`); 113 | } 114 | 115 | return response.json() as Promise; 116 | } 117 | 118 | async getPost(postId: string): Promise { 119 | const url = `${this.baseUrl}/posts/${postId}`; 120 | const response = await fetch(url, { headers: this.headers }); 121 | 122 | if (!response.ok) { 123 | throw new Error(`Failed to get post: ${response.status} ${response.statusText}`); 124 | } 125 | 126 | return response.json() as Promise; 127 | } 128 | 129 | async getPostThread(postId: string): Promise { 130 | const url = `${this.baseUrl}/posts/${postId}/thread`; 131 | const response = await fetch(url, { headers: this.headers }); 132 | 133 | if (!response.ok) { 134 | throw new Error(`Failed to get post thread: ${response.status} ${response.statusText}`); 135 | } 136 | 137 | return response.json() as Promise; 138 | } 139 | 140 | // Reaction-related methods 141 | async addReaction(postId: string, emojiName: string): Promise { 142 | const url = `${this.baseUrl}/reactions`; 143 | const body = { 144 | post_id: postId, 145 | emoji_name: emojiName 146 | }; 147 | 148 | const response = await fetch(url, { 149 | method: 'POST', 150 | headers: this.headers, 151 | body: JSON.stringify(body) 152 | }); 153 | 154 | if (!response.ok) { 155 | throw new Error(`Failed to add reaction: ${response.status} ${response.statusText}`); 156 | } 157 | 158 | return response.json() as Promise; 159 | } 160 | 161 | // User-related methods 162 | async getUsers(limit: number = 100, page: number = 0): Promise { 163 | const url = new URL(`${this.baseUrl}/users`); 164 | url.searchParams.append('page', page.toString()); 165 | url.searchParams.append('per_page', limit.toString()); 166 | 167 | const response = await fetch(url.toString(), { headers: this.headers }); 168 | 169 | if (!response.ok) { 170 | throw new Error(`Failed to get users: ${response.status} ${response.statusText}`); 171 | } 172 | 173 | return response.json() as Promise; 174 | } 175 | 176 | async getUserProfile(userId: string): Promise { 177 | const url = `${this.baseUrl}/users/${userId}`; 178 | const response = await fetch(url, { headers: this.headers }); 179 | 180 | if (!response.ok) { 181 | throw new Error(`Failed to get user profile: ${response.status} ${response.statusText}`); 182 | } 183 | 184 | return response.json() as Promise; 185 | } 186 | 187 | // Direct message channel methods 188 | async createDirectMessageChannel(userId: string): Promise { 189 | const url = `${this.baseUrl}/channels/direct`; 190 | const body = [userId]; 191 | 192 | const response = await fetch(url, { 193 | method: 'POST', 194 | headers: this.headers, 195 | body: JSON.stringify(body) 196 | }); 197 | 198 | if (!response.ok) { 199 | throw new Error(`Failed to create direct message channel: ${response.status} ${response.statusText}`); 200 | } 201 | 202 | return response.json() as Promise; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/tools/messages.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MattermostClient } from "../client.js"; 3 | import { 4 | PostMessageArgs, 5 | ReplyToThreadArgs, 6 | AddReactionArgs, 7 | GetThreadRepliesArgs 8 | } from "../types.js"; 9 | 10 | // Tool definition for posting a message 11 | export const postMessageTool: Tool = { 12 | name: "mattermost_post_message", 13 | description: "Post a new message to a Mattermost channel", 14 | inputSchema: { 15 | type: "object", 16 | properties: { 17 | channel_id: { 18 | type: "string", 19 | description: "The ID of the channel to post to", 20 | }, 21 | message: { 22 | type: "string", 23 | description: "The message text to post", 24 | }, 25 | }, 26 | required: ["channel_id", "message"], 27 | }, 28 | }; 29 | 30 | // Tool definition for replying to a thread 31 | export const replyToThreadTool: Tool = { 32 | name: "mattermost_reply_to_thread", 33 | description: "Reply to a specific message thread in Mattermost", 34 | inputSchema: { 35 | type: "object", 36 | properties: { 37 | channel_id: { 38 | type: "string", 39 | description: "The ID of the channel containing the thread", 40 | }, 41 | post_id: { 42 | type: "string", 43 | description: "The ID of the parent message to reply to", 44 | }, 45 | message: { 46 | type: "string", 47 | description: "The reply text", 48 | }, 49 | }, 50 | required: ["channel_id", "post_id", "message"], 51 | }, 52 | }; 53 | 54 | // Tool definition for adding a reaction 55 | export const addReactionTool: Tool = { 56 | name: "mattermost_add_reaction", 57 | description: "Add a reaction emoji to a message", 58 | inputSchema: { 59 | type: "object", 60 | properties: { 61 | channel_id: { 62 | type: "string", 63 | description: "The ID of the channel containing the message", 64 | }, 65 | post_id: { 66 | type: "string", 67 | description: "The ID of the message to react to", 68 | }, 69 | emoji_name: { 70 | type: "string", 71 | description: "The name of the emoji reaction (without colons)", 72 | }, 73 | }, 74 | required: ["channel_id", "post_id", "emoji_name"], 75 | }, 76 | }; 77 | 78 | // Tool definition for getting thread replies 79 | export const getThreadRepliesTool: Tool = { 80 | name: "mattermost_get_thread_replies", 81 | description: "Get all replies in a message thread", 82 | inputSchema: { 83 | type: "object", 84 | properties: { 85 | channel_id: { 86 | type: "string", 87 | description: "The ID of the channel containing the thread", 88 | }, 89 | post_id: { 90 | type: "string", 91 | description: "The ID of the parent message", 92 | }, 93 | }, 94 | required: ["channel_id", "post_id"], 95 | }, 96 | }; 97 | 98 | // Tool handler for posting a message 99 | export async function handlePostMessage( 100 | client: MattermostClient, 101 | args: PostMessageArgs 102 | ) { 103 | const { channel_id, message } = args; 104 | 105 | try { 106 | const response = await client.createPost(channel_id, message); 107 | 108 | return { 109 | content: [ 110 | { 111 | type: "text", 112 | text: JSON.stringify({ 113 | id: response.id, 114 | channel_id: response.channel_id, 115 | message: response.message, 116 | create_at: new Date(response.create_at).toISOString(), 117 | }, null, 2), 118 | }, 119 | ], 120 | }; 121 | } catch (error) { 122 | console.error("Error posting message:", error); 123 | return { 124 | content: [ 125 | { 126 | type: "text", 127 | text: JSON.stringify({ 128 | error: error instanceof Error ? error.message : String(error), 129 | }), 130 | }, 131 | ], 132 | isError: true, 133 | }; 134 | } 135 | } 136 | 137 | // Tool handler for replying to a thread 138 | export async function handleReplyToThread( 139 | client: MattermostClient, 140 | args: ReplyToThreadArgs 141 | ) { 142 | const { channel_id, post_id, message } = args; 143 | 144 | try { 145 | const response = await client.createPost(channel_id, message, post_id); 146 | 147 | return { 148 | content: [ 149 | { 150 | type: "text", 151 | text: JSON.stringify({ 152 | id: response.id, 153 | channel_id: response.channel_id, 154 | root_id: response.root_id, 155 | message: response.message, 156 | create_at: new Date(response.create_at).toISOString(), 157 | }, null, 2), 158 | }, 159 | ], 160 | }; 161 | } catch (error) { 162 | console.error("Error replying to thread:", error); 163 | return { 164 | content: [ 165 | { 166 | type: "text", 167 | text: JSON.stringify({ 168 | error: error instanceof Error ? error.message : String(error), 169 | }), 170 | }, 171 | ], 172 | isError: true, 173 | }; 174 | } 175 | } 176 | 177 | // Tool handler for adding a reaction 178 | export async function handleAddReaction( 179 | client: MattermostClient, 180 | args: AddReactionArgs 181 | ) { 182 | const { post_id, emoji_name } = args; 183 | 184 | try { 185 | const response = await client.addReaction(post_id, emoji_name); 186 | 187 | return { 188 | content: [ 189 | { 190 | type: "text", 191 | text: JSON.stringify({ 192 | post_id: response.post_id, 193 | user_id: response.user_id, 194 | emoji_name: response.emoji_name, 195 | create_at: new Date(response.create_at).toISOString(), 196 | }, null, 2), 197 | }, 198 | ], 199 | }; 200 | } catch (error) { 201 | console.error("Error adding reaction:", error); 202 | return { 203 | content: [ 204 | { 205 | type: "text", 206 | text: JSON.stringify({ 207 | error: error instanceof Error ? error.message : String(error), 208 | }), 209 | }, 210 | ], 211 | isError: true, 212 | }; 213 | } 214 | } 215 | 216 | // Tool handler for getting thread replies 217 | export async function handleGetThreadReplies( 218 | client: MattermostClient, 219 | args: GetThreadRepliesArgs 220 | ) { 221 | const { post_id } = args; 222 | 223 | try { 224 | const response = await client.getPostThread(post_id); 225 | 226 | // Format the posts for better readability 227 | const formattedPosts = response.order.map(postId => { 228 | const post = response.posts[postId]; 229 | return { 230 | id: post.id, 231 | user_id: post.user_id, 232 | message: post.message, 233 | create_at: new Date(post.create_at).toISOString(), 234 | root_id: post.root_id || null, 235 | }; 236 | }); 237 | 238 | return { 239 | content: [ 240 | { 241 | type: "text", 242 | text: JSON.stringify({ 243 | posts: formattedPosts, 244 | root_post: response.posts[post_id] ? { 245 | id: response.posts[post_id].id, 246 | user_id: response.posts[post_id].user_id, 247 | message: response.posts[post_id].message, 248 | create_at: new Date(response.posts[post_id].create_at).toISOString(), 249 | } : null, 250 | }, null, 2), 251 | }, 252 | ], 253 | }; 254 | } catch (error) { 255 | console.error("Error getting thread replies:", error); 256 | return { 257 | content: [ 258 | { 259 | type: "text", 260 | text: JSON.stringify({ 261 | error: error instanceof Error ? error.message : String(error), 262 | }), 263 | }, 264 | ], 265 | isError: true, 266 | }; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { 5 | CallToolRequest, 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | } from "@modelcontextprotocol/sdk/types.js"; 9 | import { tools, executeTool, setTopicMonitorInstance } from "./tools/index.js"; 10 | import { MattermostClient } from "./client.js"; 11 | import { loadConfig } from "./config.js"; 12 | import { TopicMonitor } from "./monitor/index.js"; 13 | import * as http from 'http'; 14 | 15 | async function main() { 16 | // Check for command-line arguments 17 | const runMonitoringImmediately = process.argv.includes('--run-monitoring'); 18 | const exitAfterMonitoring = process.argv.includes('--exit-after-monitoring'); 19 | 20 | console.error("Starting Mattermost MCP Server..."); 21 | 22 | // Load configuration 23 | const config = loadConfig(); 24 | 25 | // Initialize Mattermost client 26 | let client: MattermostClient; 27 | try { 28 | client = new MattermostClient(); 29 | console.error("Successfully initialized Mattermost client"); 30 | } catch (error) { 31 | console.error("Failed to initialize Mattermost client:", error); 32 | process.exit(1); 33 | } 34 | 35 | // Initialize and start topic monitor if enabled 36 | let topicMonitor: TopicMonitor | null = null; 37 | if (config.monitoring?.enabled) { 38 | try { 39 | console.error("Initializing topic monitor..."); 40 | topicMonitor = new TopicMonitor(client, config.monitoring); 41 | // Set the TopicMonitor instance in the monitoring tool 42 | setTopicMonitorInstance(topicMonitor); 43 | await topicMonitor.start(); 44 | console.error("Topic monitor started successfully"); 45 | } catch (error) { 46 | console.error("Failed to initialize topic monitor:", error); 47 | // Continue without monitoring 48 | } 49 | } else { 50 | console.error("Topic monitoring is disabled in configuration"); 51 | } 52 | 53 | // Initialize MCP server 54 | const server = new Server( 55 | { 56 | name: "Mattermost MCP Server", 57 | version: "1.0.0", 58 | }, 59 | { 60 | capabilities: { 61 | tools: {}, 62 | }, 63 | } 64 | ); 65 | 66 | // Register tool listing handler 67 | server.setRequestHandler(ListToolsRequestSchema, async () => { 68 | console.error("Received ListToolsRequest"); 69 | return { 70 | tools, 71 | }; 72 | }); 73 | 74 | // Register tool execution handler 75 | server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { 76 | console.error(`Received CallToolRequest for tool: ${request.params.name}`); 77 | 78 | try { 79 | if (!request.params.arguments) { 80 | throw new Error("No arguments provided"); 81 | } 82 | 83 | return await executeTool(client, request.params.name, request.params.arguments); 84 | } catch (error) { 85 | console.error("Error executing tool:", error); 86 | return { 87 | content: [ 88 | { 89 | type: "text", 90 | text: JSON.stringify({ 91 | error: error instanceof Error ? error.message : String(error), 92 | }), 93 | }, 94 | ], 95 | isError: true, 96 | }; 97 | } 98 | }); 99 | 100 | // Connect to transport 101 | const transport = new StdioServerTransport(); 102 | console.error("Connecting server to transport..."); 103 | await server.connect(transport); 104 | 105 | console.error("Mattermost MCP Server running on stdio"); 106 | 107 | // Run monitoring immediately if requested 108 | if (runMonitoringImmediately && topicMonitor) { 109 | console.error("Running monitoring immediately as requested..."); 110 | try { 111 | await topicMonitor.runNow(); 112 | 113 | // Exit after monitoring if requested 114 | if (exitAfterMonitoring) { 115 | console.error("Exiting after monitoring as requested..."); 116 | process.exit(0); 117 | } 118 | } catch (error) { 119 | console.error("Error running monitoring immediately:", error); 120 | 121 | // Exit with error code if exit-after-monitoring is set 122 | if (exitAfterMonitoring) { 123 | console.error("Exiting with error..."); 124 | process.exit(1); 125 | } 126 | } 127 | } 128 | 129 | // Set up command-line interface 130 | process.stdin.setEncoding('utf8'); 131 | console.error("Setting up command-line interface..."); 132 | 133 | process.stdin.on('data', async (data) => { 134 | console.error(`Received input: "${data.toString().trim()}"`); 135 | const input = data.toString().trim().toLowerCase(); 136 | 137 | if (input === 'run' || input === 'monitor' || input === 'check') { 138 | console.error("Command received: Running monitoring process..."); 139 | if (topicMonitor) { 140 | try { 141 | console.error("Calling topicMonitor.runNow()..."); 142 | await topicMonitor.runNow(); 143 | console.error("Monitoring process completed successfully"); 144 | } catch (error) { 145 | console.error("Error running monitoring process:", error); 146 | } 147 | } else { 148 | console.error("Monitoring is not enabled or initialized"); 149 | } 150 | } else if (input === 'help') { 151 | console.error("Available commands:"); 152 | console.error(" run, monitor, check - Run the monitoring process immediately"); 153 | console.error(" help - Show this help message"); 154 | console.error(" exit - Shutdown the server"); 155 | } else if (input === 'exit' || input === 'quit') { 156 | console.error("Shutting down server..."); 157 | process.exit(0); 158 | } else { 159 | console.error("Unknown command. Type 'help' for available commands"); 160 | } 161 | }); 162 | 163 | // Resume stdin to capture input 164 | process.stdin.resume(); 165 | 166 | console.error("Command interface ready. Type 'run' to trigger monitoring, 'help' for more commands"); 167 | 168 | // Set up HTTP server for remote triggering of monitoring 169 | const httpPort = 3456; // Choose a port that's likely to be available 170 | const httpServer = http.createServer(async (req, res) => { 171 | // Set CORS headers 172 | res.setHeader('Access-Control-Allow-Origin', '*'); 173 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 174 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); 175 | 176 | // Handle preflight requests 177 | if (req.method === 'OPTIONS') { 178 | res.writeHead(204); 179 | res.end(); 180 | return; 181 | } 182 | 183 | // Only respond to specific paths 184 | if (req.url === '/run-monitoring') { 185 | console.error("Received HTTP request to run monitoring"); 186 | 187 | if (topicMonitor) { 188 | try { 189 | console.error("Running monitoring via HTTP request..."); 190 | await topicMonitor.runNow(); 191 | console.error("Monitoring completed successfully"); 192 | 193 | res.writeHead(200, { 'Content-Type': 'application/json' }); 194 | res.end(JSON.stringify({ success: true, message: 'Monitoring completed successfully' })); 195 | } catch (error) { 196 | console.error("Error running monitoring:", error); 197 | 198 | res.writeHead(500, { 'Content-Type': 'application/json' }); 199 | res.end(JSON.stringify({ 200 | success: false, 201 | error: error instanceof Error ? error.message : String(error) 202 | })); 203 | } 204 | } else { 205 | console.error("Monitoring is not enabled or initialized"); 206 | 207 | res.writeHead(400, { 'Content-Type': 'application/json' }); 208 | res.end(JSON.stringify({ 209 | success: false, 210 | error: 'Monitoring is not enabled or initialized' 211 | })); 212 | } 213 | } else if (req.url === '/status') { 214 | // Status endpoint 215 | res.writeHead(200, { 'Content-Type': 'application/json' }); 216 | res.end(JSON.stringify({ 217 | status: 'running', 218 | monitoring: { 219 | enabled: !!topicMonitor, 220 | running: topicMonitor ? topicMonitor.isRunning() : false 221 | } 222 | })); 223 | } else { 224 | // Not found 225 | res.writeHead(404, { 'Content-Type': 'application/json' }); 226 | res.end(JSON.stringify({ error: 'Not found' })); 227 | } 228 | }); 229 | 230 | // Start the HTTP server 231 | httpServer.listen(httpPort, () => { 232 | console.error(`HTTP server listening on port ${httpPort}`); 233 | console.error(`To trigger monitoring, visit http://localhost:${httpPort}/run-monitoring`); 234 | console.error(`To check status, visit http://localhost:${httpPort}/status`); 235 | }); 236 | 237 | // Handle process termination 238 | process.on('SIGINT', () => { 239 | console.error("Shutting down Mattermost MCP Server..."); 240 | if (topicMonitor) { 241 | topicMonitor.stop(); 242 | } 243 | process.exit(0); 244 | }); 245 | } 246 | 247 | main().catch((error) => { 248 | console.error("Fatal error in main():", error); 249 | process.exit(1); 250 | }); 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mattermost MCP Server 2 | 3 | MCP Server for the Mattermost API, enabling Claude and other MCP clients to interact with Mattermost workspaces. 4 | 5 | ## Features 6 | 7 | This MCP server provides tools for interacting with Mattermost, including: 8 | 9 | ### Topic Monitoring 10 | 11 | The server includes a topic monitoring system that can: 12 | - Monitor specified channels for messages containing topics of interest 13 | - Run on a configurable schedule (using cron syntax) 14 | - Send notifications when relevant topics are discussed 15 | - Mention you in a specified channel when topics are found 16 | 17 | ### Channel Tools 18 | - `mattermost_list_channels`: List public channels in the workspace 19 | - `mattermost_get_channel_history`: Get recent messages from a channel 20 | 21 | ### Message Tools 22 | - `mattermost_post_message`: Post a new message to a channel 23 | - `mattermost_reply_to_thread`: Reply to a specific message thread 24 | - `mattermost_add_reaction`: Add an emoji reaction to a message 25 | - `mattermost_get_thread_replies`: Get all replies in a thread 26 | 27 | ### Monitoring Tools 28 | - `mattermost_run_monitoring`: Trigger the topic monitoring process immediately 29 | 30 | ### User Tools 31 | - `mattermost_get_users`: Get a list of users in the workspace 32 | - `mattermost_get_user_profile`: Get detailed profile information for a user 33 | 34 | ## Setup 35 | 36 | 1. Clone this repository: 37 | ```bash 38 | git clone https://github.com/yourusername/mattermost-mcp.git 39 | cd mattermost-mcp 40 | ``` 41 | 42 | 2. Install dependencies: 43 | ```bash 44 | npm install 45 | ``` 46 | 47 | 3. Configure the server: 48 | 49 | The repository includes a `config.json` file with placeholder values. For your actual configuration, create a `config.local.json` file (which is gitignored) with your real credentials: 50 | 51 | ```json 52 | { 53 | "mattermostUrl": "https://your-mattermost-instance.com/api/v4", 54 | "token": "your-personal-access-token", 55 | "teamId": "your-team-id", 56 | "monitoring": { 57 | "enabled": false, 58 | "schedule": "*/15 * * * *", 59 | "channels": ["town-square", "off-topic"], 60 | "topics": ["tv series", "champions league"], 61 | "messageLimit": 50 62 | } 63 | } 64 | ``` 65 | 66 | This approach keeps your real credentials out of the repository while maintaining the template for others. 67 | 68 | 4. Build the server: 69 | ```bash 70 | npm run build 71 | ``` 72 | 73 | 5. Run the server: 74 | ```bash 75 | npm start 76 | ``` 77 | 78 | ## Topic Monitoring Configuration 79 | 80 | The monitoring system can be configured with the following options: 81 | 82 | - `enabled` (boolean): Whether monitoring is enabled 83 | - `schedule` (string): Cron expression for when to check for new messages (e.g., "*/15 * * * *" for every 15 minutes) 84 | - `channels` (string[]): Array of channel names to monitor 85 | - `topics` (string[]): Array of topics to look for in messages 86 | - `messageLimit` (number): Number of recent messages to analyze per check 87 | - `notificationChannelId` (string, optional): Channel ID where notifications will be sent. If not provided, the system will automatically use a direct message channel. 88 | - `userId` (string, optional): Your user ID for mentions in notifications. If not provided, the system will automatically detect the current user. 89 | 90 | To enable monitoring, set `enabled` to `true` in your `config.local.json` file. 91 | 92 | ### Running Monitoring Manually 93 | 94 | You can trigger the monitoring process manually in several ways: 95 | 96 | 1. **Using the provided scripts**: 97 | - `./run-monitoring-http.sh` - Triggers monitoring via HTTP without restarting the server (recommended) 98 | - `./run-monitoring.sh` - Starts a new server instance with monitoring enabled 99 | - `./trigger-monitoring.sh` - Runs the monitoring process and exits (useful for cron jobs) 100 | - `./view-channel-messages.js [count]` - View the last messages in a channel 101 | - `./analyze-channel.js [count]` - Analyze message statistics in a channel 102 | - `./get-last-message.js ` - Get the last message from a channel 103 | 104 | 2. **Using the command-line interface (CLI)**: 105 | - While the server is running, simply type one of these commands in the terminal: 106 | - `run` - Run the monitoring process 107 | - `monitor` - Same as `run` 108 | - `check` - Same as `run` 109 | - Other available commands: 110 | - `help` - Show available commands 111 | - `exit` - Shutdown the server 112 | 113 | 3. **Using the MCP tool**: 114 | - Use the `mattermost_run_monitoring` tool through the MCP interface 115 | - This will immediately check all configured channels for your topics of interest 116 | 117 | 4. **Using the command-line flags**: 118 | - Start the server with the `--run-monitoring` flag: 119 | ```bash 120 | npm start -- --run-monitoring 121 | ``` 122 | - This will run the monitoring process immediately after the server starts 123 | - Add `--exit-after-monitoring` to exit after the monitoring process completes: 124 | ```bash 125 | npm start -- --run-monitoring --exit-after-monitoring 126 | ``` 127 | - This is useful for running the monitoring process from cron jobs 128 | 129 | ## Tool Details 130 | 131 | ### Channel Tools 132 | 133 | #### `mattermost_list_channels` 134 | - List public channels in the workspace 135 | - Optional inputs: 136 | - `limit` (number, default: 100, max: 200): Maximum number of channels to return 137 | - `page` (number, default: 0): Page number for pagination 138 | - Returns: List of channels with their IDs and information 139 | 140 | #### `mattermost_get_channel_history` 141 | - Get recent messages from a channel 142 | - Required inputs: 143 | - `channel_id` (string): The ID of the channel 144 | - Optional inputs: 145 | - `limit` (number, default: 30): Number of messages to retrieve 146 | - `page` (number, default: 0): Page number for pagination 147 | - Returns: List of messages with their content and metadata 148 | 149 | ### Message Tools 150 | 151 | #### `mattermost_post_message` 152 | - Post a new message to a Mattermost channel 153 | - Required inputs: 154 | - `channel_id` (string): The ID of the channel to post to 155 | - `message` (string): The message text to post 156 | - Returns: Message posting confirmation and ID 157 | 158 | #### `mattermost_reply_to_thread` 159 | - Reply to a specific message thread 160 | - Required inputs: 161 | - `channel_id` (string): The channel containing the thread 162 | - `post_id` (string): ID of the parent message 163 | - `message` (string): The reply text 164 | - Returns: Reply confirmation and ID 165 | 166 | #### `mattermost_add_reaction` 167 | - Add an emoji reaction to a message 168 | - Required inputs: 169 | - `channel_id` (string): The channel containing the message 170 | - `post_id` (string): Message ID to react to 171 | - `emoji_name` (string): Emoji name without colons 172 | - Returns: Reaction confirmation 173 | 174 | #### `mattermost_get_thread_replies` 175 | - Get all replies in a message thread 176 | - Required inputs: 177 | - `channel_id` (string): The channel containing the thread 178 | - `post_id` (string): ID of the parent message 179 | - Returns: List of replies with their content and metadata 180 | 181 | ### User Tools 182 | 183 | #### `mattermost_get_users` 184 | - Get list of workspace users with basic profile information 185 | - Optional inputs: 186 | - `limit` (number, default: 100, max: 200): Maximum users to return 187 | - `page` (number, default: 0): Page number for pagination 188 | - Returns: List of users with their basic profiles 189 | 190 | #### `mattermost_get_user_profile` 191 | - Get detailed profile information for a specific user 192 | - Required inputs: 193 | - `user_id` (string): The user's ID 194 | - Returns: Detailed user profile information 195 | 196 | ## Usage with Claude Desktop 197 | 198 | Add the following to your `claude_desktop_config.json`: 199 | 200 | ```json 201 | { 202 | "mcpServers": { 203 | "mattermost": { 204 | "command": "node", 205 | "args": [ 206 | "/path/to/mattermost-mcp/build/index.js" 207 | ] 208 | } 209 | } 210 | } 211 | ``` 212 | 213 | ## Troubleshooting 214 | 215 | If you encounter permission errors, verify that: 216 | 1. Your personal access token has the necessary permissions 217 | 2. The token is correctly copied to your configuration 218 | 3. The Mattermost URL and team ID are correct 219 | 220 | ## HTTP Endpoints 221 | 222 | The server exposes HTTP endpoints for remote control: 223 | 224 | - **Run Monitoring**: `http://localhost:3456/run-monitoring` 225 | - Triggers the monitoring process immediately 226 | - Returns JSON response with success/error information 227 | 228 | - **Check Status**: `http://localhost:3456/status` 229 | - Returns information about the server and monitoring status 230 | - Useful for health checks 231 | 232 | You can use these endpoints with curl or any HTTP client: 233 | ```bash 234 | # Trigger monitoring 235 | curl http://localhost:3456/run-monitoring 236 | 237 | # Check status 238 | curl http://localhost:3456/status 239 | ``` 240 | 241 | ## Utility Scripts 242 | 243 | ### run-monitoring-http.sh 244 | 245 | This script triggers the monitoring process via the HTTP endpoint: 246 | ```bash 247 | ./run-monitoring-http.sh 248 | ``` 249 | 250 | This is the recommended way to trigger monitoring manually as it: 251 | - Doesn't restart the server 252 | - Doesn't interfere with the scheduled monitoring 253 | - Works reliably from any terminal 254 | 255 | ### view-channel-messages.js 256 | 257 | This script allows you to view the most recent messages in any channel: 258 | 259 | ```bash 260 | # View messages in a channel (channel name is required) 261 | node view-channel-messages.js 262 | 263 | # View a specific number of messages 264 | node view-channel-messages.js 265 | 266 | # Example: View the last 10 messages in a channel 267 | node view-channel-messages.js general 10 268 | ``` 269 | 270 | The script will display: 271 | - Channel information (name, purpose, total message count) 272 | - The most recent messages with timestamps and usernames 273 | - If the channel doesn't exist, it will list all available channels 274 | 275 | ### analyze-channel.js 276 | 277 | This script provides detailed statistics about messages in a channel: 278 | 279 | ```bash 280 | # Analyze messages in a channel (channel name is required) 281 | node analyze-channel.js 282 | 283 | # Analyze a specific number of messages 284 | node analyze-channel.js 285 | 286 | # Example: Analyze the last 50 messages in a channel 287 | node analyze-channel.js general 50 288 | ``` 289 | 290 | The script will display: 291 | - Channel information and metadata 292 | - Total message count (including system messages) 293 | - Breakdown of user messages vs. system messages 294 | - Message count by user 295 | - The most recent messages in the channel 296 | 297 | ### get-last-message.js 298 | 299 | This script retrieves only the most recent message from a channel: 300 | 301 | ```bash 302 | # Get the last message from a channel (channel name is required) 303 | node get-last-message.js 304 | 305 | # Example: Get the last message from the general channel 306 | node get-last-message.js general 307 | ``` 308 | 309 | The script will display: 310 | - The sender's user ID and username 311 | - The timestamp of the message 312 | - The full message content 313 | 314 | ## License 315 | 316 | This MCP server is licensed under the MIT License. 317 | -------------------------------------------------------------------------------- /src/monitor/index.ts: -------------------------------------------------------------------------------- 1 | import { MattermostClient } from '../client.js'; 2 | import { MonitoringConfig } from '../config.js'; 3 | import { Channel, Post, User, UserProfile } from '../types.js'; 4 | import { findRelevantPosts, createNotificationMessage } from './analyzer.js'; 5 | import { Scheduler } from './scheduler.js'; 6 | 7 | /** 8 | * TopicMonitor class for monitoring channels for topics of interest 9 | */ 10 | export class TopicMonitor { 11 | private client: MattermostClient; 12 | private config: MonitoringConfig; 13 | private scheduler: Scheduler; 14 | private channelCache: Map = new Map(); // Map of channel names to IDs 15 | private currentUserId: string | null = null; 16 | private currentUsername: string | null = null; 17 | private directMessageChannelId: string | null = null; 18 | 19 | /** 20 | * Creates a new topic monitor 21 | * @param client Mattermost client 22 | * @param config Monitoring configuration 23 | */ 24 | constructor(client: MattermostClient, config: MonitoringConfig) { 25 | this.client = client; 26 | this.config = config; 27 | this.scheduler = new Scheduler(config, this.monitorChannels.bind(this)); 28 | } 29 | 30 | /** 31 | * Initializes the monitor by fetching necessary user information 32 | */ 33 | async initialize(): Promise { 34 | // If userId is not provided, fetch the current user's information 35 | if (!this.config.userId) { 36 | await this.fetchCurrentUser(); 37 | } else { 38 | this.currentUserId = this.config.userId; 39 | } 40 | 41 | // If notificationChannelId is not provided, create or find a direct message channel 42 | if (!this.config.notificationChannelId) { 43 | await this.createDirectMessageChannel(); 44 | } 45 | } 46 | 47 | /** 48 | * Fetches the current user's information 49 | */ 50 | private async fetchCurrentUser(): Promise { 51 | try { 52 | // Try to get the user ID from the response 53 | try { 54 | // Get the first page of users 55 | const response = await this.client.getUsers(100, 0); 56 | 57 | // If response is an array (not the expected UsersResponse format) 58 | if (Array.isArray(response)) { 59 | // Find a non-bot user (preferably sysadmin) 60 | for (const user of response) { 61 | if (user.username === 'sysadmin') { 62 | this.currentUserId = user.id; 63 | this.currentUsername = user.username; 64 | console.error(`Found sysadmin user: ${user.username} (${user.id})`); 65 | return; 66 | } 67 | } 68 | 69 | // If sysadmin not found, use any non-bot user 70 | for (const user of response) { 71 | if (!user.is_bot) { 72 | this.currentUserId = user.id; 73 | this.currentUsername = user.username; 74 | console.error(`Found user: ${user.username} (${user.id})`); 75 | return; 76 | } 77 | } 78 | 79 | // If no non-bot user found, use any user 80 | if (response.length > 0) { 81 | this.currentUserId = response[0].id; 82 | this.currentUsername = response[0].username; 83 | console.error(`Using first available user: ${response[0].username} (${response[0].id})`); 84 | return; 85 | } 86 | } else if (response && response.users && Array.isArray(response.users)) { 87 | // Standard format 88 | for (const user of response.users) { 89 | if (user.username === 'sysadmin') { 90 | this.currentUserId = user.id; 91 | this.currentUsername = user.username; 92 | console.error(`Found sysadmin user: ${user.username} (${user.id})`); 93 | return; 94 | } 95 | } 96 | 97 | for (const user of response.users) { 98 | if (!user.is_bot) { 99 | this.currentUserId = user.id; 100 | this.currentUsername = user.username; 101 | console.error(`Found user: ${user.username} (${user.id})`); 102 | return; 103 | } 104 | } 105 | } else { 106 | console.error('Response format unexpected:', response); 107 | } 108 | } catch (innerError) { 109 | console.error('Error getting users:', innerError); 110 | } 111 | 112 | // Fallback: Use the town-square channel's first post author 113 | try { 114 | // Get the town-square channel 115 | const channelsResponse = await this.client.getChannels(100, 0); 116 | let townSquareId = null; 117 | 118 | if (channelsResponse && channelsResponse.channels && Array.isArray(channelsResponse.channels)) { 119 | for (const channel of channelsResponse.channels) { 120 | if (channel.name === 'town-square') { 121 | townSquareId = channel.id; 122 | break; 123 | } 124 | } 125 | } 126 | 127 | if (townSquareId) { 128 | // Get posts from town-square 129 | const postsResponse = await this.client.getPostsForChannel(townSquareId, 1, 0); 130 | 131 | if (postsResponse && postsResponse.posts) { 132 | // Get the first post's user ID 133 | const posts = Object.values(postsResponse.posts); 134 | if (posts.length > 0) { 135 | this.currentUserId = posts[0].user_id; 136 | 137 | // Get the username for this user ID 138 | try { 139 | const userProfile = await this.client.getUserProfile(this.currentUserId); 140 | this.currentUsername = userProfile.username; 141 | console.error(`Found username for post author: ${this.currentUsername}`); 142 | } catch (profileError) { 143 | console.error('Error getting user profile:', profileError); 144 | this.currentUsername = 'user'; 145 | } 146 | 147 | console.error(`Using fallback user ID from post: ${this.currentUserId}`); 148 | return; 149 | } 150 | } 151 | } 152 | } catch (innerError) { 153 | console.error('Error using fallback method:', innerError); 154 | } 155 | 156 | // Final fallback: Use hardcoded values 157 | this.currentUserId = "system"; 158 | this.currentUsername = "system"; 159 | console.error(`Using hardcoded user ID: ${this.currentUserId}`); 160 | 161 | } catch (error) { 162 | console.error('Error fetching current user:', error); 163 | throw error; 164 | } 165 | } 166 | 167 | /** 168 | * Creates or finds a direct message channel for the current user 169 | */ 170 | private async createDirectMessageChannel(): Promise { 171 | try { 172 | if (!this.currentUserId) { 173 | throw new Error('Current user ID is not set'); 174 | } 175 | 176 | // Try to create a direct message channel with the user 177 | try { 178 | // First, try to find an existing DM channel 179 | const response = await this.client.getChannels(100, 0); 180 | 181 | if (response && response.channels && Array.isArray(response.channels)) { 182 | // Look for a direct message channel 183 | for (const channel of response.channels) { 184 | // Direct message channels have type 'D' 185 | if (channel.type === 'D') { 186 | this.directMessageChannelId = channel.id; 187 | console.error(`Found direct message channel: ${channel.id}`); 188 | return; 189 | } 190 | } 191 | } 192 | 193 | // If we couldn't find a DM channel, try to create one 194 | console.error('Could not find a direct message channel, attempting to create one...'); 195 | 196 | try { 197 | // Create a direct message channel with the current user 198 | const dmChannel = await this.client.createDirectMessageChannel(this.currentUserId); 199 | this.directMessageChannelId = dmChannel.id; 200 | console.error(`Created direct message channel: ${dmChannel.id}`); 201 | return; 202 | } catch (createError) { 203 | console.error('Error creating direct message channel:', createError); 204 | } 205 | 206 | // If creating a DM channel fails, use town-square as a fallback 207 | for (const channel of response.channels) { 208 | if (channel.name === 'town-square') { 209 | this.directMessageChannelId = channel.id; 210 | console.error(`Using town-square as fallback notification channel: ${channel.id}`); 211 | return; 212 | } 213 | } 214 | } catch (innerError) { 215 | console.error('Error finding/creating DM channel:', innerError); 216 | } 217 | 218 | throw new Error('Could not find a suitable notification channel'); 219 | } catch (error) { 220 | console.error('Error creating direct message channel:', error); 221 | throw error; 222 | } 223 | } 224 | 225 | /** 226 | * Starts the monitoring process 227 | */ 228 | async start(): Promise { 229 | if (!this.config.enabled) { 230 | console.error('Monitoring is disabled in configuration'); 231 | return; 232 | } 233 | 234 | // Initialize user information and notification channel 235 | await this.initialize(); 236 | 237 | this.scheduler.start(); 238 | } 239 | 240 | /** 241 | * Stops the monitoring process 242 | */ 243 | stop(): void { 244 | this.scheduler.stop(); 245 | } 246 | 247 | /** 248 | * Updates the monitoring configuration 249 | * @param config New monitoring configuration 250 | */ 251 | updateConfig(config: MonitoringConfig): void { 252 | this.config = config; 253 | this.scheduler.updateConfig(config); 254 | } 255 | 256 | /** 257 | * Checks if the monitor is running 258 | * @returns True if the monitor is running, false otherwise 259 | */ 260 | isRunning(): boolean { 261 | return this.scheduler.isRunning(); 262 | } 263 | 264 | /** 265 | * Runs the monitoring process immediately 266 | */ 267 | async runNow(): Promise { 268 | console.error("Running monitoring process immediately..."); 269 | await this.monitorChannels(); 270 | console.error("Immediate monitoring process completed."); 271 | } 272 | 273 | /** 274 | * Main monitoring function that checks channels for topics of interest 275 | */ 276 | private async monitorChannels(): Promise { 277 | try { 278 | // Get channel IDs for the channels we want to monitor 279 | await this.refreshChannelCache(); 280 | 281 | // Process each channel 282 | for (const channelName of this.config.channels) { 283 | await this.processChannel(channelName); 284 | } 285 | } catch (error) { 286 | console.error('Error in monitorChannels:', error); 287 | } 288 | } 289 | 290 | /** 291 | * Refreshes the cache of channel names to IDs 292 | */ 293 | private async refreshChannelCache(): Promise { 294 | try { 295 | // Get all channels 296 | const response = await this.client.getChannels(100, 0); 297 | 298 | // Update the cache 299 | for (const channel of response.channels) { 300 | this.channelCache.set(channel.name, channel.id); 301 | } 302 | } catch (error) { 303 | console.error('Error refreshing channel cache:', error); 304 | throw error; 305 | } 306 | } 307 | 308 | /** 309 | * Processes a single channel for topics of interest 310 | * @param channelName Name of the channel to process 311 | */ 312 | private async processChannel(channelName: string): Promise { 313 | try { 314 | // Get the channel ID 315 | const channelId = this.channelCache.get(channelName); 316 | if (!channelId) { 317 | console.error(`Channel not found: ${channelName}`); 318 | return; 319 | } 320 | 321 | // Get recent posts 322 | const postsResponse = await this.client.getPostsForChannel( 323 | channelId, 324 | this.config.messageLimit, 325 | 0 326 | ); 327 | 328 | // Convert posts object to array 329 | const posts: Post[] = Object.values(postsResponse.posts || {}); 330 | 331 | if (posts.length === 0) { 332 | console.error(`No posts found in channel: ${channelName}`); 333 | return; 334 | } 335 | 336 | // Find posts that match the topics 337 | const relevantPosts = findRelevantPosts(posts, this.config.topics); 338 | 339 | if (relevantPosts.length === 0) { 340 | console.error(`No relevant posts found in channel: ${channelName}`); 341 | return; 342 | } 343 | 344 | console.error(`Found ${relevantPosts.length} relevant posts in channel: ${channelName}`); 345 | 346 | // Create and send notification 347 | const userId = this.config.userId || this.currentUserId; 348 | const username = this.currentUsername || 'user'; 349 | if (!userId) { 350 | console.error('No user ID available for notification'); 351 | return; 352 | } 353 | 354 | const notificationMessage = createNotificationMessage( 355 | relevantPosts, 356 | channelName, 357 | username // Use username instead of userId 358 | ); 359 | 360 | if (notificationMessage) { 361 | const channelId = this.config.notificationChannelId || this.directMessageChannelId; 362 | if (!channelId) { 363 | console.error('No notification channel ID available'); 364 | return; 365 | } 366 | 367 | await this.client.createPost( 368 | channelId, 369 | notificationMessage 370 | ); 371 | console.error(`Sent notification for channel: ${channelName}`); 372 | } 373 | } catch (error) { 374 | console.error(`Error processing channel ${channelName}:`, error); 375 | } 376 | } 377 | } 378 | --------------------------------------------------------------------------------