Skip to content

Commit 134c658

Browse files
committed
refactor: Move command processing logic to CommandPreProcessor
1 parent 345cdd1 commit 134c658

File tree

3 files changed

+46
-168
lines changed

3 files changed

+46
-168
lines changed

src/mcp_shell_server/shell_executor.py

Lines changed: 11 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import pwd
55
import shlex
66
import time
7-
from typing import IO, Any, Dict, List, Optional, Tuple, Union
7+
from typing import IO, Any, Dict, List, Optional
88

9+
from mcp_shell_server.command_preprocessor import CommandPreProcessor
910
from mcp_shell_server.command_validator import CommandValidator
1011
from mcp_shell_server.directory_manager import DirectoryManager
1112
from mcp_shell_server.io_redirection_handler import IORedirectionHandler
@@ -23,38 +24,7 @@ def __init__(self):
2324
self.validator = CommandValidator()
2425
self.directory_manager = DirectoryManager()
2526
self.io_handler = IORedirectionHandler()
26-
27-
def _clean_command(self, command: List[str]) -> List[str]:
28-
"""
29-
Clean command by trimming whitespace from each part.
30-
Removes empty strings but preserves arguments that are meant to be spaces.
31-
32-
Args:
33-
command (List[str]): Original command and its arguments
34-
35-
Returns:
36-
List[str]: Cleaned command
37-
"""
38-
return [arg for arg in command if arg] # Remove empty strings
39-
40-
def _create_shell_command(self, command: List[str]) -> str:
41-
"""
42-
Create a shell command string from a list of arguments.
43-
Handles wildcards and arguments properly.
44-
"""
45-
if not command:
46-
return ""
47-
48-
escaped_args = []
49-
for arg in command:
50-
if arg.isspace():
51-
# Wrap space-only arguments in single quotes
52-
escaped_args.append(f"'{arg}'")
53-
else:
54-
# Properly escape all arguments including those with wildcards
55-
escaped_args.append(shlex.quote(arg.strip()))
56-
57-
return " ".join(escaped_args)
27+
self.preprocessor = CommandPreProcessor()
5828

5929
def _validate_command(self, command: List[str]) -> None:
6030
"""
@@ -95,97 +65,6 @@ def _validate_pipeline(self, commands: List[str]) -> Dict[str, str]:
9565
"""
9666
return self.validator.validate_pipeline(commands)
9767

98-
def _split_pipe_commands(self, command: List[str]) -> List[List[str]]:
99-
"""
100-
Split commands by pipe operator into separate commands.
101-
102-
Args:
103-
command (List[str]): Command and its arguments with pipe operators
104-
105-
Returns:
106-
List[List[str]]: List of commands split by pipe operator
107-
"""
108-
commands: List[List[str]] = []
109-
current_command: List[str] = []
110-
111-
for arg in command:
112-
if arg.strip() == "|":
113-
if current_command:
114-
commands.append(current_command)
115-
current_command = []
116-
else:
117-
current_command.append(arg)
118-
119-
if current_command:
120-
commands.append(current_command)
121-
122-
return commands
123-
124-
def _parse_command(
125-
self, command: List[str]
126-
) -> Tuple[List[str], Dict[str, Union[None, str, bool]]]:
127-
"""
128-
Parse command and extract redirections.
129-
"""
130-
cmd = []
131-
redirects: Dict[str, Union[None, str, bool]] = {
132-
"stdin": None,
133-
"stdout": None,
134-
"stdout_append": False,
135-
}
136-
137-
i = 0
138-
while i < len(command):
139-
token = command[i]
140-
141-
# Shell operators check
142-
if token in ["|", ";", "&&", "||"]:
143-
raise ValueError(f"Unexpected shell operator: {token}")
144-
145-
# Output redirection
146-
if token in [">", ">>"]:
147-
if i + 1 >= len(command):
148-
raise ValueError("Missing path for output redirection")
149-
if i + 1 < len(command) and command[i + 1] in [">", ">>", "<"]:
150-
raise ValueError("Invalid redirection target: operator found")
151-
path = command[i + 1]
152-
redirects["stdout"] = path
153-
redirects["stdout_append"] = token == ">>"
154-
i += 2
155-
continue
156-
157-
# Input redirection
158-
if token == "<":
159-
if i + 1 >= len(command):
160-
raise ValueError("Missing path for input redirection")
161-
path = command[i + 1]
162-
redirects["stdin"] = path
163-
i += 2
164-
continue
165-
166-
cmd.append(token)
167-
i += 1
168-
169-
return cmd, redirects
170-
171-
def _preprocess_command(self, command: List[str]) -> List[str]:
172-
"""
173-
Preprocess the command to handle cases where '|' is attached to a command.
174-
"""
175-
preprocessed_command = []
176-
for token in command:
177-
if token in ["||", "&&", ";"]: # 特別なシェル演算子を保護
178-
preprocessed_command.append(token)
179-
elif "|" in token and token != "|":
180-
parts = token.split("|")
181-
preprocessed_command.extend(
182-
[part.strip() for part in parts if part.strip()]
183-
)
184-
preprocessed_command.append("|")
185-
else:
186-
preprocessed_command.append(token)
187-
return preprocessed_command
188-
18968
def _get_default_shell(self) -> str:
19069
"""Get the login shell of the current user"""
19170
try:
@@ -218,8 +97,8 @@ async def execute(
21897
}
21998

