Skip to content

Commit b53f8d0

Browse files
committed
Merge branch 'feature/make-directory-required' into develop
2 parents a6a3ee6 + aff3085 commit b53f8d0

File tree

6 files changed

+99
-64
lines changed

6 files changed

+99
-64
lines changed

src/mcp_shell_server/server.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@ def get_tool_description(self) -> Tool:
5656
"minimum": 0,
5757
},
5858
},
59-
"required": ["command"],
59+
"required": ["command", "directory"],
6060
},
6161
)
6262

6363
async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
6464
"""Execute the shell command with the given arguments"""
6565
command = arguments.get("command", [])
6666
stdin = arguments.get("stdin")
67-
directory = arguments.get("directory")
67+
directory = arguments.get("directory", "/tmp") # default to /tmp for safety
6868
timeout = arguments.get("timeout")
6969

7070
if not command:
@@ -73,7 +73,11 @@ async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
7373
if not isinstance(command, list):
7474
raise ValueError("'command' must be an array")
7575

76-
result = await self.executor.execute(command, stdin, directory, timeout)
76+
# Make sure directory exists
77+
if not directory:
78+
raise ValueError("Directory is required")
79+
80+
result = await self.executor.execute(command, directory, stdin, timeout)
7781

7882
# Raise error if command execution failed
7983
if result.get("error"):

