Skip to content

Commit 4da78d5

Browse files
committed
Merge branch 'develop'
2 parents 6e09f2e + 64fb33e commit 4da78d5

File tree

9 files changed

+206
-22
lines changed

9 files changed

+206
-22
lines changed

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
.PHONY: test format lint typecheck check install-pre-commit
22
.DEFAULT_GOAL := all
33

4+
install:
5+
uv pip install --upgrade '.[dev,test]'
6+
47
test:
58
uv run pytest
69

710
format:
8-
uv run black .
911
uv run isort .
12+
uv run black .
13+
uv run mypy --install-types --non-interactive src/mcp_shell_server tests
1014
uv run ruff check --fix .
1115

1216

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/tumf-mcp-shell-server-badge.png)](https://mseep.ai/app/tumf-mcp-shell-server)
77

8-
98
A secure shell command execution server implementing the Model Context Protocol (MCP). This server allows remote execution of whitelisted shell commands with support for stdin input.
109

1110
<a href="https://glama.ai/mcp/servers/rt2d4pbn22"><img width="380" height="200" src="https://glama.ai/mcp/servers/rt2d4pbn22/badge" alt="mcp-shell-server MCP server" /></a>
1211

12+
<a href="https://glama.ai/mcp/servers/rt2d4pbn22"><img width="380" height="200" src="https://glama.ai/mcp/servers/rt2d4pbn22/badge" alt="mcp-shell-server MCP server" /></a>
13+
1314
## Features
1415

1516
* **Secure Command Execution**: Only whitelisted commands can be executed
@@ -80,6 +81,17 @@ npx -y @smithery/cli install mcp-shell-server --client claude
8081
```
8182

8283
### Manual Installation
84+
85+
### Installing via Smithery
86+
87+
To install Shell Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-shell-server):
88+
89+
```bash
90+
npx -y @smithery/cli install mcp-shell-server --client claude
91+
```
92+
93+
### Manual Installation
94+
8395
```bash
8496
pip install mcp-shell-server
8597
```

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ target-version = ['py311']
7373
profile = "black"
7474
line_length = 88
7575

76+
[tool.mypy]
77+
error_summary = false
78+
hide_error_codes = true
79+
disallow_untyped_defs = false
80+
check_untyped_defs = false
81+
7682
[tool.hatch.version]
7783
path = "src/mcp_shell_server/version.py"
7884

src/mcp_shell_server/command_validator.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import os
6+
import re
67
from typing import Dict, List
78

89

@@ -15,7 +16,8 @@ def __init__(self):
1516
"""
1617
Initialize the validator.
1718
"""
18-
pass
19+
# No state; environment variables are read on demand
20+
return None
1921

2022
def _get_allowed_commands(self) -> set[str]:
2123
"""Get the set of allowed commands from environment variables"""
@@ -24,14 +26,27 @@ def _get_allowed_commands(self) -> set[str]:
2426
commands = allow_commands + "," + allowed_commands
2527
return {cmd.strip() for cmd in commands.split(",") if cmd.strip()}
2628

29+
def _get_allowed_patterns(self) -> List[re.Pattern]:
30+
"""Get the list of allowed regex patterns from environment variables"""
31+
allow_patterns = os.environ.get("ALLOW_PATTERNS", "")
32+
patterns = [
33+
pattern.strip() for pattern in allow_patterns.split(",") if pattern.strip()
34+
]
35+
return [re.compile(pattern) for pattern in patterns]
36+
2737
def get_allowed_commands(self) -> list[str]:
28-
"""Get the list of allowed commands from environment variables"""
38+
"""Public API: return list form of allowed commands"""
2939
return list(self._get_allowed_commands())
3040

3141
def is_command_allowed(self, command: str) -> bool:
32-
"""Check if a command is in the allowed list"""
42+
"""Check if a command is in the allowed list or matches an allowed pattern"""
3343
cmd = command.strip()
34-
return cmd in self._get_allowed_commands()
44+
if cmd in self._get_allowed_commands():
45+
return True
46+
for pattern in self._get_allowed_patterns():
47+
if pattern.match(cmd):
48+
return True
49+
return False
3550

3651
def validate_no_shell_operators(self, cmd: str) -> None:
3752
"""
@@ -92,13 +107,12 @@ def validate_command(self, command: List[str]) -> None:
92107
if not command:
93108
raise ValueError("Empty command")
94109

95-
allowed_commands = self._get_allowed_commands()
96-
if not allowed_commands:
110+
if not self._get_allowed_commands() and not self._get_allowed_patterns():
97111
raise ValueError(
98112
"No commands are allowed. Please set ALLOW_COMMANDS environment variable."
99113
)
100114

101115
# Clean and check the first command
102116
cleaned_cmd = command[0].strip()
103-
if cleaned_cmd not in allowed_commands:
117+
if not self.is_command_allowed(cleaned_cmd):
104118
raise ValueError(f"Command not allowed: {cleaned_cmd}")

src/mcp_shell_server/process_manager.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,31 @@ async def start_process(
8787
return process
8888

8989
async def cleanup_processes(
90-
self, processes: List[asyncio.subprocess.Process]
90+
self, processes: Optional[List[asyncio.subprocess.Process]] = None
9191
) -> None:
9292
"""Clean up processes by killing them if they're still running.
9393
9494
Args:
95-
processes: List of processes to clean up
95+
processes: Optional list of processes to clean up. If None, clean up all tracked processes
9696
"""
97+
if processes is None:
98+
processes = list(self._processes)
99+
97100
cleanup_tasks = []
98101
for process in processes:
99102
if process.returncode is None:
100103
try:
101-
# Force kill immediately as required by tests
102-
process.kill()
103-
cleanup_tasks.append(asyncio.create_task(process.wait()))
104+
# First attempt graceful termination
105+
process.terminate()
106+
try:
107+
await asyncio.wait_for(process.wait(), timeout=0.5)
108+
except asyncio.TimeoutError:
109+
# Force kill if termination didn't work
110+
process.kill()
111+
cleanup_tasks.append(asyncio.create_task(process.wait()))
112+
except ProcessLookupError:
113+
# Process already terminated
114+
pass
104115
except Exception as e:
105116
logging.warning(f"Error killing process: {e}")
106117

src/mcp_shell_server/server.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import logging
3+
import signal
34
import traceback
45
from collections.abc import Sequence
56
from typing import Any
@@ -30,11 +31,21 @@ def get_allowed_commands(self) -> list[str]:
3031
"""Get the allowed commands"""
3132
return self.executor.validator.get_allowed_commands()
3233

34+
def get_allowed_patterns(self) -> list[str]:
35+
"""Get the allowed regex patterns"""
36+
return [
37+
pattern.pattern
38+
for pattern in self.executor.validator._get_allowed_patterns()
39+
]
40+
3341
def get_tool_description(self) -> Tool:
42+
"""Get the tool description for the execute command"""
43+
allowed_commands = ", ".join(self.get_allowed_commands())
44+
allowed_patterns = ", ".join(self.get_allowed_patterns())
3445
"""Get the tool description for the execute command"""
3546
return Tool(
3647
name=self.name,
37-
description=f"{self.description}\nAllowed commands: {', '.join(self.get_allowed_commands())}",
48+
description=f"{self.description}\nAllowed commands: {allowed_commands}\nAllowed patterns: {allowed_patterns}",
3849
inputSchema={
3950
"type": "object",
4051
"properties": {
@@ -142,13 +153,64 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
142153
async def main() -> None:
143154
"""Main entry point for the MCP shell server"""
144155
logger.info(f"Starting MCP shell server v{__version__}")
156+
157+
# Setup signal handling
158+
loop = asyncio.get_running_loop()
159+
stop_event = asyncio.Event()
160+
161+
def handle_signal():
162+
if not stop_event.is_set(): # Prevent duplicate handling
163+
logger.info("Received shutdown signal, starting cleanup...")
164+
stop_event.set()
165+
166+
# Register signal handlers
167+
for sig in (signal.SIGTERM, signal.SIGINT):
168+
loop.add_signal_handler(sig, handle_signal)
169+
145170
try:
146171
from mcp.server.stdio import stdio_server
147172

148173
async with stdio_server() as (read_stream, write_stream):
149-
await app.run(
150-
read_stream, write_stream, app.create_initialization_options()
174+
# Run the server until stop_event is set
175+
server_task = asyncio.create_task(
176+
app.run(read_stream, write_stream, app.create_initialization_options())
151177
)
178+
179+
# Create task for stop event
180+
stop_task = asyncio.create_task(stop_event.wait())
181+
182+
# Wait for either server completion or stop signal
183+
done, pending = await asyncio.wait(
184+
[server_task, stop_task], return_when=asyncio.FIRST_COMPLETED
185+
)
186+
187+
# Check for exceptions in completed tasks
188+
for task in done:
189+
try:
190+
await task
191+
except Exception:
192+
raise # Re-raise the exception
193+
194+
# Cancel any pending tasks
195+
for task in pending:
196+
task.cancel()
197+
try:
198+
await task
199+
except asyncio.CancelledError:
200+
pass
201+
152202
except Exception as e:
153203
logger.error(f"Server error: {str(e)}")
154204
raise
205+
finally:
206+
# Cleanup signal handlers
207+
for sig in (signal.SIGTERM, signal.SIGINT):
208+
loop.remove_signal_handler(sig)
209+
210+
# Ensure all processes are terminated
211+
if hasattr(tool_handler, "executor") and hasattr(
212+
tool_handler.executor, "process_manager"
213+
):
214+
await tool_handler.executor.process_manager.cleanup_processes()
215+
216+
logger.info("Server shutdown complete")

tests/test_command_validator.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ def test_get_allowed_commands(validator, monkeypatch):
2222
assert set(validator.get_allowed_commands()) == {"cmd1", "cmd2", "cmd3", "cmd4"}
2323

2424

25-
def test_is_command_allowed(validator, monkeypatch):
25+
def test_is_command_allowed_with_patterns(validator, monkeypatch):
26+
clear_env(monkeypatch)
27+
monkeypatch.setenv("ALLOW_COMMANDS", "allowed_cmd")
28+
monkeypatch.setenv("ALLOW_PATTERNS", "^cmd[0-9]+$")
29+
30+
assert validator.is_command_allowed("allowed_cmd")
31+
assert validator.is_command_allowed("cmd123")
32+
assert not validator.is_command_allowed("disallowed_cmd")
33+
assert not validator.is_command_allowed("cmdabc")
2634
clear_env(monkeypatch)
2735
monkeypatch.setenv("ALLOW_COMMANDS", "allowed_cmd")
2836
assert validator.is_command_allowed("allowed_cmd")

tests/test_process_manager.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,17 +142,18 @@ async def test_cleanup_processes(process_manager):
142142
# Create mock processes with different states
143143
running_proc = create_mock_process()
144144
running_proc.returncode = None
145+
# Mock wait to simulate timeout
146+
running_proc.wait.side_effect = [asyncio.TimeoutError(), None]
145147

146148
completed_proc = create_mock_process()
147149
completed_proc.returncode = 0
148150

149151
# Execute cleanup
150152
await process_manager.cleanup_processes([running_proc, completed_proc])
151153

152-
# Verify running process was killed and waited for
153-
running_proc.kill.assert_called_once()
154-
running_proc.wait.assert_awaited_once()
155-
154+
# Verify running process was terminated first, then killed
155+
running_proc.terminate.assert_called_once()
156+
assert running_proc.wait.await_count == 2 # wait called for both terminate and kill
156157
# Verify completed process was not killed or waited for
157158
completed_proc.kill.assert_not_called()
158159
completed_proc.wait.assert_not_called()

tests/test_server.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import os
3+
import signal
34
import tempfile
45

56
import pytest
@@ -434,3 +435,68 @@ async def test_environment_variables(monkeypatch, temp_test_dir):
434435
{"command": ["env"], "directory": temp_test_dir},
435436
)
436437
assert len(result) == 1
438+
439+
440+
@pytest.mark.asyncio
441+
async def test_signal_handling(monkeypatch, mocker):
442+
"""Test signal handling and cleanup during server shutdown"""
443+
from mcp_shell_server.server import main
444+
445+
# Setup mocks
446+
mock_read_stream = mocker.AsyncMock()
447+
mock_write_stream = mocker.AsyncMock()
448+
mock_cleanup_processes = mocker.AsyncMock()
449+
450+
# Mock process manager
451+
class MockExecutor:
452+
def __init__(self):
453+
self.process_manager = mocker.MagicMock()
454+
self.process_manager.cleanup_processes = mock_cleanup_processes
455+
456+
class MockToolHandler:
457+
def __init__(self):
458+
self.executor = MockExecutor()
459+
460+
# Setup server mocks
461+
context_manager = mocker.AsyncMock()
462+
context_manager.__aenter__ = mocker.AsyncMock(
463+
return_value=(mock_read_stream, mock_write_stream)
464+
)
465+
context_manager.__aexit__ = mocker.AsyncMock()
466+
mock_stdio_server = mocker.Mock(return_value=context_manager)
467+
mocker.patch("mcp.server.stdio.stdio_server", mock_stdio_server)
468+
469+
# Mock server run to simulate long-running task
470+
async def mock_run(*args):
471+
# Wait indefinitely or until cancelled
472+
try:
473+
await asyncio.sleep(10)
474+
except asyncio.CancelledError:
475+
pass
476+
477+
mocker.patch("mcp_shell_server.server.app.run", side_effect=mock_run)
478+
479+
# Mock tool handler
480+
tool_handler = MockToolHandler()
481+
mocker.patch("mcp_shell_server.server.tool_handler", tool_handler)
482+
483+
# Run main in a task so we can simulate signal
484+
task = asyncio.create_task(main())
485+
486+
# Give the server a moment to start
487+
await asyncio.sleep(0.1)
488+
489+
# Simulate SIGINT
490+
loop = asyncio.get_running_loop()
491+
loop.call_soon(lambda: [h() for h in loop._signal_handlers.get(signal.SIGINT, [])])
492+
493+
# Wait for main to complete
494+
try:
495+
await asyncio.wait_for(task, timeout=1.0)
496+
except asyncio.TimeoutError:
497+
task.cancel()
498+
await asyncio.sleep(0.1)
499+
500+
# Verify cleanup was called
501+
mock_cleanup_processes.assert_called_once()
502+
context_manager.__aexit__.assert_called_once()

0 commit comments

Comments
 (0)