Skip to content

Commit 7771ab9

Browse files
committed
Merge branch 'develop'
2 parents 5038667 + 35a9d14 commit 7771ab9

File tree

5 files changed

+96
-15
lines changed

5 files changed

+96
-15
lines changed

README.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ A secure shell command execution server implementing the Model Context Protocol
88
* **Standard Input Support**: Pass input to commands via stdin
99
* **Comprehensive Output**: Returns stdout, stderr, exit status, and execution time
1010
* **Shell Operator Safety**: Validates commands after shell operators (; , &&, ||, |)
11+
* **Timeout Control**: Set maximum execution time for commands
1112

1213
## MCP client setting in your Claude.app
1314

@@ -97,6 +98,19 @@ ALLOW_COMMANDS="ls, cat , echo" # Multiple spaces
9798
"command": ["cat"],
9899
"stdin": "Hello, World!"
99100
}
101+
102+
# Command with timeout
103+
{
104+
"command": ["long-running-process"],
105+
"timeout": 30 # Maximum execution time in seconds
106+
}
107+
108+
# Command with working directory and timeout
109+
{
110+
"command": ["grep", "-r", "pattern"],
111+
"directory": "/path/to/search",
112+
"timeout": 60
113+
}
100114
```
101115

102116
### Response Format
@@ -159,10 +173,12 @@ pytest
159173

160174
### Request Arguments
161175

162-
| Field | Type | Required | Description |
163-
|----------|------------|----------|-----------------------------------------------|
164-
| command | string[] | Yes | Command and its arguments as array elements |
165-
| stdin | string | No | Input to be passed to the command |
176+
| Field | Type | Required | Description |
177+
|-----------|------------|----------|-----------------------------------------------|
178+
| command | string[] | Yes | Command and its arguments as array elements |
179+
| stdin | string | No | Input to be passed to the command |
180+
| directory | string | No | Working directory for command execution |
181+
| timeout | integer | No | Maximum execution time in seconds |
166182

167183
### Response Fields
168184

mcp_shell_server/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ def get_tool_description(self) -> Tool:
4949
"type": "string",
5050
"description": "Working directory where the command will be executed",
5151
},
52+
"timeout": {
53+
"type": "integer",
54+
"description": "Maximum execution time in seconds",
55+
"minimum": 0,
56+
},
5257
},
5358
"required": ["command"],
5459
},
@@ -59,11 +64,12 @@ async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
5964
command = arguments.get("command", [])
6065
stdin = arguments.get("stdin")
6166
directory = arguments.get("directory")
67+
timeout = arguments.get("timeout")
6268

6369
if not command:
6470
raise ValueError("No command provided")
6571

66-
result = await self.executor.execute(command, stdin, directory)
72+
result = await self.executor.execute(command, stdin, directory, timeout)
6773

6874
# Raise error if command execution failed
6975
if result.get("error"):

mcp_shell_server/shell_executor.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ async def execute(
103103
command: List[str],
104104
stdin: Optional[str] = None,
105105
directory: Optional[str] = None,
106+
timeout: Optional[int] = None,
106107
) -> Dict[str, Any]:
107108
"""
108109
Execute a shell command with optional stdin input and working directory.
@@ -111,6 +112,7 @@ async def execute(
111112
command (List[str]): Command and its arguments
112113
stdin (Optional[str]): Input to be passed to the command via stdin
113114
directory (Optional[str]): Working directory for command execution
115+
timeout (Optional[int]): Timeout for command execution in seconds
114116
115117
Returns:
116118
Dict[str, Any]: Execution result containing stdout, stderr, status code, and execution time.
@@ -146,17 +148,36 @@ async def execute(
146148
cwd=directory, # Set working directory if specified
147149
)
148150

149-
stdin_bytes = stdin.encode() if stdin else None
150-
stdout, stderr = await process.communicate(input=stdin_bytes)
151+
try:
152+
stdin_bytes = stdin.encode() if stdin else None
153+
stdout, stderr = await asyncio.wait_for(
154+
process.communicate(input=stdin_bytes), timeout=timeout
155+
)
156+
157+
return {
158+
"error": None,
159+
"stdout": stdout.decode() if stdout else "",
160+
"stderr": stderr.decode() if stderr else "",
161+
"status": process.returncode,
162+
"execution_time": time.time() - start_time,
163+
"directory": directory,
164+
}
165+
166+
except asyncio.TimeoutError:
167+
try:
168+
process.kill()
169+
await process.wait()
170+
except ProcessLookupError:
171+
pass
172+
173+
return {
174+
"error": f"Command timed out after {timeout} seconds",
175+
"status": -1,
176+
"stdout": "",
177+
"stderr": f"Command timed out after {timeout} seconds",
178+
"execution_time": time.time() - start_time,
179+
}
151180

152-
return {
153-
"error": None, # Set error field to None for success case
154-
"stdout": stdout.decode() if stdout else "",
155-
"stderr": stderr.decode() if stderr else "",
156-
"status": process.returncode,
157-
"execution_time": time.time() - start_time,
158-
"directory": directory, # Include working directory in response
159-
}
160181
except FileNotFoundError:
161182
return {
162183
"error": f"Command not found: {cleaned_command[0]}",

tests/test_server.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,20 @@ async def test_call_tool_with_nested_directory(temp_test_dir, monkeypatch):
168168
)
169169
assert isinstance(result[0], TextContent)
170170
assert result[0].text.strip() == nested_real_path
171+
172+
173+
@pytest.mark.asyncio
174+
async def test_call_tool_with_timeout(monkeypatch):
175+
"""Test command execution with timeout"""
176+
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
177+
with pytest.raises(RuntimeError) as excinfo:
178+
await call_tool("shell_execute", {"command": ["sleep", "2"], "timeout": 1})
179+
assert "Command timed out after 1 seconds" in str(excinfo.value)
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_call_tool_completes_within_timeout(monkeypatch):
184+
"""Test command that completes within timeout period"""
185+
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
186+
result = await call_tool("shell_execute", {"command": ["sleep", "1"], "timeout": 2})
187+
assert len(result) == 0 # sleep command produces no output

tests/test_shell_executor.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,24 @@ async def test_execute_with_nested_directory(executor, temp_test_dir, monkeypatc
163163
assert result["error"] is None
164164
assert result["status"] == 0
165165
assert result["stdout"].strip() == nested_real_path
166+
167+
168+
@pytest.mark.asyncio
169+
async def test_command_timeout(executor, monkeypatch):
170+
"""Test command timeout functionality"""
171+
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
172+
result = await executor.execute(["sleep", "2"], timeout=1)
173+
assert result["error"] == "Command timed out after 1 seconds"
174+
assert result["status"] == -1
175+
assert result["stdout"] == ""
176+
assert result["stderr"] == "Command timed out after 1 seconds"
177+
178+
179+
@pytest.mark.asyncio
180+
async def test_command_completes_within_timeout(executor, monkeypatch):
181+
"""Test command that completes within timeout period"""
182+
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
183+
result = await executor.execute(["sleep", "1"], timeout=2)
184+
assert result["error"] is None
185+
assert result["status"] == 0
186+
assert result["stdout"] == ""

0 commit comments

Comments
 (0)