src/mcp_shell_server/shell_executor.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,14 @@ def _validate_directory(self, directory: Optional[str]) -> None:
238238
directory (Optional[str]): Directory path to validate
239239
240240
Raises:
241-
ValueError: If the directory doesn't exist or is not accessible
241+
ValueError: If the directory doesn't exist, not absolute or is not accessible
242242
"""
243+
# make directory required
243244
if directory is None:
244-
return
245-
245+
raise ValueError("Directory is required")
246+
# verify directory is absolute path
247+
if not os.path.isabs(directory):
248+
raise ValueError(f"Directory must be an absolute path: {directory}")
246249
if not os.path.exists(directory):
247250
raise ValueError(f"Directory does not exist: {directory}")
248251
if not os.path.isdir(directory):
@@ -371,8 +374,8 @@ def _preprocess_command(self, command: List[str]) -> List[str]:
371374
async def execute(
372375
self,
373376
command: List[str],
377+
directory: str,
374378
stdin: Optional[str] = None,
375-
directory: Optional[str] = None,
376379
timeout: Optional[int] = None,
377380
) -> Dict[str, Any]:
378381
start_time = time.time()

tests/test_server.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,31 @@ async def test_list_tools():
2727
assert tool.inputSchema["type"] == "object"
2828
assert "command" in tool.inputSchema["properties"]
2929
assert "stdin" in tool.inputSchema["properties"]
30-
assert "directory" in tool.inputSchema["properties"] # New assertion
31-
assert tool.inputSchema["required"] == ["command"]
30+
assert "directory" in tool.inputSchema["properties"]
31+
assert tool.inputSchema["required"] == ["command", "directory"]
3232

3333

3434
@pytest.mark.asyncio
35-
async def test_call_tool_valid_command(monkeypatch):
35+
async def test_call_tool_valid_command(monkeypatch, temp_test_dir):
3636
"""Test execution of a valid command"""
3737
monkeypatch.setenv("ALLOW_COMMANDS", "echo")
38-
result = await call_tool("shell_execute", {"command": ["echo", "hello world"]})
38+
result = await call_tool(
39+
"shell_execute",
40+
{"command": ["echo", "hello world"], "directory": temp_test_dir},
41+
)
3942
assert len(result) == 1
4043
assert isinstance(result[0], TextContent)
4144
assert result[0].type == "text"
4245
assert result[0].text.strip() == "hello world"
4346

4447

4548
@pytest.mark.asyncio
46-
async def test_call_tool_with_stdin(monkeypatch):
49+
async def test_call_tool_with_stdin(monkeypatch, temp_test_dir):
4750
"""Test command execution with stdin"""
4851
monkeypatch.setenv("ALLOW_COMMANDS", "cat")
4952
result = await call_tool(
50-
"shell_execute", {"command": ["cat"], "stdin": "test input"}
53+
"shell_execute",
54+
{"command": ["cat"], "stdin": "test input", "directory": temp_test_dir},
5155
)
5256
assert len(result) == 1
5357
assert isinstance(result[0], TextContent)
@@ -56,11 +60,14 @@ async def test_call_tool_with_stdin(monkeypatch):
5660

5761

5862
@pytest.mark.asyncio
59-
async def test_call_tool_invalid_command(monkeypatch):
63+
async def test_call_tool_invalid_command(monkeypatch, temp_test_dir):
6064
"""Test execution of an invalid command"""
6165
monkeypatch.setenv("ALLOW_COMMANDS", "echo")
6266
with pytest.raises(RuntimeError) as excinfo:
63-
await call_tool("shell_execute", {"command": ["invalid_command"]})
67+
await call_tool(
68+
"shell_execute",
69+
{"command": ["invalid_command"], "directory": temp_test_dir},
70+
)
6471
assert "Command not allowed: invalid_command" in str(excinfo.value)
6572

6673

tests/test_shell_executor.py

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,67 +21,69 @@ def temp_test_dir():
2121

2222

2323
@pytest.mark.asyncio
24-
async def test_basic_command_execution(executor, monkeypatch):
24+
async def test_basic_command_execution(executor, temp_test_dir, monkeypatch):
2525
monkeypatch.setenv("ALLOW_COMMANDS", "echo")
26-
result = await executor.execute(["echo", "hello"])
26+
result = await executor.execute(["echo", "hello"], temp_test_dir)
2727
assert result["stdout"].strip() == "hello"
2828
assert result["status"] == 0
2929

3030

3131
@pytest.mark.asyncio
32-
async def test_stdin_input(executor, monkeypatch):
32+
async def test_stdin_input(executor, temp_test_dir, monkeypatch):
3333
monkeypatch.setenv("ALLOW_COMMANDS", "cat")
34-
result = await executor.execute(["cat"], stdin="hello world")
34+
result = await executor.execute(["cat"], temp_test_dir, stdin="hello world")
3535
assert result["stdout"].strip() == "hello world"
3636
assert result["status"] == 0
3737

3838

3939
@pytest.mark.asyncio
40-
async def test_command_with_space_allowed(executor, monkeypatch):
40+
async def test_command_with_space_allowed(executor, temp_test_dir, monkeypatch):
4141
monkeypatch.setenv("ALLOW_COMMANDS", "cat")
42-
result = await executor.execute(["cat "], stdin="hello world")
42+
result = await executor.execute(["cat "], temp_test_dir, stdin="hello world")
4343
assert result["error"] is None
4444
assert result["stdout"].strip() == "hello world"
4545
assert result["status"] == 0
4646

4747

4848
@pytest.mark.asyncio
49-
async def test_command_not_allowed(executor, monkeypatch):
49+
async def test_command_not_allowed(executor, temp_test_dir, monkeypatch):
5050
monkeypatch.setenv("ALLOW_COMMANDS", "ls")
51-
result = await executor.execute(["rm", "-rf", "/"])
51+
result = await executor.execute(["rm", "-rf", "/"], temp_test_dir)
5252
assert result["error"] == "Command not allowed: rm"
5353
assert result["status"] == 1
5454

5555

5656
@pytest.mark.asyncio
57-
async def test_empty_command(executor):
58-
result = await executor.execute([])
57+
async def test_empty_command(executor, temp_test_dir):
58+
result = await executor.execute([], temp_test_dir)
5959
assert result["error"] == "Empty command"
6060
assert result["status"] == 1
6161

6262

6363
@pytest.mark.asyncio
64-
async def test_command_with_space_in_allow_commands(executor, monkeypatch):
64+
async def test_command_with_space_in_allow_commands(
65+
executor, temp_test_dir, monkeypatch
66+
):
6567
monkeypatch.setenv("ALLOW_COMMANDS", "ls, echo ,cat")
66-
result = await executor.execute(["echo", "test"])
68+
result = await executor.execute(["echo", "test"], temp_test_dir)
6769
assert result["stdout"].strip() == "test"
6870
assert result["status"] == 0
6971

7072

7173
@pytest.mark.asyncio
72-
async def test_multiple_commands_with_operator(executor, monkeypatch):
74+
async def test_multiple_commands_with_operator(executor, temp_test_dir, monkeypatch):
7375
monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls")
74-
result = await executor.execute(["echo", "hello", ";"])
76+
result = await executor.execute(["echo", "hello", ";"], temp_test_dir)
7577
assert result["error"] == "Unexpected shell operator: ;"
7678
assert result["status"] == 1
7779

7880

7981
@pytest.mark.asyncio
80-
async def test_shell_operators_not_allowed(executor, monkeypatch):
82+
async def test_shell_operators_not_allowed(executor, temp_test_dir, monkeypatch):
8183
monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls,true")
8284
operators = [";", "&&", "||"]
8385
for op in operators:
84-
result = await executor.execute(["echo", "hello", op, "true"])
86+
result = await executor.execute(["echo", "hello", op, "true"], temp_test_dir)
8587
assert result["error"] == f"Unexpected shell operator: {op}"
8688
assert result["status"] == 1
8789

@@ -140,17 +142,6 @@ async def test_execute_with_file_as_directory(executor, temp_test_dir, monkeypat
140142
assert result["status"] == 1
141143

142144

143-
@pytest.mark.asyncio
144-
async def test_execute_with_no_directory_specified(executor, monkeypatch):
145-
"""Test command execution without specifying a directory"""
146-
monkeypatch.setenv("ALLOW_COMMANDS", "pwd")
147-
result = await executor.execute(["pwd"])
148-
assert result["error"] is None
149-
assert result["status"] == 0
150-
assert os.path.exists(result["stdout"].strip())
151-
152-
153-
@pytest.mark.asyncio
154145
async def test_execute_with_nested_directory(executor, temp_test_dir, monkeypatch):
155146
"""Test command execution in a nested directory"""
156147
monkeypatch.setenv("ALLOW_COMMANDS", "pwd,mkdir,ls")
@@ -167,54 +158,54 @@ async def test_execute_with_nested_directory(executor, temp_test_dir, monkeypatc
167158

168159

169160
@pytest.mark.asyncio
170-
async def test_command_timeout(executor, monkeypatch):
161+
async def test_command_timeout(executor, temp_test_dir, monkeypatch):
171162
"""Test command timeout functionality"""
172163
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
173-
result = await executor.execute(["sleep", "2"], timeout=1)
164+
result = await executor.execute(["sleep", "2"], temp_test_dir, timeout=1)
174165
assert result["error"] == "Command timed out after 1 seconds"
175166
assert result["status"] == -1
176167
assert result["stdout"] == ""
177168
assert result["stderr"] == "Command timed out after 1 seconds"
178169

179170

180171
@pytest.mark.asyncio
181-
async def test_command_completes_within_timeout(executor, monkeypatch):
172+
async def test_command_completes_within_timeout(executor, temp_test_dir, monkeypatch):
182173
"""Test command that completes within timeout period"""
183174
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
184-
result = await executor.execute(["sleep", "1"], timeout=2)
175+
result = await executor.execute(["sleep", "1"], temp_test_dir, timeout=2)
185176
assert result["error"] is None
186177
assert result["status"] == 0
187178
assert result["stdout"] == ""
188179

189180

190181
@pytest.mark.asyncio
191-
async def test_allowed_commands_alias(executor, monkeypatch):
182+
async def test_allowed_commands_alias(executor, temp_test_dir, monkeypatch):
192183
"""Test ALLOWED_COMMANDS alias functionality"""
193184
monkeypatch.setenv("ALLOWED_COMMANDS", "echo")
194-
result = await executor.execute(["echo", "hello"])
185+
result = await executor.execute(["echo", "hello"], temp_test_dir)
195186
assert result["stdout"].strip() == "hello"
196187
assert result["status"] == 0
197188

198189

199190
@pytest.mark.asyncio
200-
async def test_both_allow_commands_vars(executor, monkeypatch):
191+
async def test_both_allow_commands_vars(executor, temp_test_dir, monkeypatch):
201192
"""Test both ALLOW_COMMANDS and ALLOWED_COMMANDS working together"""
202193
monkeypatch.setenv("ALLOW_COMMANDS", "echo")
203194
monkeypatch.setenv("ALLOWED_COMMANDS", "cat")
204195

205196
# Test command from ALLOW_COMMANDS
206-
result1 = await executor.execute(["echo", "hello"])
197+
result1 = await executor.execute(["echo", "hello"], temp_test_dir)
207198
assert result1["stdout"].strip() == "hello"
208199
assert result1["status"] == 0
209200

210201
# Test command from ALLOWED_COMMANDS
211-
result2 = await executor.execute(["cat"], stdin="world")
202+
result2 = await executor.execute(["cat"], temp_test_dir, stdin="world")
212203
assert result2["stdout"].strip() == "world"
213204
assert result2["status"] == 0
214205

215206

216207
@pytest.mark.asyncio
217-
async def test_allow_commands_precedence(executor, monkeypatch):
208+
async def test_allow_commands_precedence(executor, temp_test_dir, monkeypatch):
218209
"""Test that commands are combined from both environment variables"""
219210
monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls")
220211
monkeypatch.setenv("ALLOWED_COMMANDS", "echo,cat")
@@ -224,10 +215,12 @@ async def test_allow_commands_precedence(executor, monkeypatch):
224215

225216

226217
@pytest.mark.asyncio
227-
async def test_pipe_operator(executor, monkeypatch):
218+
async def test_pipe_operator(executor, temp_test_dir, monkeypatch):
228219
"""Test that pipe operator works correctly"""
229220
monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep")
230-
result = await executor.execute(["echo", "hello\nworld", "|", "grep", "world"])
221+
result = await executor.execute(
222+
["echo", "hello\nworld", "|", "grep", "world"], temp_test_dir
223+
)
231224
assert result["error"] is None
232225
assert result["status"] == 0
233226
assert result["stdout"].strip() == "world"
@@ -237,12 +230,15 @@ async def test_pipe_operator(executor, monkeypatch):
237230
async def test_pipe_commands(executor, temp_test_dir, monkeypatch):
238231
"""Test piping commands together"""
239232
monkeypatch.setenv("ALLOW_COMMANDS", "echo,grep,cut,tr")
240-
result = await executor.execute(["echo", "hello world", "|", "grep", "world"])
233+
result = await executor.execute(
234+
["echo", "hello world", "|", "grep", "world"], temp_test_dir
235+
)
241236
assert result["stdout"].strip() == "hello world"
242237

243238
# Test multiple pipes
244239
result = await executor.execute(
245-
["echo", "hello world", "|", "cut", "-d", " ", "-f2", "|", "tr", "a-z", "A-Z"]
240+
["echo", "hello world", "|", "cut", "-d", " ", "-f2", "|", "tr", "a-z", "A-Z"],
241+
temp_test_dir,
246242
)
247243
assert result["stdout"].strip() == "WORLD"
248244

tests/test_shell_executor_error_cases.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import asyncio
2+
import os
3+
import tempfile
24

35
import pytest
46

57
from mcp_shell_server.shell_executor import ShellExecutor
68

79

10+
@pytest.fixture
11+
def temp_test_dir():
12+
"""Create a temporary directory for testing"""
13+
with tempfile.TemporaryDirectory() as tmpdirname:
14+
# Return the real path to handle macOS /private/tmp symlink
15+
yield os.path.realpath(tmpdirname)
16+
17+
818
@pytest.mark.asyncio
919
async def test_empty_command_validation():
1020
"""Test validation of empty commands"""
@@ -43,23 +53,23 @@ async def test_shell_operator_validation():
4353

4454

4555
@pytest.mark.asyncio
46-
async def test_process_execution_timeout(monkeypatch):
56+
async def test_process_execution_timeout(monkeypatch, temp_test_dir):
4757
"""Test process execution timeout handling"""
4858
monkeypatch.setenv("ALLOW_COMMANDS", "sleep")
4959
executor = ShellExecutor()
5060

5161
# プロセスのタイムアウトをテスト
5262
command = ["sleep", "5"]
5363
with pytest.raises(asyncio.TimeoutError):
54-
await asyncio.wait_for(executor.execute(command), timeout=0.1)
64+
await asyncio.wait_for(executor.execute(command, temp_test_dir), timeout=0.1)
5565

5666

5767
@pytest.mark.asyncio
58-
async def test_process_failure(monkeypatch):
68+
async def test_process_failure(monkeypatch, temp_test_dir):
5969
"""Test handling of process execution failure"""
6070
monkeypatch.setenv("ALLOW_COMMANDS", "false")
6171
executor = ShellExecutor()
6272

6373
# falseコマンドは常に終了コード1を返す
64-
result = await executor.execute(["false"])
74+
result = await executor.execute(["false"], temp_test_dir)
6575
assert result["status"] == 1

0 commit comments

Comments
 (0)