Skip to content

Commit 0109538

Browse files
committed
feat(server.py): add directory parameter to command execution for specifying working directory
feat(shell_executor.py): implement directory validation and support for executing commands in specified directory test(tests): add tests for command execution with directory functionality to ensure correct behavior
1 parent 50f54ee commit 0109538

File tree

6 files changed

+331
-7
lines changed

6 files changed

+331
-7
lines changed

mcp_shell_server/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def get_tool_description(self) -> Tool:
4545
"type": "string",
4646
"description": "Input to be passed to the command via stdin",
4747
},
48+
"directory": {
49+
"type": "string",
50+
"description": "Working directory where the command will be executed",
51+
},
4852
},
4953
"required": ["command"],
5054
},
@@ -54,11 +58,12 @@ async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
5458
"""Execute the shell command with the given arguments"""
5559
command = arguments.get("command", [])
5660
stdin = arguments.get("stdin")
61+
directory = arguments.get("directory")
5762

5863
if not command:
5964
raise ValueError("No command provided")
6065

61-
result = await self.executor.execute(command, stdin)
66+
result = await self.executor.execute(command, stdin, directory)
6267

6368
# Raise error if command execution failed
6469
if result.get("error"):
@@ -115,4 +120,4 @@ async def main() -> None:
115120
)
116121
except Exception as e:
117122
logger.error(f"Server error: {str(e)}")
118-
raise
123+
raise

mcp_shell_server/shell_executor.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,19 +74,40 @@ def _validate_command(self, command: List[str]) -> None:
7474
f"Command not allowed after {cleaned_arg}: {next_cmd}"
7575
)
7676

77+
def _validate_directory(self, directory: Optional[str]) -> None:
78+
"""
79+
Validate if the directory exists and is accessible.
80+
81+
Args:
82+
directory (Optional[str]): Directory path to validate
83+
84+
Raises:
85+
ValueError: If the directory doesn't exist or is not accessible
86+
"""
87+
if directory is None:
88+
return
89+
90+
if not os.path.exists(directory):
91+
raise ValueError(f"Directory does not exist: {directory}")
92+
if not os.path.isdir(directory):
93+
raise ValueError(f"Not a directory: {directory}")
94+
if not os.access(directory, os.R_OK | os.X_OK):
95+
raise ValueError(f"Directory is not accessible: {directory}")
96+
7797
def get_allowed_commands(self) -> list[str]:
7898
"""Get the allowed commands"""
7999
return list(self._get_allowed_commands())
80100

