mcp-python-repl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_python_repl/config.py +82 -0
- mcp_python_repl/executor.py +235 -0
- mcp_python_repl/server.py +754 -0
- mcp_python_repl/session.py +142 -0
- mcp_python_repl-0.1.0.dist-info/METADATA +187 -0
- mcp_python_repl-0.1.0.dist-info/RECORD +8 -0
- mcp_python_repl-0.1.0.dist-info/WHEEL +4 -0
- mcp_python_repl-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for mcp-python-repl.
|
|
3
|
+
|
|
4
|
+
All settings can be overridden via environment variables prefixed with REPL_.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Defaults
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
16
|
+
DEFAULT_MAX_SESSIONS = 50
|
|
17
|
+
DEFAULT_SESSION_TTL_MINUTES = 120
|
|
18
|
+
DEFAULT_MAX_OUTPUT_BYTES = 1_048_576 # 1 MB
|
|
19
|
+
DEFAULT_LOG_ENTRIES = 200
|
|
20
|
+
|
|
21
|
+
# Modules / builtins that are blocked in sandboxed mode
|
|
22
|
+
SANDBOXED_BLOCKED_MODULES = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"subprocess",
|
|
25
|
+
"shutil",
|
|
26
|
+
"ctypes",
|
|
27
|
+
"socket",
|
|
28
|
+
"http.server",
|
|
29
|
+
"xmlrpc",
|
|
30
|
+
"ftplib",
|
|
31
|
+
"smtplib",
|
|
32
|
+
"telnetlib",
|
|
33
|
+
"webbrowser",
|
|
34
|
+
"antigravity",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SANDBOXED_BLOCKED_BUILTINS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"exec",
|
|
41
|
+
"eval",
|
|
42
|
+
"compile",
|
|
43
|
+
"__import__",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Config:
|
|
50
|
+
"""Immutable server configuration resolved from environment."""
|
|
51
|
+
|
|
52
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
|
|
53
|
+
max_sessions: int = DEFAULT_MAX_SESSIONS
|
|
54
|
+
session_ttl_minutes: int = DEFAULT_SESSION_TTL_MINUTES
|
|
55
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
|
|
56
|
+
max_log_entries: int = DEFAULT_LOG_ENTRIES
|
|
57
|
+
sandbox_enabled: bool = False
|
|
58
|
+
working_directory: str = field(default_factory=os.getcwd)
|
|
59
|
+
transport: str = "stdio"
|
|
60
|
+
host: str = "127.0.0.1"
|
|
61
|
+
port: int = 8000
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_env(cls) -> Config:
|
|
66
|
+
"""Build configuration from REPL_* environment variables."""
|
|
67
|
+
|
|
68
|
+
def _env(key: str, default: str) -> str:
|
|
69
|
+
return os.environ.get(f"REPL_{key}", default)
|
|
70
|
+
|
|
71
|
+
return cls(
|
|
72
|
+
timeout_seconds=int(_env("TIMEOUT", str(DEFAULT_TIMEOUT_SECONDS))),
|
|
73
|
+
max_sessions=int(_env("MAX_SESSIONS", str(DEFAULT_MAX_SESSIONS))),
|
|
74
|
+
session_ttl_minutes=int(_env("SESSION_TTL", str(DEFAULT_SESSION_TTL_MINUTES))),
|
|
75
|
+
max_output_bytes=int(_env("MAX_OUTPUT", str(DEFAULT_MAX_OUTPUT_BYTES))),
|
|
76
|
+
max_log_entries=int(_env("MAX_LOG_ENTRIES", str(DEFAULT_LOG_ENTRIES))),
|
|
77
|
+
sandbox_enabled=_env("SANDBOX", "false").lower() in ("1", "true", "yes"),
|
|
78
|
+
working_directory=_env("WORKDIR", os.getcwd()),
|
|
79
|
+
transport=_env("TRANSPORT", "stdio"),
|
|
80
|
+
host=_env("HOST", "127.0.0.1"),
|
|
81
|
+
port=int(_env("PORT", "8000")),
|
|
82
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python code executor with timeout protection and optional sandboxing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import builtins
|
|
8
|
+
import io
|
|
9
|
+
import signal
|
|
10
|
+
import traceback
|
|
11
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .config import SANDBOXED_BLOCKED_BUILTINS, SANDBOXED_BLOCKED_MODULES, Config
|
|
17
|
+
from .session import ExecutionRecord, Session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExecutionResult:
|
|
22
|
+
"""Structured result of a single code execution."""
|
|
23
|
+
|
|
24
|
+
status: str # "completed" | "error" | "timeout"
|
|
25
|
+
result: Any | None = None
|
|
26
|
+
stdout: str = ""
|
|
27
|
+
stderr: str = ""
|
|
28
|
+
error_type: str | None = None
|
|
29
|
+
error_message: str | None = None
|
|
30
|
+
error_traceback: str | None = None
|
|
31
|
+
error_line: int | None = None
|
|
32
|
+
hint: str | None = None
|
|
33
|
+
new_vars: list[str] | None = None
|
|
34
|
+
modified_vars: list[str] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TimeoutError(Exception): # noqa: A001
|
|
38
|
+
"""Raised when code execution exceeds the allowed time."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _timeout_handler(signum: int, frame: Any) -> None:
|
|
42
|
+
raise TimeoutError("Code execution timed out")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_sandbox_builtins(original_builtins: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
"""Return a copy of builtins with dangerous items removed."""
|
|
47
|
+
safe = dict(vars(builtins))
|
|
48
|
+
|
|
49
|
+
# Remove dangerous builtins
|
|
50
|
+
for name in SANDBOXED_BLOCKED_BUILTINS:
|
|
51
|
+
safe.pop(name, None)
|
|
52
|
+
|
|
53
|
+
# Replace __import__ with a restricted version
|
|
54
|
+
_real_import = builtins.__import__
|
|
55
|
+
|
|
56
|
+
def _restricted_import(
|
|
57
|
+
name: str,
|
|
58
|
+
globals: dict | None = None,
|
|
59
|
+
locals: dict | None = None,
|
|
60
|
+
fromlist: tuple = (),
|
|
61
|
+
level: int = 0,
|
|
62
|
+
) -> Any:
|
|
63
|
+
top_level = name.split(".")[0]
|
|
64
|
+
if top_level in SANDBOXED_BLOCKED_MODULES:
|
|
65
|
+
raise ImportError(
|
|
66
|
+
f"Module '{name}' is blocked in sandbox mode. "
|
|
67
|
+
f"Blocked modules: {', '.join(sorted(SANDBOXED_BLOCKED_MODULES))}"
|
|
68
|
+
)
|
|
69
|
+
return _real_import(name, globals, locals, fromlist, level)
|
|
70
|
+
|
|
71
|
+
safe["__import__"] = _restricted_import
|
|
72
|
+
return safe
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def execute_code(
|
|
76
|
+
code: str,
|
|
77
|
+
session: Session,
|
|
78
|
+
config: Config,
|
|
79
|
+
) -> ExecutionResult:
|
|
80
|
+
"""
|
|
81
|
+
Execute *code* inside *session*'s namespace.
|
|
82
|
+
|
|
83
|
+
- Captures stdout / stderr
|
|
84
|
+
- Enforces timeout via SIGALRM (Unix) or threading (Windows)
|
|
85
|
+
- Optionally sandboxes dangerous builtins / imports
|
|
86
|
+
- Persists new / modified variables in session namespace
|
|
87
|
+
- Records execution in session history
|
|
88
|
+
"""
|
|
89
|
+
if not code or not code.strip():
|
|
90
|
+
return ExecutionResult(
|
|
91
|
+
status="error", error_type="ValueError", error_message="No code provided"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Build execution namespace
|
|
95
|
+
exec_ns: dict[str, Any] = {**session.namespace}
|
|
96
|
+
|
|
97
|
+
if config.sandbox_enabled:
|
|
98
|
+
exec_ns["__builtins__"] = _make_sandbox_builtins(vars(builtins))
|
|
99
|
+
else:
|
|
100
|
+
exec_ns["__builtins__"] = builtins
|
|
101
|
+
|
|
102
|
+
exec_ns.pop("result", None) # Clear previous result
|
|
103
|
+
|
|
104
|
+
stdout_buf = io.StringIO()
|
|
105
|
+
stderr_buf = io.StringIO()
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Execute with timeout
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
use_signal = hasattr(signal, "SIGALRM")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if use_signal:
|
|
114
|
+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
|
|
115
|
+
signal.alarm(config.timeout_seconds)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
119
|
+
exec(code, exec_ns) # noqa: S102
|
|
120
|
+
finally:
|
|
121
|
+
if use_signal:
|
|
122
|
+
signal.alarm(0)
|
|
123
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
124
|
+
|
|
125
|
+
except TimeoutError:
|
|
126
|
+
_record(session, code, "timeout")
|
|
127
|
+
return ExecutionResult(
|
|
128
|
+
status="timeout",
|
|
129
|
+
error_type="TimeoutError",
|
|
130
|
+
error_message=f"Execution exceeded {config.timeout_seconds}s limit",
|
|
131
|
+
hint="Break your code into smaller chunks or increase REPL_TIMEOUT.",
|
|
132
|
+
stdout=_truncate(stdout_buf.getvalue(), config.max_output_bytes),
|
|
133
|
+
stderr=_truncate(stderr_buf.getvalue(), config.max_output_bytes),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
except SyntaxError as exc:
|
|
137
|
+
_record(session, code, "error", error=str(exc))
|
|
138
|
+
return ExecutionResult(
|
|
139
|
+
status="error",
|
|
140
|
+
error_type="SyntaxError",
|
|
141
|
+
error_message=exc.msg,
|
|
142
|
+
error_line=exc.lineno,
|
|
143
|
+
hint="Check your Python syntax.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
except NameError as exc:
|
|
147
|
+
_record(session, code, "error", error=str(exc))
|
|
148
|
+
available = list(session.variable_summary().keys())
|
|
149
|
+
return ExecutionResult(
|
|
150
|
+
status="error",
|
|
151
|
+
error_type="NameError",
|
|
152
|
+
error_message=str(exc),
|
|
153
|
+
hint=(
|
|
154
|
+
"Variable not found. Variables persist by their ASSIGNED NAME, "
|
|
155
|
+
"not through 'result'. Use repl_list_namespace() to see available variables. "
|
|
156
|
+
f"Currently available: {available}"
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
_record(session, code, "error", error=str(exc))
|
|
162
|
+
return ExecutionResult(
|
|
163
|
+
status="error",
|
|
164
|
+
error_type=type(exc).__name__,
|
|
165
|
+
error_message=str(exc),
|
|
166
|
+
error_traceback=traceback.format_exc(),
|
|
167
|
+
stdout=_truncate(stdout_buf.getvalue(), config.max_output_bytes),
|
|
168
|
+
stderr=_truncate(stderr_buf.getvalue(), config.max_output_bytes),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Persist namespace changes
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
new_vars: list[str] = []
|
|
175
|
+
modified_vars: list[str] = []
|
|
176
|
+
|
|
177
|
+
for key, value in exec_ns.items():
|
|
178
|
+
if key.startswith("_") or key in ("__builtins__", "result"):
|
|
179
|
+
continue
|
|
180
|
+
if key not in session.namespace:
|
|
181
|
+
new_vars.append(key)
|
|
182
|
+
session.namespace[key] = value
|
|
183
|
+
elif session.namespace.get(key) is not value:
|
|
184
|
+
modified_vars.append(key)
|
|
185
|
+
session.namespace[key] = value
|
|
186
|
+
|
|
187
|
+
stdout = _truncate(stdout_buf.getvalue(), config.max_output_bytes)
|
|
188
|
+
stderr = _truncate(stderr_buf.getvalue(), config.max_output_bytes)
|
|
189
|
+
result_val = exec_ns.get("result")
|
|
190
|
+
|
|
191
|
+
_record(session, code, "completed", new_vars=new_vars, modified_vars=modified_vars)
|
|
192
|
+
|
|
193
|
+
return ExecutionResult(
|
|
194
|
+
status="completed",
|
|
195
|
+
result=result_val,
|
|
196
|
+
stdout=stdout,
|
|
197
|
+
stderr=stderr,
|
|
198
|
+
new_vars=new_vars or None,
|
|
199
|
+
modified_vars=modified_vars or None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Helpers
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _truncate(text: str, max_bytes: int) -> str:
|
|
209
|
+
if len(text) <= max_bytes:
|
|
210
|
+
return text
|
|
211
|
+
return text[:max_bytes] + f"\n... [truncated, {len(text)} total chars]"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _record(
|
|
215
|
+
session: Session,
|
|
216
|
+
code: str,
|
|
217
|
+
status: str,
|
|
218
|
+
*,
|
|
219
|
+
new_vars: list[str] | None = None,
|
|
220
|
+
modified_vars: list[str] | None = None,
|
|
221
|
+
error: str | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
preview = code[:120].replace("\n", "\\n")
|
|
224
|
+
if len(code) > 120:
|
|
225
|
+
preview += "..."
|
|
226
|
+
session.history.append(
|
|
227
|
+
ExecutionRecord(
|
|
228
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
229
|
+
code_preview=preview,
|
|
230
|
+
status=status,
|
|
231
|
+
new_vars=new_vars or [],
|
|
232
|
+
modified_vars=modified_vars or [],
|
|
233
|
+
error=error,
|
|
234
|
+
)
|
|
235
|
+
)
|