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.
@@ -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
+ )