feat: refactor MCP tools to use terminal names instead of IDs

- Replace terminalId parameter with terminalName across all tools
- Add window name uniqueness validation for create/rename operations
- Implement terminal name-to-window resolution in all MCP tools
- Add home directory tilde expansion in working directory paths
- Update terminal resources to use encoded names in URIs
- Remove terminal IDs from API responses (list-terminals, resources)
- Add type checking and error handling for Python agent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-14 17:46:29 +02:00
parent f3b8cc689d
commit 1bceb3cb4b
8 changed files with 1865 additions and 136 deletions
+126 -38
View File
@@ -47,7 +47,7 @@ async function ensureDefaultSession(): Promise<void> {
// List terminals - Tool
server.tool(
"list-terminals",
"List all terminals (isolated shell environments). Returns terminal ID, name, current working directory, and currently running command.",
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, and currently running command.",
{},
async () => {
try {
@@ -74,7 +74,6 @@ server.tool(
: "unknown";
return {
id: window.id,
name: window.name,
active: window.active,
workingDirectory,
@@ -153,7 +152,6 @@ server.tool(
let text = `Terminal created: ${JSON.stringify(
{
id: window.id,
name: window.name,
workingDirectory,
},
@@ -194,7 +192,7 @@ server.tool(
"capture-terminal",
"Capture the last N lines of output from a terminal. Use this to see command results, check status, or debug issues.",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
lines: z
.number()
.optional()
@@ -206,19 +204,36 @@ server.tool(
"Milliseconds to wait before capturing output. Useful for slow commands."
),
},
async ({ terminalId, lines, wait }) => {
async ({ terminalName, lines, wait }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Wait if specified
if (wait) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalId);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`);
throw new Error(`No pane found for terminal ${terminalName}`);
}
const content = await tmux.capturePaneContent(
@@ -254,7 +269,7 @@ server.tool(
"send-text",
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
text: z
.string()
.describe(
@@ -282,14 +297,31 @@ server.tool(
"Capture terminal output after sending text. Specify 'wait' for slow commands."
),
},
async ({ terminalId, text, pressEnter, return_output }) => {
async ({ terminalName, text, pressEnter, return_output }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalId);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`);
throw new Error(`No pane found for terminal ${terminalName}`);
}
const output = await tmux.sendText({
@@ -304,7 +336,7 @@ server.tool(
content: [
{
type: "text",
text: `Text sent to terminal ${terminalId}${
text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : ""
}.\n\n--- Output ---\n${output}`,
},
@@ -316,7 +348,7 @@ server.tool(
content: [
{
type: "text",
text: `Text sent to terminal ${terminalId}${
text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : ""
}.\n\nUse capture-terminal to verify the result.`,
},
@@ -341,7 +373,7 @@ server.tool(
"send-keys",
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
keys: z
.string()
.describe(
@@ -368,14 +400,31 @@ server.tool(
"Capture terminal output after sending keys. Specify 'wait' for slow commands."
),
},
async ({ terminalId, keys, repeat, return_output }) => {
async ({ terminalName, keys, repeat, return_output }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalId);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`);
throw new Error(`No pane found for terminal ${terminalName}`);
}
const output = await tmux.sendKeys({
@@ -390,7 +439,7 @@ server.tool(
content: [
{
type: "text",
text: `Keys '${keys}' sent to terminal ${terminalId}${
text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\n--- Output ---\n${output}`,
},
@@ -402,7 +451,7 @@ server.tool(
content: [
{
type: "text",
text: `Keys '${keys}' sent to terminal ${terminalId}${
text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\nUse capture-terminal to verify the result.`,
},
@@ -427,17 +476,24 @@ server.tool(
"rename-terminal",
"Rename a terminal to a more descriptive name",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
name: z.string().describe("New name for the terminal"),
terminalName: z.string().describe("Current name of the terminal"),
newName: z.string().describe("New name for the terminal"),
},
async ({ terminalId, name }) => {
async ({ terminalName, newName }) => {
try {
await tmux.renameWindow(terminalId, name);
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
await tmux.renameWindow(session.id, terminalName, newName);
return {
content: [
{
type: "text",
text: `Terminal ${terminalId} renamed to "${name}"`,
text: `Terminal "${terminalName}" renamed to "${newName}"`,
},
],
};
@@ -460,16 +516,33 @@ server.tool(
"kill-terminal",
"Close a terminal and end its shell session",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
},
async ({ terminalId }) => {
async ({ terminalName }) => {
try {
await tmux.killWindow(terminalId);
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
await tmux.killWindow(window.id);
return {
content: [
{
type: "text",
text: `Terminal ${terminalId} has been closed`,
text: `Terminal "${terminalName}" has been closed`,
},
],
};
@@ -511,7 +584,6 @@ server.resource("Terminals", "tmux://terminals", async () => {
: "unknown";
return {
id: window.id,
name: window.name,
active: window.active,
workingDirectory,
@@ -543,7 +615,7 @@ server.resource("Terminals", "tmux://terminals", async () => {
// Expose terminal content as a resource
server.resource(
"Terminal Content",
new ResourceTemplate("tmux://terminal/{terminalId}", {
new ResourceTemplate("tmux://terminal/{terminalName}", {
list: async () => {
try {
await ensureDefaultSession();
@@ -557,7 +629,7 @@ server.resource(
const terminalResources = windows.map((window) => ({
name: `Terminal: ${window.name} ${window.active ? "(active)" : ""}`,
uri: `tmux://terminal/${window.id}`,
uri: `tmux://terminal/${encodeURIComponent(window.name)}`,
description: `Content from terminal ${window.name}`,
}));
@@ -574,19 +646,35 @@ server.resource(
}
},
}),
async (uri, { terminalId }) => {
async (uri, { terminalName }) => {
try {
// Ensure terminalId is a string
const terminalIdStr = Array.isArray(terminalId)
? terminalId[0]
: terminalId;
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Ensure terminalName is a string
const terminalNameStr = Array.isArray(terminalName)
? terminalName[0]
: terminalName;
// Decode URI component in case terminal name has special characters
const decodedTerminalName = decodeURIComponent(terminalNameStr);
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, decodedTerminalName);
if (!window) {
throw new Error(`Terminal '${decodedTerminalName}' not found`);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalIdStr);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalIdStr}`);
throw new Error(`No pane found for terminal ${decodedTerminalName}`);
}
const content = await tmux.capturePaneContent(pane.id, 200, false);
+78 -5
View File
@@ -1,6 +1,7 @@
import { exec as execCallback } from "child_process";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import os from "node:os";
const exec = promisify(execCallback);
@@ -111,6 +112,32 @@ export async function findSessionByName(
}
}
/**
* Find a window by name in a session
*/
export async function findWindowByName(
sessionId: string,
name: string
): Promise<TmuxWindow | null> {
try {
const windows = await listWindows(sessionId);
return windows.find((window) => window.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Check if a window name is unique in a session
*/
export async function isWindowNameUnique(
sessionId: string,
name: string
): Promise<boolean> {
const window = await findWindowByName(sessionId, name);
return window === null;
}
/**
* List windows in a session
*/
@@ -237,6 +264,20 @@ export async function createSession(name: string): Promise<TmuxSession | null> {
return findSessionByName(name);
}
/**
* Expand tilde in path to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith('~/')) {
const homeDir = process.env.HOME || os.homedir();
return path.replace('~', homeDir);
}
if (path === '~') {
return process.env.HOME || os.homedir();
}
return path;
}
/**
* Create a new window in a session with optional working directory and initial command
*/
@@ -248,10 +289,20 @@ export async function createWindow(
command?: string;
}
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
// Validate name uniqueness
const isUnique = await isWindowNameUnique(sessionId, name);
if (!isUnique) {
throw new Error(
`Terminal with name '${name}' already exists. Please choose a unique name.`
);
}
// Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
if (options?.workingDirectory) {
newWindowCmd += ` -c '${options.workingDirectory}'`;
// Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`;
}
await executeTmux(newWindowCmd);
@@ -313,13 +364,35 @@ export async function killPane(paneId: string): Promise<void> {
}
/**
* Rename a tmux window by ID
* Rename a tmux window by name or ID
*/
export async function renameWindow(
windowId: string,
name: string
sessionId: string,
windowNameOrId: string,
newName: string
): Promise<void> {
await executeTmux(`rename-window -t '${windowId}' '${name}'`);
// Validate new name is unique
const isUnique = await isWindowNameUnique(sessionId, newName);
if (!isUnique) {
throw new Error(
`Terminal with name '${newName}' already exists. Please choose a unique name.`
);
}
// Check if windowNameOrId is a window ID (starts with @) or a name
let windowId: string;
if (windowNameOrId.startsWith("@")) {
windowId = windowNameOrId;
} else {
// Resolve name to ID
const window = await findWindowByName(sessionId, windowNameOrId);
if (!window) {
throw new Error(`Terminal '${windowNameOrId}' not found.`);
}
windowId = window.id;
}
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
// Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
}