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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/claude/cli.d.ts +24 -0
- package/dist/claude/cli.js +139 -0
- package/dist/claude/session.d.ts +41 -0
- package/dist/claude/session.js +582 -0
- package/dist/claude/types.d.ts +76 -0
- package/dist/claude/types.js +3 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +67 -0
- package/dist/mattermost/client.d.ts +35 -0
- package/dist/mattermost/client.js +226 -0
- package/dist/mattermost/message-formatter.d.ts +28 -0
- package/dist/mattermost/message-formatter.js +244 -0
- package/dist/mattermost/types.d.ts +62 -0
- package/dist/mattermost/types.js +1 -0
- package/dist/mcp/permission-server.d.ts +2 -0
- package/dist/mcp/permission-server.js +272 -0
- package/package.json +56 -0
|
@@ -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,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
|
+
}
|