mattermost-claude-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ export interface MattermostWebSocketEvent {
2
+ event: string;
3
+ data: Record<string, unknown>;
4
+ broadcast: {
5
+ channel_id?: string;
6
+ user_id?: string;
7
+ team_id?: string;
8
+ };
9
+ seq: number;
10
+ }
11
+ export interface MattermostPost {
12
+ id: string;
13
+ create_at: number;
14
+ update_at: number;
15
+ delete_at: number;
16
+ user_id: string;
17
+ channel_id: string;
18
+ root_id: string;
19
+ message: string;
20
+ type: string;
21
+ props: Record<string, unknown>;
22
+ metadata?: {
23
+ embeds?: unknown[];
24
+ files?: unknown[];
25
+ };
26
+ }
27
+ export interface MattermostUser {
28
+ id: string;
29
+ username: string;
30
+ email: string;
31
+ first_name: string;
32
+ last_name: string;
33
+ nickname: string;
34
+ }
35
+ export interface PostedEventData {
36
+ channel_display_name: string;
37
+ channel_name: string;
38
+ channel_type: string;
39
+ post: string;
40
+ sender_name: string;
41
+ team_id: string;
42
+ }
43
+ export interface ReactionAddedEventData {
44
+ reaction: string;
45
+ }
46
+ export interface MattermostReaction {
47
+ user_id: string;
48
+ post_id: string;
49
+ emoji_name: string;
50
+ create_at: number;
51
+ }
52
+ export interface CreatePostRequest {
53
+ channel_id: string;
54
+ message: string;
55
+ root_id?: string;
56
+ props?: Record<string, unknown>;
57
+ }
58
+ export interface UpdatePostRequest {
59
+ id: string;
60
+ message: string;
61
+ props?: Record<string, unknown>;
62
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Permission Server for Mattermost
4
+ *
5
+ * This server handles Claude Code's permission prompts by forwarding them to
6
+ * Mattermost for user approval via emoji reactions.
7
+ *
8
+ * It is spawned by Claude Code when using --permission-prompt-tool and
9
+ * communicates via stdio (MCP protocol).
10
+ *
11
+ * Approval options:
12
+ * - 👍 (+1) Allow this tool use
13
+ * - ✅ (white_check_mark) Allow all future tool uses in this session
14
+ * - 👎 (-1) Deny this tool use
15
+ *
16
+ * Environment variables (passed by mm-claude):
17
+ * - MATTERMOST_URL: Mattermost server URL
18
+ * - MATTERMOST_TOKEN: Bot access token
19
+ * - MATTERMOST_CHANNEL_ID: Channel to post permission requests
20
+ * - MM_THREAD_ID: Thread ID for the current session
21
+ * - ALLOWED_USERS: Comma-separated list of authorized usernames
22
+ * - DEBUG: Set to '1' for debug logging
23
+ */
24
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
25
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
26
+ import { z } from 'zod';
27
+ import WebSocket from 'ws';
28
+ // =============================================================================
29
+ // Configuration
30
+ // =============================================================================
31
+ const MM_URL = process.env.MATTERMOST_URL || '';
32
+ const MM_TOKEN = process.env.MATTERMOST_TOKEN || '';
33
+ const MM_CHANNEL_ID = process.env.MATTERMOST_CHANNEL_ID || '';
34
+ const MM_THREAD_ID = process.env.MM_THREAD_ID || '';
35
+ const ALLOWED_USERS = (process.env.ALLOWED_USERS || '')
36
+ .split(',')
37
+ .map(u => u.trim())
38
+ .filter(u => u.length > 0);
39
+ const DEBUG = process.env.DEBUG === '1';
40
+ const PERMISSION_TIMEOUT_MS = 120000; // 2 minutes
41
+ // Session state
42
+ let allowAllSession = false;
43
+ let botUserId = null;
44
+ // =============================================================================
45
+ // Debug Logging
46
+ // =============================================================================
47
+ function debug(msg) {
48
+ if (DEBUG)
49
+ console.error(`[MCP] ${msg}`);
50
+ }
51
+ async function getBotUserId() {
52
+ if (botUserId)
53
+ return botUserId;
54
+ const response = await fetch(`${MM_URL}/api/v4/users/me`, {
55
+ headers: { 'Authorization': `Bearer ${MM_TOKEN}` },
56
+ });
57
+ const me = await response.json();
58
+ botUserId = me.id;
59
+ return botUserId;
60
+ }
61
+ async function getUserById(userId) {
62
+ try {
63
+ const response = await fetch(`${MM_URL}/api/v4/users/${userId}`, {
64
+ headers: { 'Authorization': `Bearer ${MM_TOKEN}` },
65
+ });
66
+ if (!response.ok)
67
+ return null;
68
+ const user = await response.json();
69
+ return user.username || null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ function isUserAllowed(username) {
76
+ if (ALLOWED_USERS.length === 0)
77
+ return true;
78
+ return ALLOWED_USERS.includes(username);
79
+ }
80
+ async function createPost(message, rootId) {
81
+ const response = await fetch(`${MM_URL}/api/v4/posts`, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'Authorization': `Bearer ${MM_TOKEN}`,
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ body: JSON.stringify({
88
+ channel_id: MM_CHANNEL_ID,
89
+ message,
90
+ root_id: rootId || undefined,
91
+ }),
92
+ });
93
+ if (!response.ok) {
94
+ throw new Error(`Failed to create post: ${response.status}`);
95
+ }
96
+ return response.json();
97
+ }
98
+ async function addReaction(postId, emoji) {
99
+ const userId = await getBotUserId();
100
+ await fetch(`${MM_URL}/api/v4/reactions`, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Authorization': `Bearer ${MM_TOKEN}`,
104
+ 'Content-Type': 'application/json',
105
+ },
106
+ body: JSON.stringify({
107
+ user_id: userId,
108
+ post_id: postId,
109
+ emoji_name: emoji,
110
+ }),
111
+ });
112
+ }
113
+ // =============================================================================
114
+ // Reaction Handling
115
+ // =============================================================================
116
+ function waitForReaction(postId) {
117
+ return new Promise((resolve, reject) => {
118
+ const wsUrl = MM_URL.replace(/^http/, 'ws') + '/api/v4/websocket';
119
+ debug(`Connecting to WebSocket: ${wsUrl}`);
120
+ const ws = new WebSocket(wsUrl);
121
+ const timeout = setTimeout(() => {
122
+ debug(`Timeout waiting for reaction on ${postId}`);
123
+ ws.close();
124
+ reject(new Error('Permission request timed out'));
125
+ }, PERMISSION_TIMEOUT_MS);
126
+ ws.on('open', () => {
127
+ debug(`WebSocket connected, authenticating...`);
128
+ ws.send(JSON.stringify({
129
+ seq: 1,
130
+ action: 'authentication_challenge',
131
+ data: { token: MM_TOKEN },
132
+ }));
133
+ });
134
+ ws.on('message', async (data) => {
135
+ try {
136
+ const event = JSON.parse(data.toString());
137
+ debug(`WS event: ${event.event || event.status || 'unknown'}`);
138
+ if (event.event === 'reaction_added') {
139
+ const reactionData = event.data;
140
+ // Mattermost sends reaction as JSON string
141
+ const reaction = typeof reactionData.reaction === 'string'
142
+ ? JSON.parse(reactionData.reaction)
143
+ : reactionData.reaction;
144
+ debug(`Reaction on post ${reaction?.post_id}, looking for ${postId}`);
145
+ if (reaction?.post_id === postId) {
146
+ const userId = reaction.user_id;
147
+ debug(`Reaction from user ${userId}, emoji: ${reaction.emoji_name}`);
148
+ // Ignore bot's own reactions (from adding reaction options)
149
+ const myId = await getBotUserId();
150
+ if (userId === myId) {
151
+ debug(`Ignoring bot's own reaction`);
152
+ return;
153
+ }
154
+ // Check if user is authorized
155
+ const username = await getUserById(userId);
156
+ debug(`Username: ${username}, allowed: ${ALLOWED_USERS.join(',') || '(all)'}`);
157
+ if (!username || !isUserAllowed(username)) {
158
+ debug(`Ignoring unauthorized user: ${username || userId}`);
159
+ return;
160
+ }
161
+ debug(`Accepting reaction ${reaction.emoji_name} from ${username}`);
162
+ clearTimeout(timeout);
163
+ ws.close();
164
+ resolve({ emoji: reaction.emoji_name, username });
165
+ }
166
+ }
167
+ }
168
+ catch (e) {
169
+ debug(`Parse error: ${e}`);
170
+ }
171
+ });
172
+ ws.on('error', (err) => {
173
+ debug(`WebSocket error: ${err}`);
174
+ clearTimeout(timeout);
175
+ reject(err);
176
+ });
177
+ });
178
+ }
179
+ // =============================================================================
180
+ // Tool Formatting
181
+ // =============================================================================
182
+ function formatToolInfo(toolName, input) {
183
+ const short = (p) => {
184
+ const home = process.env.HOME || '';
185
+ return p?.startsWith(home) ? '~' + p.slice(home.length) : p;
186
+ };
187
+ switch (toolName) {
188
+ case 'Read':
189
+ return `📄 **Read** \`${short(input.file_path)}\``;
190
+ case 'Write':
191
+ return `📝 **Write** \`${short(input.file_path)}\``;
192
+ case 'Edit':
193
+ return `✏️ **Edit** \`${short(input.file_path)}\``;
194
+ case 'Bash': {
195
+ const cmd = (input.command || '').substring(0, 100);
196
+ return `💻 **Bash** \`${cmd}${cmd.length >= 100 ? '...' : ''}\``;
197
+ }
198
+ default:
199
+ if (toolName.startsWith('mcp__')) {
200
+ const parts = toolName.split('__');
201
+ return `🔌 **${parts.slice(2).join('__')}** *(${parts[1]})*`;
202
+ }
203
+ return `🔧 **${toolName}**`;
204
+ }
205
+ }
206
+ async function handlePermission(toolName, toolInput) {
207
+ debug(`handlePermission called for ${toolName}`);
208
+ // Auto-approve if "allow all" was selected earlier
209
+ if (allowAllSession) {
210
+ debug(`Auto-allowing ${toolName} (allow all active)`);
211
+ return { behavior: 'allow', updatedInput: toolInput };
212
+ }
213
+ if (!MM_URL || !MM_TOKEN || !MM_CHANNEL_ID) {
214
+ console.error('[MCP] Missing Mattermost config');
215
+ return { behavior: 'deny', message: 'Permission service not configured' };
216
+ }
217
+ try {
218
+ // Post permission request to Mattermost
219
+ const toolInfo = formatToolInfo(toolName, toolInput);
220
+ const message = `⚠️ **Permission requested**\n\n${toolInfo}\n\n` +
221
+ `👍 Allow | ✅ Allow all | 👎 Deny`;
222
+ const post = await createPost(message, MM_THREAD_ID || undefined);
223
+ // Add reaction options for the user to click
224
+ await addReaction(post.id, '+1');
225
+ await addReaction(post.id, 'white_check_mark');
226
+ await addReaction(post.id, '-1');
227
+ // Wait for user's reaction
228
+ const { emoji } = await waitForReaction(post.id);
229
+ if (emoji === '+1' || emoji === 'thumbsup') {
230
+ console.error(`[MCP] Allowed: ${toolName}`);
231
+ return { behavior: 'allow', updatedInput: toolInput };
232
+ }
233
+ else if (emoji === 'white_check_mark' || emoji === 'heavy_check_mark') {
234
+ allowAllSession = true;
235
+ console.error(`[MCP] Allowed all: ${toolName}`);
236
+ return { behavior: 'allow', updatedInput: toolInput };
237
+ }
238
+ else {
239
+ console.error(`[MCP] Denied: ${toolName}`);
240
+ return { behavior: 'deny', message: 'User denied permission' };
241
+ }
242
+ }
243
+ catch (error) {
244
+ console.error('[MCP] Permission error:', error);
245
+ return { behavior: 'deny', message: String(error) };
246
+ }
247
+ }
248
+ // =============================================================================
249
+ // MCP Server Setup
250
+ // =============================================================================
251
+ async function main() {
252
+ const server = new McpServer({
253
+ name: 'mm-claude-permissions',
254
+ version: '1.0.0',
255
+ });
256
+ server.tool('permission_prompt', 'Handle permission requests via Mattermost reactions', {
257
+ tool_name: z.string().describe('Name of the tool requesting permission'),
258
+ input: z.record(z.string(), z.unknown()).describe('Tool input parameters'),
259
+ }, async ({ tool_name, input }) => {
260
+ const result = await handlePermission(tool_name, input);
261
+ return {
262
+ content: [{ type: 'text', text: JSON.stringify(result) }],
263
+ };
264
+ });
265
+ const transport = new StdioServerTransport();
266
+ await server.connect(transport);
267
+ console.error('[MCP] Permission server ready');
268
+ }
269
+ main().catch((err) => {
270
+ console.error('[MCP] Fatal:', err);
271
+ process.exit(1);
272
+ });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "mattermost-claude-code",
3
+ "version": "0.2.0",
4
+ "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "mm-claude": "./dist/index.js",
9
+ "mm-claude-mcp": "./dist/mcp/permission-server.js"
10
+ },
11
+ "scripts": {
12
+ "dev": "tsx watch src/index.ts",
13
+ "build": "tsc",
14
+ "start": "node dist/index.js"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "claude-code",
19
+ "mattermost",
20
+ "bot",
21
+ "ai",
22
+ "cli",
23
+ "anthropic",
24
+ "chat"
25
+ ],
26
+ "author": "Anne Schuth",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/anneschuth/mattermost-claude-code.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/anneschuth/mattermost-claude-code/issues"
34
+ },
35
+ "homepage": "https://github.com/anneschuth/mattermost-claude-code#readme",
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.25.1",
43
+ "dotenv": "^16.4.7",
44
+ "ws": "^8.18.0",
45
+ "zod": "^4.2.1"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.10.2",
49
+ "@types/ws": "^8.5.13",
50
+ "tsx": "^4.19.2",
51
+ "typescript": "^5.7.2"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ }
56
+ }