# Directory Structure ``` ├── .gitignore ├── index.ts ├── package.json ├── README.md ├── src │ ├── handlers │ │ └── directory.ts │ ├── schemas │ │ └── directory.ts │ ├── types │ │ └── index.ts │ └── utils │ ├── command-executor.ts │ └── constants.ts ├── tsconfig.json └── yarn.lock ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules/ 3 | yarn-debug.log* 4 | yarn-error.log* 5 | npm-debug.log* 6 | 7 | # Build outputs 8 | dist/ 9 | build/ 10 | *.js 11 | *.js.map 12 | *.d.ts 13 | 14 | # Environment variables 15 | .env 16 | .env.local 17 | .env.*.local 18 | 19 | # IDE 20 | .vscode/ 21 | .idea/ 22 | *.sublime-project 23 | *.sublime-workspace 24 | 25 | # OS 26 | .DS_Store 27 | .DS_Store? 28 | ._* 29 | .Spotlight-V100 30 | .Trashes 31 | ehthumbs.db 32 | Thumbs.db 33 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Terminal Use 2 | 3 | A Model Context Protocol (MCP) server for terminal access. This server allows Claude to interact with specified directories on your system. 4 | 5 | ## Configuration 6 | 7 | ### Environment Variables 8 | 9 | The server can be configured using the following environment variables: 10 | 11 | - `ALLOWED_DIRECTORY`: The directory path that Claude is allowed to access (default: '${HOME}/your/allowed/directory') 12 | 13 | You can set these variables either through: 14 | 1. A `.env` file in your project root 15 | 2. Environment variables in your system 16 | 3. Direct configuration in claude_desktop_config.json 17 | 18 | ### Claude Desktop Configuration 19 | 20 | Add this to your `claude_desktop_config.json`: 21 | 22 | ```json 23 | { 24 | "mcpServers": { 25 | "terminal": { 26 | "command": "node", 27 | "args": [ 28 | "${HOME}/path/to/mcp-terminal-use/dist/index.js" 29 | ], 30 | "env": { 31 | "ALLOWED_DIRECTORY": "${HOME}/your/allowed/directory" 32 | } 33 | } 34 | } 35 | } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "outDir": "./dist", 11 | "rootDir": ".", 12 | "declaration": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "./**/*.ts" 17 | ], 18 | "exclude": [ 19 | "node_modules", 20 | "dist" 21 | ] 22 | } 23 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "terminal", 3 | "version": "0.5.1", 4 | "description": "MCP server for terminal access zh", 5 | "license": "MIT", 6 | "author": "Alex Man", 7 | "homepage": "", 8 | "bugs": "", 9 | "type": "module", 10 | "bin": { 11 | "mcp-terminal": "dist/index.js" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc && shx chmod +x dist/*.js", 18 | "prepare": "npm run build", 19 | "watch": "tsc --watch" 20 | }, 21 | "dependencies": { 22 | "@modelcontextprotocol/sdk": "0.5.0", 23 | "dotenv": "^16.4.7", 24 | "glob": "^10.3.10", 25 | "replicate": "^0.27.1", 26 | "zod-to-json-schema": "^3.23.5" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^20.11.0", 30 | "shx": "^0.3.4", 31 | "typescript": "^5.3.3" 32 | } 33 | } 34 | ``` -------------------------------------------------------------------------------- /src/schemas/directory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { isWithinAllowedDirectory, resolvePath, ALLOWED_DIRECTORY } from '../utils/constants.js'; 3 | 4 | export const MkdirSchema = z.object({ 5 | path: z.string().refine( 6 | (val) => isWithinAllowedDirectory(val), 7 | 'Directory must be created within allowed directory' 8 | ), 9 | }); 10 | 11 | export const CdSchema = z.object({ 12 | path: z.string().refine( 13 | (val) => { 14 | const targetPath = resolvePath(val); 15 | return targetPath.startsWith(ALLOWED_DIRECTORY); 16 | }, 17 | 'Can only change to directories within allowed directory' 18 | ), 19 | }); 20 | 21 | // Types derived from schemas 22 | export type MkdirArgs = z.infer<typeof MkdirSchema>; 23 | export type CdArgs = z.infer<typeof CdSchema>; 24 | 25 | // Validation functions 26 | export function validateMkdirArgs(args: unknown) { 27 | return MkdirSchema.safeParse(args); 28 | } 29 | 30 | export function validateCdArgs(args: unknown) { 31 | return CdSchema.safeParse(args); 32 | } 33 | ``` -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | // Base types for tool responses 4 | export interface ToolMeta { 5 | progressToken: null; 6 | } 7 | 8 | export interface ToolContent { 9 | type: string; 10 | text: string; 11 | } 12 | 13 | export interface ToolResponse { 14 | meta: ToolMeta; 15 | content: ToolContent[]; 16 | isError?: boolean; 17 | } 18 | 19 | // Generic handler type 20 | export type ToolHandler = (args: unknown) => Promise<ToolResponse>; 21 | 22 | // Schema validation result type 23 | export type ValidationResult<T> = { 24 | success: true; 25 | data: T; 26 | } | { 27 | success: false; 28 | error: z.ZodError; 29 | }; 30 | 31 | // Package manager types 32 | export interface PackageManagerOptions { 33 | packages?: string[]; 34 | flags?: string[]; 35 | dev?: boolean; 36 | } 37 | 38 | // Testing types 39 | export interface TestOptions { 40 | testPath?: string; 41 | watch?: boolean; 42 | mode?: 'run' | 'open'; 43 | spec?: string; 44 | } 45 | 46 | // Linting types 47 | export interface LintOptions { 48 | path?: string; 49 | fix?: boolean; 50 | write?: boolean; 51 | project?: string; 52 | } 53 | 54 | // Directory types 55 | export interface DirectoryOptions { 56 | path: string; 57 | } 58 | 59 | // Git types 60 | export interface GitOptions { 61 | path?: string; 62 | message?: string; 63 | patch?: string; 64 | staged?: boolean; 65 | } 66 | ``` -------------------------------------------------------------------------------- /src/handlers/directory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ToolResponse } from '../types/index.js'; 2 | import { validateMkdirArgs, validateCdArgs } from '../schemas/directory.js'; 3 | import { createToolResponse, createErrorResponse, execAsync } from '../utils/command-executor.js'; 4 | 5 | export async function handleMkdir(args: unknown): Promise<ToolResponse> { 6 | try { 7 | const parsed = validateMkdirArgs(args); 8 | if (!parsed.success) { 9 | throw new Error(`Invalid arguments for mkdir: ${parsed.error}`); 10 | } 11 | 12 | const { path: dirPath } = parsed.data; 13 | const { stdout, stderr } = await execAsync(`mkdir -p ${dirPath}`); 14 | 15 | return createToolResponse(stdout || "Directory created successfully", stderr); 16 | } catch (error) { 17 | return createErrorResponse(error); 18 | } 19 | } 20 | 21 | export async function handleCd(args: unknown): Promise<ToolResponse> { 22 | try { 23 | const parsed = validateCdArgs(args); 24 | if (!parsed.success) { 25 | throw new Error(`Invalid arguments for cd: ${parsed.error}`); 26 | } 27 | 28 | const { path: dirPath } = parsed.data; 29 | try { 30 | process.chdir(dirPath); 31 | return createToolResponse(`Changed directory to: ${process.cwd()}`); 32 | } catch (err) { 33 | const error = err as Error; 34 | throw new Error(`Failed to change directory: ${error.message}`); 35 | } 36 | } catch (error) { 37 | return createErrorResponse(error); 38 | } 39 | } 40 | ``` -------------------------------------------------------------------------------- /src/utils/command-executor.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { exec, spawn } from 'child_process'; 2 | import { promisify } from 'util'; 3 | 4 | // Promisify exec for async/await usage 5 | export const execAsync = promisify(exec); 6 | 7 | // Types for command execution results 8 | export interface CommandResult { 9 | stdout: string; 10 | stderr: string; 11 | } 12 | 13 | export interface ToolResponse { 14 | meta: { 15 | progressToken: null; 16 | }; 17 | content: Array<{ 18 | type: string; 19 | text: string; 20 | }>; 21 | isError?: boolean; 22 | } 23 | 24 | // Helper function to create a standard tool response 25 | export function createToolResponse(stdout: string, stderr?: string): ToolResponse { 26 | return { 27 | meta: { 28 | progressToken: null, 29 | }, 30 | content: [ 31 | { type: "text", text: stdout }, 32 | ...(stderr ? [{ type: "text", text: `Error: ${stderr}` }] : []), 33 | ], 34 | }; 35 | } 36 | 37 | // Helper function to create an error response 38 | export function createErrorResponse(error: unknown): ToolResponse { 39 | const errorMessage = error instanceof Error ? error.message : String(error); 40 | return { 41 | meta: { 42 | progressToken: null, 43 | }, 44 | content: [{ type: "text", text: `Error: ${errorMessage}` }], 45 | isError: true, 46 | }; 47 | } 48 | 49 | // Helper function to handle git apply with patch data 50 | export async function gitApplyWithPatch(patch: string): Promise<CommandResult> { 51 | return new Promise((resolve, reject) => { 52 | const git = spawn('git', ['apply']); 53 | let stdout = ''; 54 | let stderr = ''; 55 | 56 | git.stdout.on('data', (data) => { 57 | stdout += data.toString(); 58 | }); 59 | 60 | git.stderr.on('data', (data) => { 61 | stderr += data.toString(); 62 | }); 63 | 64 | git.on('close', (code) => { 65 | if (code === 0) { 66 | resolve({ stdout, stderr }); 67 | } else { 68 | reject(new Error(`git apply failed with code ${code}\n${stderr}`)); 69 | } 70 | }); 71 | 72 | git.stdin.write(patch); 73 | git.stdin.end(); 74 | }); 75 | } 76 | 77 | // Helper function to execute a command and return a tool response 78 | export async function executeCommand(command: string): Promise<ToolResponse> { 79 | try { 80 | const { stdout, stderr } = await execAsync(command); 81 | return createToolResponse(stdout, stderr); 82 | } catch (error) { 83 | return createErrorResponse(error); 84 | } 85 | } 86 | ``` -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- ```typescript 1 | import path from 'path'; 2 | import dotenv from 'dotenv'; 3 | dotenv.config(); 4 | 5 | // Allowed directory for command execution 6 | export const ALLOWED_DIRECTORY = process.env.ALLOWED_DIRECTORY as string 7 | 8 | // List of allowed commands 9 | export const ALLOWED_COMMANDS = [ 10 | // Git commands 11 | 'git diff', 12 | 'git diff --staged', 13 | 'git apply', 14 | 'git add -p', 15 | 'git init', 16 | 'git add', 17 | 'git commit', 18 | 'git status', 19 | 'git log', 20 | // Directory commands 21 | 'mkdir', 22 | 'cd', 23 | // NPM commands 24 | 'npm init', 25 | 'npm init -y', 26 | 'npm install', 27 | 'npm run', 28 | 'npm add', 29 | 'npm remove', 30 | 'npm create', 31 | // Yarn commands 32 | 'yarn init', 33 | 'yarn init -y', 34 | 'yarn install', 35 | 'yarn run', 36 | 'yarn add', 37 | 'yarn remove', 38 | 'yarn create', 39 | // Testing commands 40 | 'jest', 41 | 'vitest', 42 | 'cypress', 43 | // Linting and formatting 44 | 'eslint', 45 | 'prettier', 46 | 'tsc', 47 | // File editing commands 48 | 'sed', 49 | ] as const; 50 | 51 | // Helper function to check if a command is allowed with its options 52 | export function isAllowedCommand(command: string): boolean { 53 | // Extraer el comando base 54 | const baseCommand = command.split(' ')[0]; 55 | 56 | // Caso especial para sed 57 | if (baseCommand === 'sed') { 58 | // Verificar que comience con sed -i 59 | if (!command.startsWith('sed -i')) { 60 | return false; 61 | } 62 | 63 | // Extraer el path del archivo objetivo (último argumento) 64 | const matches = command.match(/.*\s+(\/[^\s]+)$/); 65 | if (!matches || !matches[1]) { 66 | return false; 67 | } 68 | 69 | const filePath = matches[1].replace(/['"]$/, ''); // Eliminar comillas al final si existen 70 | 71 | // Verificar que el archivo objetivo esté dentro del directorio permitido 72 | if (!isWithinAllowedDirectory(filePath)) { 73 | return false; 74 | } 75 | 76 | return true; 77 | } 78 | 79 | // Casos especiales para npm create y yarn create 80 | if (command.startsWith('npm create') || command.startsWith('yarn create')) { 81 | return true; 82 | } 83 | 84 | // Para otros comandos, mantener la lógica existente 85 | const commandStart = command.split(' ').slice(0, 2).join(' '); 86 | return ALLOWED_COMMANDS.some(cmd => { 87 | if (command.startsWith(cmd)) return true; 88 | if (commandStart === cmd) return true; 89 | return false; 90 | }); 91 | } 92 | 93 | // Helper function to check if a path is within allowed directory 94 | export function isWithinAllowedDirectory(targetPath: string): boolean { 95 | const currentDir = process.cwd(); 96 | const absolutePath = path.isAbsolute(targetPath) 97 | ? path.resolve(targetPath) 98 | : path.resolve(currentDir, targetPath); 99 | return absolutePath.startsWith(ALLOWED_DIRECTORY); 100 | } 101 | 102 | // Helper function to resolve path considering current directory 103 | export function resolvePath(targetPath: string): string { 104 | return path.isAbsolute(targetPath) 105 | ? path.resolve(targetPath) 106 | : path.resolve(process.cwd(), targetPath); 107 | } 108 | ``` -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | ToolSchema, 9 | } from "@modelcontextprotocol/sdk/types.js"; 10 | import { z } from "zod"; 11 | import { zodToJsonSchema } from "zod-to-json-schema"; 12 | 13 | // Import schemas 14 | import { MkdirSchema, CdSchema } from './src/schemas/directory.js'; 15 | 16 | // Import handlers 17 | import { handleMkdir, handleCd } from './src/handlers/directory.js'; 18 | 19 | // Import utils 20 | import { isAllowedCommand, isWithinAllowedDirectory } from './src/utils/constants.js'; 21 | import { createErrorResponse, execAsync, createToolResponse } from './src/utils/command-executor.js'; 22 | 23 | const ToolInputSchema = ToolSchema.shape.inputSchema; 24 | type ToolInput = z.infer<typeof ToolInputSchema>; 25 | 26 | // Execute command schema 27 | const ExecuteCommandSchema = z.object({ 28 | command: z.string().refine( 29 | (val) => { 30 | // Directory commands 31 | if (val.startsWith('cd') || val.startsWith('mkdir')) { 32 | const parts = val.split(' '); 33 | if (parts.length < 2) return false; 34 | const dirPath = parts[1]; 35 | return isWithinAllowedDirectory(dirPath); 36 | } 37 | 38 | // Check if command is allowed with its options 39 | return isAllowedCommand(val); 40 | }, 41 | 'Command not allowed or path is outside allowed directory' 42 | ), 43 | }); 44 | 45 | // Server setup 46 | const server = new Server( 47 | { 48 | name: "terminal-server", 49 | version: "0.2.1", 50 | }, 51 | { 52 | capabilities: { 53 | tools: {}, 54 | }, 55 | }, 56 | ); 57 | 58 | // Tool handlers 59 | server.setRequestHandler(ListToolsRequestSchema, async () => { 60 | return { 61 | tools: [ 62 | { 63 | name: "execute_command", 64 | description: "Execute a terminal command and get its output. Only allowed commands are permitted and must be within the /Users/{username}/the/path/you/use directory.", 65 | inputSchema: zodToJsonSchema(ExecuteCommandSchema) as ToolInput, 66 | }, 67 | { 68 | name: "mkdir", 69 | description: "Create a new directory within the allowed directory.", 70 | inputSchema: zodToJsonSchema(MkdirSchema) as ToolInput, 71 | }, 72 | { 73 | name: "cd", 74 | description: "Change to any directory within the allowed directory or its subdirectories.", 75 | inputSchema: zodToJsonSchema(CdSchema) as ToolInput, 76 | }, 77 | ], 78 | }; 79 | }); 80 | 81 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 82 | try { 83 | const { name, arguments: args } = request.params; 84 | 85 | let response; 86 | switch (name) { 87 | case "execute_command": { 88 | const parsed = ExecuteCommandSchema.safeParse(args); 89 | if (!parsed.success) { 90 | throw new Error(`Invalid arguments for execute_command: ${parsed.error}`); 91 | } 92 | 93 | const { command } = parsed.data; 94 | const { stdout, stderr } = await execAsync(command); 95 | response = createToolResponse(stdout, stderr); 96 | break; 97 | } 98 | 99 | case "mkdir": 100 | response = await handleMkdir(args); 101 | break; 102 | 103 | case "cd": 104 | response = await handleCd(args); 105 | break; 106 | 107 | default: 108 | throw new Error(`Unknown tool: ${name}`); 109 | } 110 | 111 | // Convert ToolResponse to the expected format 112 | return { 113 | _meta: { 114 | progressToken: null 115 | }, 116 | content: response.content 117 | }; 118 | } catch (error) { 119 | const errorResponse = createErrorResponse(error); 120 | return { 121 | _meta: { 122 | progressToken: null 123 | }, 124 | content: errorResponse.content 125 | }; 126 | } 127 | }); 128 | 129 | // Start server 130 | async function runServer() { 131 | const transport = new StdioServerTransport(); 132 | await server.connect(transport); 133 | console.error("Terminal MCP Server running on localhost"); 134 | } 135 | 136 | runServer().catch((error) => { 137 | console.error("Fatal error running server:", error); 138 | process.exit(1); 139 | }); 140 | ```