Skip to content

Commit f86e1e0

Browse files
committed
Merge branch 'develop' of https://github.com/tumf/mcp-shell-server into develop
2 parents 561c208 + fda0adb commit f86e1e0

File tree

6 files changed

+155
-19
lines changed

6 files changed

+155
-19
lines changed

Makefile

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ test:
88
uv run pytest
99

1010
format:
11-
black .
12-
isort .
13-
ruff check --fix .
11+
uv run isort .
12+
uv run black .
13+
uv run ruff check --fix .
1414

1515

1616
lint:
17-
black --check .
18-
isort --check .
19-
ruff check .
17+
uv run isort --check .
18+
uv run black --check .
19+
uv run ruff check .
2020

2121
typecheck:
22-
mypy src/mcp_shell_server tests
22+
uv run mypy src/mcp_shell_server tests
2323

2424
coverage:
25-
pytest --cov=src/mcp_shell_server tests
25+
uv run pytest --cov=src/mcp_shell_server tests
2626

2727
# Run all checks required before pushing
2828
check: lint typecheck

pyproject.toml

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

78+
[tool.mypy]
79+
error_summary = false
80+
hide_error_codes = true
81+
disallow_untyped_defs = false
82+
check_untyped_defs = false
83+
7884
[tool.hatch.version]
7985
path = "src/mcp_shell_server/version.py"
8086

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: 54 additions & 2 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
@@ -139,13 +140,64 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
139140
async def main() -> None:
140141
"""Main entry point for the MCP shell server"""
141142
logger.info(f"Starting MCP shell server v{__version__}")
143+
144+
# Setup signal handling
145+
loop = asyncio.get_running_loop()
146+
stop_event = asyncio.Event()
147+
148+
def handle_signal():
149+
if not stop_event.is_set(): # Prevent duplicate handling
150+
logger.info("Received shutdown signal, starting cleanup...")
151+
stop_event.set()
152+
153+
# Register signal handlers
154+
for sig in (signal.SIGTERM, signal.SIGINT):
155+
loop.add_signal_handler(sig, handle_signal)
156+
142157
try:
143158
from mcp.server.stdio import stdio_server
144159

145160
async with stdio_server() as (read_stream, write_stream):
146-
await app.run(
147-
read_stream, write_stream, app.create_initialization_options()
161+
# Run the server until stop_event is set
162+
server_task = asyncio.create_task(
163+
app.run(read_stream, write_stream, app.create_initialization_options())
148164
)
165+
166+
# Create task for stop event
167+
stop_task = asyncio.create_task(stop_event.wait())
168+
169+
# Wait for either server completion or stop signal
170+
done, pending = await asyncio.wait(
171+
[server_task, stop_task], return_when=asyncio.FIRST_COMPLETED
172+
)
173+
174+
# Check for exceptions in completed tasks
175+
for task in done:
176+
try:
177+
await task
178+
except Exception:
179+
raise # Re-raise the exception
180+
181+
# Cancel any pending tasks
182+
for task in pending:
183+
task.cancel()
184+
try:
185+
await task
186+
except asyncio.CancelledError:
187+
pass
188+
149189
except Exception as e:
150190
logger.error(f"Server error: {str(e)}")
151191
raise
192+
finally:
193+
# Cleanup signal handlers
194+
for sig in (signal.SIGTERM, signal.SIGINT):
195+
loop.remove_signal_handler(sig)
196+
197+
# Ensure all processes are terminated
198+
if hasattr(tool_handler, "executor") and hasattr(
199+
tool_handler.executor, "process_manager"
200+
):
201+
await tool_handler.executor.process_manager.cleanup_processes()
202+
203+
logger.info("Server shutdown complete")

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)