22099
# Process command
221-
preprocessed_command = self._preprocess_command(command)
222-
cleaned_command = self._clean_command(preprocessed_command)
100+
preprocessed_command = self.preprocessor.preprocess_command(command)
101+
cleaned_command = self.preprocessor.clean_command(preprocessed_command)
223102
if not cleaned_command:
224103
return {
225104
"error": "Empty command",
@@ -245,19 +124,9 @@ async def execute(
245124
}
246125

247126
# Split commands
248-
commands: List[List[str]] = []
249-
current_cmd: List[str] = []
250-
for token in cleaned_command:
251-
if token == "|":
252-
if current_cmd:
253-
commands.append(current_cmd)
254-
current_cmd = []
255-
else:
256-
raise ValueError("Empty command before pipe operator")
257-
else:
258-
current_cmd.append(token)
259-
if current_cmd:
260-
commands.append(current_cmd)
127+
commands = self.preprocessor.split_pipe_commands(cleaned_command)
128+
if not commands:
129+
raise ValueError("Empty command before pipe operator")
261130

262131
return await self._execute_pipeline(
263132
commands, directory, timeout, envs
@@ -286,7 +155,7 @@ async def execute(
286155

287156
# Single command execution
288157
try:
289-
cmd, redirects = self._parse_command(cleaned_command)
158+
cmd, redirects = self.preprocessor.parse_command(cleaned_command)
290159
except ValueError as e:
291160
return {
292161
"error": str(e),
@@ -349,7 +218,7 @@ async def execute(
349218

350219
# Execute the command with interactive shell
351220
shell = self._get_default_shell()
352-
shell_cmd = self._create_shell_command(cmd)
221+
shell_cmd = self.preprocessor.create_shell_command(cmd)
353222
shell_cmd = f"{shell} -i -c {shlex.quote(shell_cmd)}"
354223

355224
process = await asyncio.create_subprocess_shell(
@@ -488,7 +357,7 @@ async def _execute_pipeline(
488357
# Execute each command in the pipeline
489358
for i, cmd in enumerate(parsed_commands):
490359
# Create the shell command
491-
shell_cmd = self._create_shell_command(cmd)
360+
shell_cmd = self.preprocessor.create_shell_command(cmd)
492361

493362
# Apply interactive mode to first command only
494363
if i == 0:

tests/test_shell_executor.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -440,55 +440,55 @@ def test_validate_redirection_syntax(executor):
440440
def test_create_shell_command(executor):
441441
"""Test shell command creation with various input combinations"""
442442
# Test basic command
443-
assert executor._create_shell_command(["echo", "hello"]) == "echo hello"
443+
assert executor.preprocessor.create_shell_command(["echo", "hello"]) == "echo hello"
444444

445445
# Test command with space-only argument
446-
assert executor._create_shell_command(["echo", " "]) == "echo ' '"
446+
assert executor.preprocessor.create_shell_command(["echo", " "]) == "echo ' '"
447447

448448
# Test command with wildcards
449-
assert executor._create_shell_command(["ls", "*.txt"]) == "ls '*.txt'"
449+
assert executor.preprocessor.create_shell_command(["ls", "*.txt"]) == "ls '*.txt'"
450450

451451
# Test command with special characters
452452
assert (
453-
executor._create_shell_command(["echo", "hello;", "world"])
453+
executor.preprocessor.create_shell_command(["echo", "hello;", "world"])
454454
== "echo 'hello;' world"
455455
)
456456

457457
# Test empty command
458-
assert executor._create_shell_command([]) == ""
458+
assert executor.preprocessor.create_shell_command([]) == ""
459459

460460

461461
def test_preprocess_command(executor):
462462
"""Test command preprocessing for pipeline handling"""
463463
# Test basic command
464-
assert executor._preprocess_command(["ls"]) == ["ls"]
464+
assert executor.preprocessor.preprocess_command(["ls"]) == ["ls"]
465465

466466
# Test command with separate pipe
467-
assert executor._preprocess_command(["ls", "|", "grep", "test"]) == [
467+
assert executor.preprocessor.preprocess_command(["ls", "|", "grep", "test"]) == [
468468
"ls",
469469
"|",
470470
"grep",
471471
"test",
472472
]
473473

474474
# Test command with attached pipe
475-
assert executor._preprocess_command(["ls|", "grep", "test"]) == [
475+
assert executor.preprocessor.preprocess_command(["ls|", "grep", "test"]) == [
476476
"ls",
477477
"|",
478478
"grep",
479479
"test",
480480
]
481481

482482
# Test command with special operators
483-
assert executor._preprocess_command(["echo", "hello", "&&", "ls"]) == [
483+
assert executor.preprocessor.preprocess_command(["echo", "hello", "&&", "ls"]) == [
484484
"echo",
485485
"hello",
486486
"&&",
487487
"ls",
488488
]
489489

490490
# Test empty command
491-
assert executor._preprocess_command([]) == []
491+
assert executor.preprocessor.preprocess_command([]) == []
492492

493493

494494
def test_validate_pipeline(executor, monkeypatch):
@@ -507,33 +507,35 @@ def test_validate_pipeline(executor, monkeypatch):
507507

508508
# Test disallowed commands in pipeline
509509
with pytest.raises(ValueError) as exc:
510-
executor._validate_pipeline(["rm", "|", "grep", "test"])
510+
executor.validator.validate_pipeline(["rm", "|", "grep", "test"])
511511
assert "Command not allowed: rm" in str(exc.value)
512512

513513
# Test shell operators in pipeline
514514
with pytest.raises(ValueError) as exc:
515-
executor._validate_pipeline(["echo", "hello", "|", "grep", "h", "&&", "ls"])
515+
executor.validator.validate_pipeline(
516+
["echo", "hello", "|", "grep", "h", "&&", "ls"]
517+
)
516518
assert "Unexpected shell operator in pipeline: &&" in str(exc.value)
517-
assert executor._preprocess_command([]) == []
519+
assert executor.preprocessor.preprocess_command([]) == []
518520

519521

520522
def test_redirection_path_validation(executor):
521523
"""Test validation of redirection paths"""
522524
# Test missing output redirection path
523525
with pytest.raises(ValueError, match="Missing path for output redirection"):
524-
executor._parse_command(["echo", "hello", ">"])
526+
executor.preprocessor.parse_command(["echo", "hello", ">"])
525527

526528
# Test missing input redirection path
527529
with pytest.raises(ValueError, match="Missing path for input redirection"):
528-
executor._parse_command(["cat", "<"])
530+
executor.preprocessor.parse_command(["cat", "<"])
529531

530532
# Test operator as redirection target
531533
with pytest.raises(ValueError, match="Invalid redirection target: operator found"):
532-
executor._parse_command(["echo", "hello", ">", ">"])
534+
executor.preprocessor.parse_command(["echo", "hello", ">", ">"])
533535

534536
# Test multiple operators
535537
with pytest.raises(ValueError, match="Invalid redirection target: operator found"):
536-
executor._parse_command(["echo", "hello", ">", ">>", "file.txt"])
538+
executor.preprocessor.parse_command(["echo", "hello", ">", ">>", "file.txt"])
537539

538540

539541
@pytest.mark.asyncio
@@ -565,13 +567,18 @@ async def test_io_handle_close(executor, temp_test_dir, monkeypatch, mocker):
565567
def test_preprocess_command_pipeline(executor):
566568
"""Test pipeline command preprocessing functionality"""
567569
# Test empty command
568-
assert executor._preprocess_command([]) == []
570+
assert executor.preprocessor.preprocess_command([]) == []
569571

570572
# Test single command without pipe
571-
assert executor._preprocess_command(["echo", "hello"]) == ["echo", "hello"]
573+
assert executor.preprocessor.preprocess_command(["echo", "hello"]) == [
574+
"echo",
575+
"hello",
576+
]
572577

573578
# Test simple pipe
574-
assert executor._preprocess_command(["echo", "hello", "|", "grep", "h"]) == [
579+
assert executor.preprocessor.preprocess_command(
580+
["echo", "hello", "|", "grep", "h"]
581+
) == [
575582
"echo",
576583
"hello",
577584
"|",
@@ -580,12 +587,12 @@ def test_preprocess_command_pipeline(executor):
580587
]
581588

582589
# Test multiple pipes
583-
assert executor._preprocess_command(
590+
assert executor.preprocessor.preprocess_command(
584591
["cat", "file", "|", "grep", "pattern", "|", "wc", "-l"]
585592
) == ["cat", "file", "|", "grep", "pattern", "|", "wc", "-l"]
586593

587594
# Test command with attached pipe operator
588-
assert executor._preprocess_command(["echo|", "grep", "pattern"]) == [
595+
assert executor.preprocessor.preprocess_command(["echo|", "grep", "pattern"]) == [
589596
"echo",
590597
"|",
591598
"grep",

tests/test_shell_executor_pipeline.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,20 @@ def temp_test_dir():
3030
async def test_pipeline_split(executor):
3131
"""Test pipeline command splitting functionality"""
3232
# Test basic pipe command
33-
commands = executor._split_pipe_commands(["echo", "hello", "|", "grep", "h"])
33+
commands = executor.preprocessor.split_pipe_commands(
34+
["echo", "hello", "|", "grep", "h"]
35+
)
3436
assert len(commands) == 2
3537
assert commands[0] == ["echo", "hello"]
3638
assert commands[1] == ["grep", "h"]
3739

3840
# Test empty pipe sections
39-
commands = executor._split_pipe_commands(["|", "grep", "pattern"])
41+
commands = executor.preprocessor.split_pipe_commands(["|", "grep", "pattern"])
4042
assert len(commands) == 1
4143
assert commands[0] == ["grep", "pattern"]
4244

4345
# Test multiple pipes
44-
commands = executor._split_pipe_commands(
46+
commands = executor.preprocessor.split_pipe_commands(
4547
["cat", "file.txt", "|", "grep", "pattern", "|", "wc", "-l"]
4648
)
4749
assert len(commands) == 3
@@ -50,7 +52,7 @@ async def test_pipeline_split(executor):
5052
assert commands[2] == ["wc", "-l"]
5153

5254
# Test trailing pipe
53-
commands = executor._split_pipe_commands(["echo", "hello", "|"])
55+
commands = executor.preprocessor.split_pipe_commands(["echo", "hello", "|"])
5456
assert len(commands) == 1
5557
assert commands[0] == ["echo", "hello"]
5658

0 commit comments

Comments
 (0)