81101
async def execute(
82-
self, command: List[str], stdin: Optional[str] = None
102+
self, command: List[str], stdin: Optional[str] = None, directory: Optional[str] = None
83103
) -> Dict[str, Any]:
84104
"""
85-
Execute a shell command with optional stdin input.
105+
Execute a shell command with optional stdin input and working directory.
86106
87107
Args:
88108
command (List[str]): Command and its arguments
89109
stdin (Optional[str]): Input to be passed to the command via stdin
110+
directory (Optional[str]): Working directory for command execution
90111
91112
Returns:
92113
Dict[str, Any]: Execution result containing stdout, stderr, status code, and execution time.
@@ -101,6 +122,7 @@ async def execute(
101122
raise ValueError("Empty command")
102123

103124
self._validate_command(cleaned_command)
125+
self._validate_directory(directory)
104126
except ValueError as e:
105127
return {
106128
"error": str(e),
@@ -118,6 +140,7 @@ async def execute(
118140
stdout=asyncio.subprocess.PIPE,
119141
stderr=asyncio.subprocess.PIPE,
120142
env={"PATH": os.environ.get("PATH", "")},
143+
cwd=directory, # Set working directory if specified
121144
)
122145

123146
stdin_bytes = stdin.encode() if stdin else None
@@ -129,6 +152,7 @@ async def execute(
129152
"stderr": stderr.decode() if stderr else "",
130153
"status": process.returncode,
131154
"execution_time": time.time() - start_time,
155+
"directory": directory, # Include working directory in response
132156
}
133157
except FileNotFoundError:
134158
return {
@@ -145,4 +169,4 @@ async def execute(
145169
"stdout": "",
146170
"stderr": str(e),
147171
"execution_time": time.time() - start_time,
148-
}
172+
}

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
[project]
32
name = "mcp-shell-server"
43
version = "0.1.0"
@@ -22,6 +21,7 @@ test = [
2221
"pytest>=7.4.0",
2322
"pytest-asyncio>=0.23.0",
2423
"pytest-env>=1.1.0",
24+
"pytest-cov>=4.1.0",
2525
]
2626
dev = [
2727
"ruff>=0.0.262",
@@ -62,4 +62,4 @@ target-version = ['py311']
6262

6363
[tool.isort]
6464
profile = "black"
65-
line_length = 88
65+
line_length = 88

tests/test_server.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1+
import os
2+
import tempfile
13
import pytest
24
from mcp.types import TextContent, Tool
35

46
from mcp_shell_server.server import call_tool, list_tools
57

68

9+
@pytest.fixture
10+
def temp_test_dir():
11+
"""Create a temporary directory for testing"""
12+
with tempfile.TemporaryDirectory() as tmpdirname:
13+
# Return the real path to handle macOS /private/tmp symlink
14+
yield os.path.realpath(tmpdirname)
15+
16+
717
@pytest.mark.asyncio
818
async def test_list_tools():
919
"""Test listing of available tools"""
@@ -16,6 +26,7 @@ async def test_list_tools():
1626
assert tool.inputSchema["type"] == "object"
1727
assert "command" in tool.inputSchema["properties"]
1828
assert "stdin" in tool.inputSchema["properties"]
29+
assert "directory" in tool.inputSchema["properties"] # New assertion
1930
assert tool.inputSchema["required"] == ["command"]
2031

2132

@@ -72,3 +83,90 @@ async def test_call_tool_empty_command():
7283
with pytest.raises(RuntimeError) as excinfo:
7384
await call_tool("execute", {"command": []})
7485
assert "No command provided" in str(excinfo.value)
86+
87+
88+
# New tests for directory functionality
89+
@pytest.mark.asyncio
90+
async def test_call_tool_with_directory(temp_test_dir, monkeypatch):
91+
"""Test command execution in a specific directory"""
92+
monkeypatch.setenv("ALLOW_COMMANDS", "pwd")
93+
result = await call_tool("execute", {
94+
"command": ["pwd"],
95+
"directory": temp_test_dir
96+
})
97+
assert len(result) == 1
98+
assert isinstance(result[0], TextContent)
99+
assert result[0].type == "text"
100+
assert result[0].text.strip() == temp_test_dir
101+
102+
103+
@pytest.mark.asyncio
104+
async def test_call_tool_with_file_operations(temp_test_dir, monkeypatch):
105+
"""Test file operations in a specific directory"""
106+
monkeypatch.setenv("ALLOW_COMMANDS", "ls,cat")
107+
108+
# Create a test file
109+
test_file = os.path.join(temp_test_dir, "test.txt")
110+
with open(test_file, "w") as f:
111+
f.write("test content")
112+
113+
# Test ls command
114+
result = await call_tool("execute", {
115+
"command": ["ls"],
116+
"directory": temp_test_dir
117+
})
118+
assert "test.txt" in result[0].text
119+
120+
# Test cat command
121+
result = await call_tool("execute", {
122+
"command": ["cat", "test.txt"],
123+
"directory": temp_test_dir
124+
})
125+
assert result[0].text.strip() == "test content"
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_call_tool_with_nonexistent_directory(monkeypatch):
130+
"""Test command execution with a non-existent directory"""
131+
monkeypatch.setenv("ALLOW_COMMANDS", "ls")
132+
with pytest.raises(RuntimeError) as excinfo:
133+
await call_tool("execute", {
134+
"command": ["ls"],
135+
"directory": "/nonexistent/directory"
136+
})
137+
assert "Directory does not exist: /nonexistent/directory" in str(excinfo.value)
138+
139+
140+
@pytest.mark.asyncio
141+
async def test_call_tool_with_file_as_directory(temp_test_dir, monkeypatch):
142+
"""Test command execution with a file specified as directory"""
143+
monkeypatch.setenv("ALLOW_COMMANDS", "ls")
144+
145+
# Create a test file
146+
test_file = os.path.join(temp_test_dir, "test.txt")
147+
with open(test_file, "w") as f:
148+
f.write("test content")
149+
150+
with pytest.raises(RuntimeError) as excinfo:
151+
await call_tool("execute", {
152+
"command": ["ls"],
153+
"directory": test_file
154+
})
155+
assert f"Not a directory: {test_file}" in str(excinfo.value)
156+
157+
158+
@pytest.mark.asyncio
159+
async def test_call_tool_with_nested_directory(temp_test_dir, monkeypatch):
160+
"""Test command execution in a nested directory"""
161+
monkeypatch.setenv("ALLOW_COMMANDS", "pwd,mkdir")
162+
163+
# Create a nested directory
164+
nested_dir = os.path.join(temp_test_dir, "nested")
165+
os.mkdir(nested_dir)
166+
nested_real_path = os.path.realpath(nested_dir)
167+
168+
result = await call_tool("execute", {
169+
"command": ["pwd"],
170+
"directory": nested_dir
171+
})
172+
assert result[0].text.strip() == nested_real_path

tests/test_shell_executor.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import os
12
import pytest
3+
import tempfile
24

35
from mcp_shell_server.shell_executor import ShellExecutor
46

@@ -8,6 +10,14 @@ def executor():
810
return ShellExecutor()
911

1012

13+
@pytest.fixture
14+
def temp_test_dir():
15+
"""Create a temporary directory for testing"""
16+
with tempfile.TemporaryDirectory() as tmpdirname:
17+
# Return the real path to handle macOS /private/tmp symlink
18+
yield os.path.realpath(tmpdirname)
19+
20+
1121
@pytest.mark.asyncio
1222
async def test_basic_command_execution(executor, monkeypatch):
1323
monkeypatch.setenv("ALLOW_COMMANDS", "echo")
@@ -72,3 +82,83 @@ async def test_shell_operators_not_allowed(executor, monkeypatch):
7282
result = await executor.execute(["echo", "hello", op])
7383
assert result["error"] == f"Unexpected shell operator: {op}"
7484
assert result["status"] == 1
85+
86+
87+
# New tests for directory functionality
88+
@pytest.mark.asyncio
89+
async def test_execute_in_directory(executor, temp_test_dir, monkeypatch):
90+
"""Test command execution in a specific directory"""
91+
monkeypatch.setenv("ALLOW_COMMANDS", "pwd")
92+
result = await executor.execute(["pwd"], directory=temp_test_dir)
93+
assert result["error"] is None
94+
assert result["status"] == 0
95+
assert result["stdout"].strip() == temp_test_dir
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_execute_with_file_in_directory(executor, temp_test_dir, monkeypatch):
100+
"""Test command execution with a file in the specified directory"""
101+
monkeypatch.setenv("ALLOW_COMMANDS", "ls,cat")
102+
103+
# Create a test file in the temporary directory
104+
test_file = os.path.join(temp_test_dir, "test.txt")
105+
with open(test_file, "w") as f:
106+
f.write("test content")
107+
108+
# Test ls command
109+
result = await executor.execute(["ls"], directory=temp_test_dir)
110+
assert "test.txt" in result["stdout"]
111+
112+
# Test cat command
113+
result = await executor.execute(["cat", "test.txt"], directory=temp_test_dir)
114+
assert result["stdout"].strip() == "test content"
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_execute_with_nonexistent_directory(executor, monkeypatch):
119+
"""Test command execution with a non-existent directory"""
120+
monkeypatch.setenv("ALLOW_COMMANDS", "ls")
121+
result = await executor.execute(["ls"], directory="/nonexistent/directory")
122+
assert result["error"] == "Directory does not exist: /nonexistent/directory"
123+
assert result["status"] == 1
124+
125+
126+
@pytest.mark.asyncio
127+
async def test_execute_with_file_as_directory(executor, temp_test_dir, monkeypatch):
128+
"""Test command execution with a file specified as directory"""
129+
monkeypatch.setenv("ALLOW_COMMANDS", "ls")
130+
131+
# Create a test file
132+
test_file = os.path.join(temp_test_dir, "test.txt")
133+
with open(test_file, "w") as f:
134+
f.write("test content")
135+
136+
result = await executor.execute(["ls"], directory=test_file)
137+
assert result["error"] == f"Not a directory: {test_file}"
138+
assert result["status"] == 1
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_execute_with_no_directory_specified(executor, monkeypatch):
143+
"""Test command execution without specifying a directory"""
144+
monkeypatch.setenv("ALLOW_COMMANDS", "pwd")
145+
result = await executor.execute(["pwd"])
146+
assert result["error"] is None
147+
assert result["status"] == 0
148+
assert os.path.exists(result["stdout"].strip())
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_execute_with_nested_directory(executor, temp_test_dir, monkeypatch):
153+
"""Test command execution in a nested directory"""
154+
monkeypatch.setenv("ALLOW_COMMANDS", "pwd,mkdir,ls")
155+
156+
# Create a nested directory
157+
nested_dir = os.path.join(temp_test_dir, "nested")
158+
os.mkdir(nested_dir)
159+
nested_real_path = os.path.realpath(nested_dir)
160+
161+
result = await executor.execute(["pwd"], directory=nested_dir)
162+
assert result["error"] is None
163+
assert result["status"] == 0
164+
assert result["stdout"].strip() == nested_real_path

0 commit comments

Comments
 (0)