@arearseth/tmux-mcp 0.2.2

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.md ADDED
@@ -0,0 +1,8 @@
1
+ Copyright 2025 Nicolò Gnudi
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8
+
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Tmux MCP Server
2
+
3
+ Model Context Protocol server that enables Claude Desktop to interact with and view tmux session content. This integration allows AI assistants to read from, control, and observe your terminal sessions.
4
+
5
+ ## Features
6
+
7
+ - List and search tmux sessions
8
+ - View and navigate tmux windows and panes
9
+ - Capture and expose terminal content from any pane
10
+ - Execute commands in tmux panes and retrieve results (use it at your own risk ⚠️)
11
+ - Create new tmux sessions and windows
12
+ - Split panes horizontally or vertically with customizable sizes
13
+ - Kill tmux sessions, windows, and panes
14
+
15
+ Check out this short video to get excited!
16
+
17
+ </br>
18
+
19
+ [![youtube video](http://i.ytimg.com/vi/3W0pqRF1RS0/hqdefault.jpg)](https://www.youtube.com/watch?v=3W0pqRF1RS0)
20
+
21
+ ## Prerequisites
22
+
23
+ - Node.js
24
+ - tmux installed and running
25
+
26
+ ## Usage
27
+
28
+ ### Configure Claude Desktop
29
+
30
+ Add this MCP server to your Claude Desktop configuration:
31
+
32
+ ```json
33
+ "mcpServers": {
34
+ "tmux": {
35
+ "command": "npx",
36
+ "args": ["-y", "tmux-mcp"]
37
+ }
38
+ }
39
+ ```
40
+
41
+ ### MCP server options
42
+
43
+ You can optionally specify the command line shell you are using, if unspecified it defaults to `bash`
44
+
45
+ ```json
46
+ "mcpServers": {
47
+ "tmux": {
48
+ "command": "npx",
49
+ "args": ["-y", "tmux-mcp", "--shell-type=fish"]
50
+ }
51
+ }
52
+ ```
53
+
54
+ The MCP server needs to know the shell only when executing commands, to properly read its exit status.
55
+
56
+ ## Available Resources
57
+
58
+ - `tmux://sessions` - List all tmux sessions
59
+ - `tmux://pane/{paneId}` - View content of a specific tmux pane
60
+ - `tmux://command/{commandId}/result` - Results from executed commands
61
+
62
+ ## Available Tools
63
+
64
+ - `list-sessions` - List all active tmux sessions
65
+ - `find-session` - Find a tmux session by name
66
+ - `list-windows` - List windows in a tmux session
67
+ - `list-panes` - List panes in a tmux window
68
+ - `capture-pane` - Capture content from a tmux pane
69
+ - `create-session` - Create a new tmux session
70
+ - `create-window` - Create a new window in a tmux session
71
+ - `split-pane` - Split a tmux pane horizontally or vertically with optional size
72
+ - `kill-session` - Kill a tmux session by ID
73
+ - `kill-window` - Kill a tmux window by ID
74
+ - `kill-pane` - Kill a tmux pane by ID
75
+ - `execute-command` - Execute a command in a tmux pane
76
+ - `get-command-result` - Get the result of an executed command
77
+
package/build/index.js ADDED
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import * as tmux from "./tmux.js";
7
+ // Create MCP server
8
+ const server = new McpServer({
9
+ name: "tmux-mcp",
10
+ version: "0.2.2"
11
+ }, {
12
+ capabilities: {
13
+ resources: {
14
+ subscribe: true,
15
+ listChanged: true
16
+ },
17
+ tools: {
18
+ listChanged: true
19
+ },
20
+ logging: {}
21
+ }
22
+ });
23
+ const shellTypeSchema = z.enum(tmux.supportedShellTypes);
24
+ // List all tmux sessions - Tool
25
+ server.tool("list-sessions", "List all active tmux sessions", {}, async () => {
26
+ try {
27
+ const sessions = await tmux.listSessions();
28
+ return {
29
+ content: [{
30
+ type: "text",
31
+ text: JSON.stringify(sessions, null, 2)
32
+ }]
33
+ };
34
+ }
35
+ catch (error) {
36
+ return {
37
+ content: [{
38
+ type: "text",
39
+ text: `Error listing tmux sessions: ${error}`
40
+ }],
41
+ isError: true
42
+ };
43
+ }
44
+ });
45
+ // Find session by name - Tool
46
+ server.tool("find-session", "Find a tmux session by name", {
47
+ name: z.string().describe("Name of the tmux session to find")
48
+ }, async ({ name }) => {
49
+ try {
50
+ const session = await tmux.findSessionByName(name);
51
+ return {
52
+ content: [{
53
+ type: "text",
54
+ text: session ? JSON.stringify(session, null, 2) : `Session not found: ${name}`
55
+ }]
56
+ };
57
+ }
58
+ catch (error) {
59
+ return {
60
+ content: [{
61
+ type: "text",
62
+ text: `Error finding tmux session: ${error}`
63
+ }],
64
+ isError: true
65
+ };
66
+ }
67
+ });
68
+ // List windows in a session - Tool
69
+ server.tool("list-windows", "List windows in a tmux session", {
70
+ sessionId: z.string().describe("ID of the tmux session")
71
+ }, async ({ sessionId }) => {
72
+ try {
73
+ const windows = await tmux.listWindows(sessionId);
74
+ return {
75
+ content: [{
76
+ type: "text",
77
+ text: JSON.stringify(windows, null, 2)
78
+ }]
79
+ };
80
+ }
81
+ catch (error) {
82
+ return {
83
+ content: [{
84
+ type: "text",
85
+ text: `Error listing windows: ${error}`
86
+ }],
87
+ isError: true
88
+ };
89
+ }
90
+ });
91
+ // List panes in a window - Tool
92
+ server.tool("list-panes", "List panes in a tmux window", {
93
+ windowId: z.string().describe("ID of the tmux window")
94
+ }, async ({ windowId }) => {
95
+ try {
96
+ const panes = await tmux.listPanes(windowId);
97
+ return {
98
+ content: [{
99
+ type: "text",
100
+ text: JSON.stringify(panes, null, 2)
101
+ }]
102
+ };
103
+ }
104
+ catch (error) {
105
+ return {
106
+ content: [{
107
+ type: "text",
108
+ text: `Error listing panes: ${error}`
109
+ }],
110
+ isError: true
111
+ };
112
+ }
113
+ });
114
+ // Capture pane content - Tool
115
+ server.tool("capture-pane", "Capture content from a tmux pane with configurable lines count and optional color preservation", {
116
+ paneId: z.string().describe("ID of the tmux pane"),
117
+ lines: z.string().optional().describe("Number of lines to capture"),
118
+ colors: z.boolean().optional().describe("Include color/escape sequences for text and background attributes in output")
119
+ }, async ({ paneId, lines, colors }) => {
120
+ try {
121
+ // Parse lines parameter if provided
122
+ const linesCount = lines ? parseInt(lines, 10) : undefined;
123
+ const includeColors = colors || false;
124
+ const content = await tmux.capturePaneContent(paneId, linesCount, includeColors);
125
+ return {
126
+ content: [{
127
+ type: "text",
128
+ text: content || "No content captured"
129
+ }]
130
+ };
131
+ }
132
+ catch (error) {
133
+ return {
134
+ content: [{
135
+ type: "text",
136
+ text: `Error capturing pane content: ${error}`
137
+ }],
138
+ isError: true
139
+ };
140
+ }
141
+ });
142
+ // Create new session - Tool
143
+ server.tool("create-session", "Create a new tmux session", {
144
+ name: z.string().describe("Name for the new tmux session")
145
+ }, async ({ name }) => {
146
+ try {
147
+ const session = await tmux.createSession(name);
148
+ return {
149
+ content: [{
150
+ type: "text",
151
+ text: session
152
+ ? `Session created: ${JSON.stringify(session, null, 2)}`
153
+ : `Failed to create session: ${name}`
154
+ }]
155
+ };
156
+ }
157
+ catch (error) {
158
+ return {
159
+ content: [{
160
+ type: "text",
161
+ text: `Error creating session: ${error}`
162
+ }],
163
+ isError: true
164
+ };
165
+ }
166
+ });
167
+ // Create new window - Tool
168
+ server.tool("create-window", "Create a new window in a tmux session", {
169
+ sessionId: z.string().describe("ID of the tmux session"),
170
+ name: z.string().describe("Name for the new window")
171
+ }, async ({ sessionId, name }) => {
172
+ try {
173
+ const window = await tmux.createWindow(sessionId, name);
174
+ return {
175
+ content: [{
176
+ type: "text",
177
+ text: window
178
+ ? `Window created: ${JSON.stringify(window, null, 2)}`
179
+ : `Failed to create window: ${name}`
180
+ }]
181
+ };
182
+ }
183
+ catch (error) {
184
+ return {
185
+ content: [{
186
+ type: "text",
187
+ text: `Error creating window: ${error}`
188
+ }],
189
+ isError: true
190
+ };
191
+ }
192
+ });
193
+ // Kill session - Tool
194
+ server.tool("kill-session", "Kill a tmux session by ID", {
195
+ sessionId: z.string().describe("ID of the tmux session to kill")
196
+ }, async ({ sessionId }) => {
197
+ try {
198
+ await tmux.killSession(sessionId);
199
+ return {
200
+ content: [{
201
+ type: "text",
202
+ text: `Session ${sessionId} has been killed`
203
+ }]
204
+ };
205
+ }
206
+ catch (error) {
207
+ return {
208
+ content: [{
209
+ type: "text",
210
+ text: `Error killing session: ${error}`
211
+ }],
212
+ isError: true
213
+ };
214
+ }
215
+ });
216
+ // Kill window - Tool
217
+ server.tool("kill-window", "Kill a tmux window by ID", {
218
+ windowId: z.string().describe("ID of the tmux window to kill")
219
+ }, async ({ windowId }) => {
220
+ try {
221
+ await tmux.killWindow(windowId);
222
+ return {
223
+ content: [{
224
+ type: "text",
225
+ text: `Window ${windowId} has been killed`
226
+ }]
227
+ };
228
+ }
229
+ catch (error) {
230
+ return {
231
+ content: [{
232
+ type: "text",
233
+ text: `Error killing window: ${error}`
234
+ }],
235
+ isError: true
236
+ };
237
+ }
238
+ });
239
+ // Kill pane - Tool
240
+ server.tool("kill-pane", "Kill a tmux pane by ID", {
241
+ paneId: z.string().describe("ID of the tmux pane to kill")
242
+ }, async ({ paneId }) => {
243
+ try {
244
+ await tmux.killPane(paneId);
245
+ return {
246
+ content: [{
247
+ type: "text",
248
+ text: `Pane ${paneId} has been killed`
249
+ }]
250
+ };
251
+ }
252
+ catch (error) {
253
+ return {
254
+ content: [{
255
+ type: "text",
256
+ text: `Error killing pane: ${error}`
257
+ }],
258
+ isError: true
259
+ };
260
+ }
261
+ });
262
+ // Split pane - Tool
263
+ server.tool("split-pane", "Split a tmux pane horizontally or vertically", {
264
+ paneId: z.string().describe("ID of the tmux pane to split"),
265
+ direction: z.enum(["horizontal", "vertical"]).optional().describe("Split direction: 'horizontal' (side by side) or 'vertical' (top/bottom). Default is 'vertical'"),
266
+ size: z.number().min(1).max(99).optional().describe("Size of the new pane as percentage (1-99). Default is 50%")
267
+ }, async ({ paneId, direction, size }) => {
268
+ try {
269
+ const newPane = await tmux.splitPane(paneId, direction || 'vertical', size);
270
+ return {
271
+ content: [{
272
+ type: "text",
273
+ text: newPane
274
+ ? `Pane split successfully. New pane: ${JSON.stringify(newPane, null, 2)}`
275
+ : `Failed to split pane ${paneId}`
276
+ }]
277
+ };
278
+ }
279
+ catch (error) {
280
+ return {
281
+ content: [{
282
+ type: "text",
283
+ text: `Error splitting pane: ${error}`
284
+ }],
285
+ isError: true
286
+ };
287
+ }
288
+ });
289
+ // Configure shell type - Tool
290
+ server.tool("set-shell-type", "Configure the shell type used for command execution. Provide paneId to override a specific pane.", {
291
+ type: shellTypeSchema,
292
+ paneId: z.string().optional().describe("ID of the tmux pane to override. Omit to change the default shell type.")
293
+ }, async ({ type, paneId }) => {
294
+ try {
295
+ tmux.setShellConfig({ type, paneId });
296
+ const target = paneId ? `pane ${paneId}` : 'default';
297
+ return {
298
+ content: [{
299
+ type: "text",
300
+ text: `Shell type for ${target} set to ${type}`
301
+ }]
302
+ };
303
+ }
304
+ catch (error) {
305
+ return {
306
+ content: [{
307
+ type: "text",
308
+ text: `Error setting shell type: ${error}`
309
+ }],
310
+ isError: true
311
+ };
312
+ }
313
+ });
314
+ // Execute command in pane - Tool
315
+ server.tool("execute-command", "Execute a command in a tmux pane and get results. For interactive applications (REPLs, editors), use `rawMode=true`. IMPORTANT: When `rawMode=false` (default), avoid heredoc syntax (cat << EOF) and other multi-line constructs as they conflict with command wrapping. For file writing, prefer: printf 'content\\n' > file, echo statements, or write to temp files instead", {
316
+ paneId: z.string().describe("ID of the tmux pane"),
317
+ command: z.string().describe("Command to execute"),
318
+ rawMode: z.boolean().optional().describe("Execute command without wrapper markers for REPL/interactive compatibility. Disables get-command-result status tracking. Use capture-pane after execution to verify command outcome."),
319
+ noEnter: z.boolean().optional().describe("Send keystrokes without pressing Enter. For TUI navigation in apps like btop, vim, less. Supports special keys (Up, Down, Escape, Tab, etc.) and strings (sent char-by-char for proper filtering). Automatically applies rawMode. Use capture-pane after to see results.")
320
+ }, async ({ paneId, command, rawMode, noEnter }) => {
321
+ try {
322
+ // If noEnter is true, automatically apply rawMode
323
+ const effectiveRawMode = noEnter || rawMode;
324
+ const commandId = await tmux.executeCommand(paneId, command, effectiveRawMode, noEnter);
325
+ if (effectiveRawMode) {
326
+ const modeText = noEnter ? "Keys sent without Enter" : "Interactive command started (rawMode)";
327
+ return {
328
+ content: [{
329
+ type: "text",
330
+ text: `${modeText}.\n\nStatus tracking is disabled.\nUse 'capture-pane' with paneId '${paneId}' to verify the command outcome.\n\nCommand ID: ${commandId}`
331
+ }]
332
+ };
333
+ }
334
+ // Create the resource URI for this command's results
335
+ const resourceUri = `tmux://command/${commandId}/result`;
336
+ return {
337
+ content: [{
338
+ type: "text",
339
+ text: `Command execution started.\n\nTo get results, subscribe to and read resource: ${resourceUri}\n\nStatus will change from 'pending' to 'completed' or 'error' when finished.`
340
+ }]
341
+ };
342
+ }
343
+ catch (error) {
344
+ return {
345
+ content: [{
346
+ type: "text",
347
+ text: `Error executing command: ${error}`
348
+ }],
349
+ isError: true
350
+ };
351
+ }
352
+ });
353
+ // Get command result - Tool
354
+ server.tool("get-command-result", "Get the result of an executed command", {
355
+ commandId: z.string().describe("ID of the executed command")
356
+ }, async ({ commandId }) => {
357
+ try {
358
+ // Check and update command status
359
+ const command = await tmux.checkCommandStatus(commandId);
360
+ if (!command) {
361
+ return {
362
+ content: [{
363
+ type: "text",
364
+ text: `Command not found: ${commandId}`
365
+ }],
366
+ isError: true
367
+ };
368
+ }
369
+ // Format the response based on command status
370
+ let resultText;
371
+ if (command.status === 'pending') {
372
+ if (command.result) {
373
+ resultText = `Status: ${command.status}\nCommand: ${command.command}\n\n--- Message ---\n${command.result}`;
374
+ }
375
+ else {
376
+ resultText = `Command still executing...\nStarted: ${command.startTime.toISOString()}\nCommand: ${command.command}`;
377
+ }
378
+ }
379
+ else {
380
+ resultText = `Status: ${command.status}\nExit code: ${command.exitCode}\nCommand: ${command.command}\n\n--- Output ---\n${command.result}`;
381
+ }
382
+ return {
383
+ content: [{
384
+ type: "text",
385
+ text: resultText
386
+ }]
387
+ };
388
+ }
389
+ catch (error) {
390
+ return {
391
+ content: [{
392
+ type: "text",
393
+ text: `Error retrieving command result: ${error}`
394
+ }],
395
+ isError: true
396
+ };
397
+ }
398
+ });
399
+ // Expose tmux session list as a resource
400
+ server.resource("Tmux Sessions", "tmux://sessions", async () => {
401
+ try {
402
+ const sessions = await tmux.listSessions();
403
+ return {
404
+ contents: [{
405
+ uri: "tmux://sessions",
406
+ text: JSON.stringify(sessions.map(session => ({
407
+ id: session.id,
408
+ name: session.name,
409
+ attached: session.attached,
410
+ windows: session.windows
411
+ })), null, 2)
412
+ }]
413
+ };
414
+ }
415
+ catch (error) {
416
+ return {
417
+ contents: [{
418
+ uri: "tmux://sessions",
419
+ text: `Error listing tmux sessions: ${error}`
420
+ }]
421
+ };
422
+ }
423
+ });
424
+ // Expose pane content as a resource
425
+ server.resource("Tmux Pane Content", new ResourceTemplate("tmux://pane/{paneId}", {
426
+ list: async () => {
427
+ try {
428
+ // Get all sessions
429
+ const sessions = await tmux.listSessions();
430
+ const paneResources = [];
431
+ // For each session, get all windows
432
+ for (const session of sessions) {
433
+ const windows = await tmux.listWindows(session.id);
434
+ // For each window, get all panes
435
+ for (const window of windows) {
436
+ const panes = await tmux.listPanes(window.id);
437
+ // For each pane, create a resource with descriptive name
438
+ for (const pane of panes) {
439
+ paneResources.push({
440
+ name: `Pane: ${session.name} - ${pane.id} - ${pane.title} ${pane.active ? "(active)" : ""}`,
441
+ uri: `tmux://pane/${pane.id}`,
442
+ description: `Content from pane ${pane.id} - ${pane.title} in session ${session.name}`
443
+ });
444
+ }
445
+ }
446
+ }
447
+ return {
448
+ resources: paneResources
449
+ };
450
+ }
451
+ catch (error) {
452
+ server.server.sendLoggingMessage({
453
+ level: 'error',
454
+ data: `Error listing panes: ${error}`
455
+ });
456
+ return { resources: [] };
457
+ }
458
+ }
459
+ }), async (uri, { paneId }) => {
460
+ try {
461
+ // Ensure paneId is a string
462
+ const paneIdStr = Array.isArray(paneId) ? paneId[0] : paneId;
463
+ // Default to no colors for resources to maintain clean programmatic access
464
+ const content = await tmux.capturePaneContent(paneIdStr, 200, false);
465
+ return {
466
+ contents: [{
467
+ uri: uri.href,
468
+ text: content || "No content captured"
469
+ }]
470
+ };
471
+ }
472
+ catch (error) {
473
+ return {
474
+ contents: [{
475
+ uri: uri.href,
476
+ text: `Error capturing pane content: ${error}`
477
+ }]
478
+ };
479
+ }
480
+ });
481
+ // Create dynamic resource for command executions
482
+ server.resource("Command Execution Result", new ResourceTemplate("tmux://command/{commandId}/result", {
483
+ list: async () => {
484
+ // Only list active commands that aren't too old
485
+ tmux.cleanupOldCommands(10); // Clean commands older than 10 minutes
486
+ const resources = [];
487
+ for (const id of tmux.getActiveCommandIds()) {
488
+ const command = tmux.getCommand(id);
489
+ if (command) {
490
+ resources.push({
491
+ name: `Command: ${command.command.substring(0, 30)}${command.command.length > 30 ? '...' : ''}`,
492
+ uri: `tmux://command/${id}/result`,
493
+ description: `Execution status: ${command.status}`
494
+ });
495
+ }
496
+ }
497
+ return { resources };
498
+ }
499
+ }), async (uri, { commandId }) => {
500
+ try {
501
+ // Ensure commandId is a string
502
+ const commandIdStr = Array.isArray(commandId) ? commandId[0] : commandId;
503
+ // Check command status
504
+ const command = await tmux.checkCommandStatus(commandIdStr);
505
+ if (!command) {
506
+ return {
507
+ contents: [{
508
+ uri: uri.href,
509
+ text: `Command not found: ${commandIdStr}`
510
+ }]
511
+ };
512
+ }
513
+ // Format the response based on command status
514
+ let resultText;
515
+ if (command.status === 'pending') {
516
+ // For rawMode commands, we set a result message while status remains 'pending'
517
+ // since we can't track their actual completion
518
+ if (command.result) {
519
+ resultText = `Status: ${command.status}\nCommand: ${command.command}\n\n--- Message ---\n${command.result}`;
520
+ }
521
+ else {
522
+ resultText = `Command still executing...\nStarted: ${command.startTime.toISOString()}\nCommand: ${command.command}`;
523
+ }
524
+ }
525
+ else {
526
+ resultText = `Status: ${command.status}\nExit code: ${command.exitCode}\nCommand: ${command.command}\n\n--- Output ---\n${command.result}`;
527
+ }
528
+ return {
529
+ contents: [{
530
+ uri: uri.href,
531
+ text: resultText
532
+ }]
533
+ };
534
+ }
535
+ catch (error) {
536
+ return {
537
+ contents: [{
538
+ uri: uri.href,
539
+ text: `Error retrieving command result: ${error}`
540
+ }]
541
+ };
542
+ }
543
+ });
544
+ async function main() {
545
+ try {
546
+ const { values } = parseArgs({
547
+ options: {
548
+ 'shell-type': { type: 'string', default: 'bash', short: 's' }
549
+ }
550
+ });
551
+ // Set shell configuration
552
+ tmux.setShellConfig({
553
+ type: values['shell-type']
554
+ });
555
+ // Start the MCP server
556
+ const transport = new StdioServerTransport();
557
+ await server.connect(transport);
558
+ }
559
+ catch (error) {
560
+ console.error("Failed to start MCP server:", error);
561
+ process.exit(1);
562
+ }
563
+ }
564
+ main().catch(error => {
565
+ console.error("Fatal error:", error);
566
+ process.exit(1);
567
+ });
package/build/tmux.js ADDED
@@ -0,0 +1,352 @@
1
+ import { exec as execCallback } from "child_process";
2
+ import { promisify } from "util";
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ const exec = promisify(execCallback);
5
+ export const supportedShellTypes = ['bash', 'zsh', 'fish', 'fc_shell'];
6
+ const shellConfig = {
7
+ defaultType: 'bash',
8
+ paneOverrides: new Map()
9
+ };
10
+ function normalizeShellType(type) {
11
+ return supportedShellTypes.includes(type)
12
+ ? type
13
+ : 'bash';
14
+ }
15
+ export function setShellConfig(config) {
16
+ const normalized = normalizeShellType(config.type);
17
+ if (config.paneId) {
18
+ shellConfig.paneOverrides.set(config.paneId, normalized);
19
+ // Reset cached initialization so the helper can be installed on demand
20
+ fcShellInitializedPanes.delete(config.paneId);
21
+ return;
22
+ }
23
+ shellConfig.defaultType = normalized;
24
+ if (normalized !== 'fc_shell') {
25
+ fcShellInitializedPanes.clear();
26
+ }
27
+ }
28
+ function resolveShellType(paneId) {
29
+ return shellConfig.paneOverrides.get(paneId) ?? shellConfig.defaultType;
30
+ }
31
+ /**
32
+ * Execute a tmux command and return the result
33
+ */
34
+ export async function executeTmux(tmuxCommand) {
35
+ try {
36
+ const { stdout } = await exec(`tmux ${tmuxCommand}`);
37
+ return stdout.trim();
38
+ }
39
+ catch (error) {
40
+ throw new Error(`Failed to execute tmux command: ${error.message}`);
41
+ }
42
+ }
43
+ /**
44
+ * Check if tmux server is running
45
+ */
46
+ export async function isTmuxRunning() {
47
+ try {
48
+ await executeTmux("list-sessions -F '#{session_name}'");
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ return false;
53
+ }
54
+ }
55
+ /**
56
+ * List all tmux sessions
57
+ */
58
+ export async function listSessions() {
59
+ const format = "#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
60
+ const output = await executeTmux(`list-sessions -F '${format}'`);
61
+ if (!output)
62
+ return [];
63
+ return output.split('\n').map(line => {
64
+ const [id, name, attached, windows] = line.split(':');
65
+ return {
66
+ id,
67
+ name,
68
+ attached: attached === '1',
69
+ windows: parseInt(windows, 10)
70
+ };
71
+ });
72
+ }
73
+ /**
74
+ * Find a session by name
75
+ */
76
+ export async function findSessionByName(name) {
77
+ try {
78
+ const sessions = await listSessions();
79
+ return sessions.find(session => session.name === name) || null;
80
+ }
81
+ catch (error) {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * List windows in a session
87
+ */
88
+ export async function listWindows(sessionId) {
89
+ const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
90
+ const output = await executeTmux(`list-windows -t '${sessionId}' -F '${format}'`);
91
+ if (!output)
92
+ return [];
93
+ return output.split('\n').map(line => {
94
+ const [id, name, active] = line.split(':');
95
+ return {
96
+ id,
97
+ name,
98
+ active: active === '1',
99
+ sessionId
100
+ };
101
+ });
102
+ }
103
+ /**
104
+ * List panes in a window
105
+ */
106
+ export async function listPanes(windowId) {
107
+ const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
108
+ const output = await executeTmux(`list-panes -t '${windowId}' -F '${format}'`);
109
+ if (!output)
110
+ return [];
111
+ return output.split('\n').map(line => {
112
+ const [id, title, active] = line.split(':');
113
+ return {
114
+ id,
115
+ windowId,
116
+ title: title,
117
+ active: active === '1'
118
+ };
119
+ });
120
+ }
121
+ /**
122
+ * Capture content from a specific pane, by default the latest 200 lines.
123
+ */
124
+ export async function capturePaneContent(paneId, lines = 200, includeColors = false) {
125
+ const colorFlag = includeColors ? '-e' : '';
126
+ return executeTmux(`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`);
127
+ }
128
+ /**
129
+ * Create a new tmux session
130
+ */
131
+ export async function createSession(name) {
132
+ await executeTmux(`new-session -d -s "${name}"`);
133
+ return findSessionByName(name);
134
+ }
135
+ /**
136
+ * Create a new window in a session
137
+ */
138
+ export async function createWindow(sessionId, name) {
139
+ const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`);
140
+ const windows = await listWindows(sessionId);
141
+ return windows.find(window => window.name === name) || null;
142
+ }
143
+ /**
144
+ * Kill a tmux session by ID
145
+ */
146
+ export async function killSession(sessionId) {
147
+ await executeTmux(`kill-session -t '${sessionId}'`);
148
+ }
149
+ /**
150
+ * Kill a tmux window by ID
151
+ */
152
+ export async function killWindow(windowId) {
153
+ await executeTmux(`kill-window -t '${windowId}'`);
154
+ }
155
+ /**
156
+ * Kill a tmux pane by ID
157
+ */
158
+ export async function killPane(paneId) {
159
+ await executeTmux(`kill-pane -t '${paneId}'`);
160
+ }
161
+ /**
162
+ * Split a tmux pane horizontally or vertically
163
+ */
164
+ export async function splitPane(targetPaneId, direction = 'vertical', size) {
165
+ // Build the split-window command
166
+ let splitCommand = 'split-window';
167
+ // Add direction flag (-h for horizontal, -v for vertical)
168
+ if (direction === 'horizontal') {
169
+ splitCommand += ' -h';
170
+ }
171
+ else {
172
+ splitCommand += ' -v';
173
+ }
174
+ // Add target pane
175
+ splitCommand += ` -t '${targetPaneId}'`;
176
+ // Add size if specified (as percentage)
177
+ if (size !== undefined && size > 0 && size < 100) {
178
+ splitCommand += ` -p ${size}`;
179
+ }
180
+ // Execute the split command
181
+ await executeTmux(splitCommand);
182
+ // Get the window ID from the target pane to list all panes
183
+ const windowInfo = await executeTmux(`display-message -p -t '${targetPaneId}' '#{window_id}'`);
184
+ // List all panes in the window to find the newly created one
185
+ const panes = await listPanes(windowInfo);
186
+ // The newest pane is typically the last one in the list
187
+ return panes.length > 0 ? panes[panes.length - 1] : null;
188
+ }
189
+ // Map to track ongoing command executions
190
+ const activeCommands = new Map();
191
+ const startMarkerText = 'TMUX_MCP_START';
192
+ const endMarkerPrefix = "TMUX_MCP_DONE_";
193
+ // Track fc_shell initialization per pane to keep terminal output minimal
194
+ const fcShellInitializedPanes = new Set();
195
+ // Execute a command in a tmux pane and track its execution
196
+ export async function executeCommand(paneId, command, rawMode, noEnter) {
197
+ // Generate unique ID for this command execution
198
+ const commandId = uuidv4();
199
+ const shellType = resolveShellType(paneId);
200
+ let fullCommand;
201
+ if (rawMode || noEnter) {
202
+ fullCommand = command;
203
+ }
204
+ else {
205
+ if (shellType === 'fc_shell') {
206
+ await ensureFcShellInitialized(paneId);
207
+ fullCommand = buildFcShellCommand(command);
208
+ }
209
+ else {
210
+ fullCommand = buildWrappedCommand(command, shellType);
211
+ }
212
+ }
213
+ // Store command in tracking map
214
+ activeCommands.set(commandId, {
215
+ id: commandId,
216
+ paneId,
217
+ command,
218
+ status: 'pending',
219
+ startTime: new Date(),
220
+ rawMode: rawMode || noEnter
221
+ });
222
+ // Send the command to the tmux pane
223
+ if (noEnter) {
224
+ // Check if this is a special key (e.g., Up, Down, Left, Right, Escape, Tab, etc.)
225
+ // Special keys in tmux are typically capitalized or have special names
226
+ const specialKeys = ['Up', 'Down', 'Left', 'Right', 'Escape', 'Tab', 'Enter', 'Space',
227
+ 'BSpace', 'Delete', 'Home', 'End', 'PageUp', 'PageDown',
228
+ 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'];
229
+ if (specialKeys.includes(fullCommand)) {
230
+ // Send special key as-is
231
+ await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
232
+ }
233
+ else {
234
+ // For regular text, send each character individually to ensure proper processing
235
+ // This handles both single characters (like 'q', 'f') and strings (like 'beam')
236
+ for (const char of fullCommand) {
237
+ await executeTmux(`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`);
238
+ }
239
+ }
240
+ }
241
+ else {
242
+ await executeTmux(`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`);
243
+ }
244
+ return commandId;
245
+ }
246
+ export async function checkCommandStatus(commandId) {
247
+ const command = activeCommands.get(commandId);
248
+ if (!command)
249
+ return null;
250
+ if (command.status !== 'pending')
251
+ return command;
252
+ const content = await capturePaneContent(command.paneId, 1000);
253
+ if (command.rawMode) {
254
+ command.result = 'Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.';
255
+ return command;
256
+ }
257
+ // Find the last occurrence of the markers
258
+ const startIndex = content.lastIndexOf(startMarkerText);
259
+ const endIndex = content.lastIndexOf(endMarkerPrefix);
260
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
261
+ command.result = "Command output could not be captured properly";
262
+ return command;
263
+ }
264
+ // Extract exit code from the end marker line
265
+ const endLine = content.substring(endIndex).split('\n')[0];
266
+ const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
267
+ const exitCodeMatch = endLine.match(endMarkerRegex);
268
+ if (exitCodeMatch) {
269
+ const exitCode = parseInt(exitCodeMatch[1], 10);
270
+ command.status = exitCode === 0 ? 'completed' : 'error';
271
+ command.exitCode = exitCode;
272
+ // Extract output between the start and end markers
273
+ const outputStart = startIndex + startMarkerText.length;
274
+ const outputContent = content.substring(outputStart, endIndex).trim();
275
+ const outputLines = outputContent ? outputContent.split('\n') : [];
276
+ if (outputLines.length > 0) {
277
+ const firstLine = outputLines[0].trim();
278
+ if (firstLine === command.command.trim()) {
279
+ outputLines.shift();
280
+ }
281
+ }
282
+ command.result = outputLines.join('\n').trim();
283
+ // Update in map
284
+ activeCommands.set(commandId, command);
285
+ }
286
+ return command;
287
+ }
288
+ // Get command by ID
289
+ export function getCommand(commandId) {
290
+ return activeCommands.get(commandId) || null;
291
+ }
292
+ // Get all active command IDs
293
+ export function getActiveCommandIds() {
294
+ return Array.from(activeCommands.keys());
295
+ }
296
+ // Clean up completed commands older than a certain time
297
+ export function cleanupOldCommands(maxAgeMinutes = 60) {
298
+ const now = new Date();
299
+ for (const [id, command] of activeCommands.entries()) {
300
+ const ageMinutes = (now.getTime() - command.startTime.getTime()) / (1000 * 60);
301
+ if (command.status !== 'pending' && ageMinutes > maxAgeMinutes) {
302
+ activeCommands.delete(id);
303
+ }
304
+ }
305
+ }
306
+ function getEndMarkerText(shellType) {
307
+ if (shellType === 'fish') {
308
+ return `${endMarkerPrefix}$status`;
309
+ }
310
+ if (shellType === 'fc_shell') {
311
+ return `${endMarkerPrefix}$::tmux_mcp_status`;
312
+ }
313
+ return `${endMarkerPrefix}$?`;
314
+ }
315
+ function buildWrappedCommand(command, shellType) {
316
+ const endMarkerText = getEndMarkerText(shellType);
317
+ return `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
318
+ }
319
+ function buildFcShellCommand(command) {
320
+ const escaped = escapeForTcl(command);
321
+ return `::tmux_mcp::run {${escaped}}`;
322
+ }
323
+ function escapeForTcl(command) {
324
+ return command
325
+ .replace(/\\/g, '\\\\')
326
+ .replace(/\r/g, '\\r')
327
+ .replace(/\n/g, '\\n')
328
+ .replace(/\{/g, '\\{')
329
+ .replace(/\}/g, '\\}');
330
+ }
331
+ async function ensureFcShellInitialized(paneId) {
332
+ if (fcShellInitializedPanes.has(paneId)) {
333
+ return;
334
+ }
335
+ const definitionCommand = [
336
+ 'namespace eval ::tmux_mcp {',
337
+ 'proc run {cmd} {',
338
+ `puts "${startMarkerText}";`,
339
+ 'set status [catch {uplevel #0 $cmd} result opts];',
340
+ 'if {$status == 0} {',
341
+ 'if {[info exists result] && $result ne ""} { puts $result }',
342
+ '} else {',
343
+ 'if {[info exists opts(-errorinfo)]} { puts $opts(-errorinfo) } else { puts $result }',
344
+ '};',
345
+ `puts "${endMarkerPrefix}$status"`,
346
+ '}',
347
+ '}'
348
+ ].join(' ');
349
+ const escapedCommand = definitionCommand.replace(/'/g, "'\\''");
350
+ await executeTmux(`send-keys -t '${paneId}' '${escapedCommand}' Enter`);
351
+ fcShellInitializedPanes.add(paneId);
352
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@arearseth/tmux-mcp",
3
+ "version": "0.2.2",
4
+ "description": "MCP Server for interfacing with tmux sessions",
5
+ "type": "module",
6
+ "main": "build/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node build/index.js",
10
+ "dev": "tsc -w",
11
+ "test": "vitest run",
12
+ "check-release": "npm run build && npm publish --dry-run",
13
+ "release": "npm run build && npm publish"
14
+ },
15
+ "bin": {
16
+ "tmux-mcp": "build/index.js"
17
+ },
18
+ "files": [
19
+ "build"
20
+ ],
21
+ "keywords": [
22
+ "mcp",
23
+ "tmux",
24
+ "claude"
25
+ ],
26
+ "author": "nickgnd",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.0.2",
30
+ "uuid": "^11.1.0",
31
+ "zod": "^3.22.4"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^20.10.5",
35
+ "@types/uuid": "^10.0.0",
36
+ "vitest": "^1.6.0",
37
+ "typescript": "^5.3.3"
38
+ },
39
+ "repository": "github:AreAArseth/tmux-mcp",
40
+ "bugs": {
41
+ "url": "https://github.com/AreAArseth/tmux-mcp/issues"
42
+ },
43
+ "homepage": "https://github.com/AreAArseth/tmux-mcp#readme"
44
+ }