Skip to content

Commit b74a1da

Browse files
committed
Merge branch 'develop'
2 parents ff6756c + dcb1dfb commit b74a1da

20 files changed

+2225
-146
lines changed

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
run: |
4747
# Strip 'v' prefix from tag and update version.py
4848
VERSION=${GITHUB_REF#refs/tags/v}
49-
echo "__version__ = \"${VERSION}\"" > mcp_shell_server/version.py
49+
echo "__version__ = \"${VERSION}\"" > src/mcp_shell_server/version.py
5050
5151
- name: Set up Python ${{ matrix.python-version }}
5252
uses: actions/setup-python@v5

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,6 @@ share/python-wheels/
7575
*.egg-info/
7676
.installed.cfg
7777
*.egg
78-
MANIFEST
78+
MANIFEST
79+
80+
prompt.md

Makefile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
.PHONY: test format lint typecheck check
2+
.DEFAULT_GOAL := all
23

34
test:
4-
pip install -e .
5-
pytest
5+
uv run pytest
66

77
format:
88
black .
@@ -18,7 +18,10 @@ lint:
1818
typecheck:
1919
mypy src/mcp_shell_server tests
2020

21+
coverage:
22+
pytest --cov=src/mcp_shell_server tests
23+
2124
# Run all checks required before pushing
22-
check: lint typecheck test
25+
check: lint typecheck
2326
fix: check format
24-
all: check
27+
all: format check coverage

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,17 @@ pip install mcp-shell-server
7575

7676
```bash
7777
ALLOW_COMMANDS="ls,cat,echo" uvx mcp-shell-server
78+
# Or using the alias
79+
ALLOWED_COMMANDS="ls,cat,echo" uvx mcp-shell-server
7880
```
7981

80-
The `ALLOW_COMMANDS` environment variable specifies which commands are allowed to be executed. Commands can be separated by commas with optional spaces around them.
82+
The `ALLOW_COMMANDS` (or its alias `ALLOWED_COMMANDS` ) environment variable specifies which commands are allowed to be executed. Commands can be separated by commas with optional spaces around them.
8183

82-
Valid formats for ALLOW_COMMANDS:
84+
Valid formats for ALLOW_COMMANDS or ALLOWED_COMMANDS:
8385

8486
```bash
8587
ALLOW_COMMANDS="ls,cat,echo" # Basic format
86-
ALLOW_COMMANDS="ls ,echo, cat" # With spaces
88+
ALLOWED_COMMANDS="ls ,echo, cat" # With spaces (using alias)
8789
ALLOW_COMMANDS="ls, cat , echo" # Multiple spaces
8890
```
8991

pyproject.toml

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ authors = [
66
]
77
dependencies = [
88
"asyncio>=3.4.3",
9-
"mcp>=1.1.0",
9+
"mcp @ git+https://github.com/tumf/mcp-python-sdk.git@fix/handle-cancelled-notifications",
1010
]
1111
requires-python = ">=3.11"
1212
readme = "README.md"
@@ -22,6 +22,7 @@ test = [
2222
"pytest-asyncio>=0.23.0",
2323
"pytest-env>=1.1.0",
2424
"pytest-cov>=6.0.0",
25+
"pytest-mock>=3.12.0",
2526
]
2627
dev = [
2728
"ruff>=0.0.262",
@@ -40,6 +41,11 @@ asyncio_mode = "strict"
4041
testpaths = "tests"
4142
# Set default event loop scope for async tests
4243
asyncio_default_fixture_loop_scope = "function"
44+
filterwarnings = [
45+
"ignore::RuntimeWarning:selectors:",
46+
"ignore::pytest.PytestUnhandledCoroutineWarning:",
47+
"ignore::pytest.PytestUnraisableExceptionWarning:",
48+
]
4349

4450
[tool.ruff]
4551
lint.select = [
@@ -69,3 +75,29 @@ path = "src/mcp_shell_server/version.py"
6975

7076
[tool.hatch.build.targets.wheel]
7177
packages = ["src/mcp_shell_server"]
78+
79+
[tool.hatch.metadata]
80+
allow-direct-references = true
81+
82+
83+
[tool.coverage.report]
84+
exclude_lines = [
85+
"pragma: no cover",
86+
"def __repr__",
87+
"raise NotImplementedError",
88+
"if __name__ == .__main__.:",
89+
"pass",
90+
"raise ImportError",
91+
"__version__",
92+
"except IOError:",
93+
"except IOError as e:",
94+
"def _cleanup_handles",
95+
"def __aexit__",
96+
"if path in [\">\", \">>\", \"<\"]:",
97+
"def _close_handles",
98+
]
99+
100+
omit = [
101+
"src/mcp_shell_server/__init__.py",
102+
"src/mcp_shell_server/version.py",
103+
]

src/mcp_shell_server/__init__.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"""MCP Shell Server Package."""
22

3-
import asyncio
4-
53
from . import server
64

5+
__version__ = "0.1.0"
6+
__all__ = ["main", "server"]
7+
78

89
def main():
910
"""Main entry point for the package."""
11+
import asyncio
12+
1013
asyncio.run(server.main())
1114

1215

13-
# Optionally expose other important items at package level
14-
__all__ = ["main", "server"]
15-
__version__ = "0.1.0"
16+
if __name__ == "__main__":
17+
main()

src/mcp_shell_server/server.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import logging
23
import traceback
34
from collections.abc import Sequence
@@ -56,33 +57,50 @@ def get_tool_description(self) -> Tool:
5657
"minimum": 0,
5758
},
5859
},
59-
"required": ["command"],
60+
"required": ["command", "directory"],
6061
},
6162
)
6263

6364
async def run_tool(self, arguments: dict) -> Sequence[TextContent]:
6465
"""Execute the shell command with the given arguments"""
6566
command = arguments.get("command", [])
6667
stdin = arguments.get("stdin")
67-
directory = arguments.get("directory")
68+
directory = arguments.get("directory", "/tmp") # default to /tmp for safety
6869
timeout = arguments.get("timeout")
6970

7071
if not command:
7172
raise ValueError("No command provided")
7273

73-
result = await self.executor.execute(command, stdin, directory, timeout)
74+
if not isinstance(command, list):
75+
raise ValueError("'command' must be an array")
7476

75-
# Raise error if command execution failed
76-
if result.get("error"):
77-
raise RuntimeError(result["error"])
77+
# Make sure directory exists
78+
if not directory:
79+
raise ValueError("Directory is required")
7880

79-
# Convert executor result to TextContent sequence
8081
content: list[TextContent] = []
81-
82-
if result.get("stdout"):
83-
content.append(TextContent(type="text", text=result["stdout"]))
84-
if result.get("stderr"):
85-
content.append(TextContent(type="text", text=result["stderr"]))
82+
try:
83+
# Handle execution with timeout
84+
try:
85+
result = await asyncio.wait_for(
86+
self.executor.execute(
87+
command, directory, stdin, None
88+
), # Pass None for timeout
89+
timeout=timeout,
90+
)
91+
except asyncio.TimeoutError as e:
92+
raise ValueError("Command execution timed out") from e
93+
94+
if result.get("error"):
95+
raise ValueError(result["error"])
96+
97+
if result.get("stdout"):
98+
content.append(TextContent(type="text", text=result["stdout"]))
99+
if result.get("stderr"):
100+
content.append(TextContent(type="text", text=result["stderr"]))
101+
102+
except asyncio.TimeoutError as e:
103+
raise ValueError(f"Command timed out after {timeout} seconds") from e
86104

87105
return content
88106

@@ -111,7 +129,6 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
111129

112130
except Exception as e:
113131
logger.error(traceback.format_exc())
114-
logger.error(f"Error during call_tool: {str(e)}")
115132
raise RuntimeError(f"Error executing command: {str(e)}") from e
116133

117134

0 commit comments

Comments
 (0)