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,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management for mcp-python-repl.
|
|
3
|
+
|
|
4
|
+
Each session holds an isolated Python namespace and execution history.
|
|
5
|
+
Sessions are identified by an opaque ``session_id`` (UUID) and expire
|
|
6
|
+
after a configurable TTL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExecutionRecord:
|
|
22
|
+
"""A single code-execution record for audit / debug."""
|
|
23
|
+
|
|
24
|
+
timestamp: str
|
|
25
|
+
code_preview: str
|
|
26
|
+
status: str
|
|
27
|
+
new_vars: list[str] = field(default_factory=list)
|
|
28
|
+
modified_vars: list[str] = field(default_factory=list)
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Session:
|
|
34
|
+
"""An isolated Python execution session."""
|
|
35
|
+
|
|
36
|
+
session_id: str
|
|
37
|
+
created_at: datetime
|
|
38
|
+
last_used: datetime
|
|
39
|
+
namespace: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
history: list[ExecutionRecord] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
def touch(self) -> None:
|
|
44
|
+
self.last_used = datetime.now(timezone.utc)
|
|
45
|
+
|
|
46
|
+
def is_expired(self, ttl_minutes: int) -> bool:
|
|
47
|
+
return datetime.now(timezone.utc) - self.last_used > timedelta(minutes=ttl_minutes)
|
|
48
|
+
|
|
49
|
+
def variable_summary(self) -> dict[str, str]:
|
|
50
|
+
"""Return {name: type_name} for every user variable."""
|
|
51
|
+
return {k: type(v).__name__ for k, v in self.namespace.items() if not k.startswith("_")}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SessionManager:
|
|
55
|
+
"""Thread-safe session store with automatic eviction."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: Config) -> None:
|
|
58
|
+
self._config = config
|
|
59
|
+
self._sessions: dict[str, Session] = {}
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Public API
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def create_session(self) -> Session:
|
|
67
|
+
"""Create a new session, evicting stale ones first."""
|
|
68
|
+
self._evict_expired()
|
|
69
|
+
|
|
70
|
+
with self._lock:
|
|
71
|
+
if len(self._sessions) >= self._config.max_sessions:
|
|
72
|
+
# Evict the oldest session
|
|
73
|
+
oldest_id = min(
|
|
74
|
+
self._sessions,
|
|
75
|
+
key=lambda sid: self._sessions[sid].last_used,
|
|
76
|
+
)
|
|
77
|
+
del self._sessions[oldest_id]
|
|
78
|
+
|
|
79
|
+
now = datetime.now(timezone.utc)
|
|
80
|
+
session = Session(
|
|
81
|
+
session_id=uuid.uuid4().hex[:12],
|
|
82
|
+
created_at=now,
|
|
83
|
+
last_used=now,
|
|
84
|
+
)
|
|
85
|
+
self._sessions[session.session_id] = session
|
|
86
|
+
return session
|
|
87
|
+
|
|
88
|
+
def get_session(self, session_id: str) -> Session | None:
|
|
89
|
+
"""Retrieve a session by id (returns *None* if expired / missing)."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
session = self._sessions.get(session_id)
|
|
92
|
+
if session is None:
|
|
93
|
+
return None
|
|
94
|
+
if session.is_expired(self._config.session_ttl_minutes):
|
|
95
|
+
del self._sessions[session_id]
|
|
96
|
+
return None
|
|
97
|
+
session.touch()
|
|
98
|
+
return session
|
|
99
|
+
|
|
100
|
+
def get_or_create(self, session_id: str | None) -> Session:
|
|
101
|
+
"""Return existing session or create a new one."""
|
|
102
|
+
if session_id:
|
|
103
|
+
session = self.get_session(session_id)
|
|
104
|
+
if session is not None:
|
|
105
|
+
return session
|
|
106
|
+
return self.create_session()
|
|
107
|
+
|
|
108
|
+
def delete_session(self, session_id: str) -> bool:
|
|
109
|
+
with self._lock:
|
|
110
|
+
return self._sessions.pop(session_id, None) is not None
|
|
111
|
+
|
|
112
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
113
|
+
self._evict_expired()
|
|
114
|
+
with self._lock:
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
"session_id": s.session_id,
|
|
118
|
+
"created_at": s.created_at.isoformat(),
|
|
119
|
+
"last_used": s.last_used.isoformat(),
|
|
120
|
+
"variable_count": len(s.variable_summary()),
|
|
121
|
+
"history_count": len(s.history),
|
|
122
|
+
}
|
|
123
|
+
for s in self._sessions.values()
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def count(self) -> int:
|
|
128
|
+
return len(self._sessions)
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Internals
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _evict_expired(self) -> None:
|
|
135
|
+
with self._lock:
|
|
136
|
+
expired = [
|
|
137
|
+
sid
|
|
138
|
+
for sid, s in self._sessions.items()
|
|
139
|
+
if s.is_expired(self._config.session_ttl_minutes)
|
|
140
|
+
]
|
|
141
|
+
for sid in expired:
|
|
142
|
+
del self._sessions[sid]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-python-repl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A production-grade MCP server providing a persistent Python REPL with multi-session support, sandboxing, and timeout protection.
|
|
5
|
+
Project-URL: Homepage, https://github.com/soufiane-aazizi/mcp-python-repl
|
|
6
|
+
Project-URL: Repository, https://github.com/soufiane-aazizi/mcp-python-repl
|
|
7
|
+
Project-URL: Issues, https://github.com/soufiane-aazizi/mcp-python-repl/issues
|
|
8
|
+
Author: Soufiane Aazizi
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agent,interpreter,llm,mcp,model-context-protocol,python,repl
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Software Development :: Interpreters
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: mcp>=1.7.0
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# ๐ mcp-python-repl
|
|
31
|
+
|
|
32
|
+
A **production-grade** MCP server providing a persistent Python REPL with multi-session support, sandboxing, and timeout protection.
|
|
33
|
+
|
|
34
|
+
Built for LLM agents that need to execute Python code across multiple turns with **variables that persist between calls**.
|
|
35
|
+
|
|
36
|
+
## โจ Features
|
|
37
|
+
|
|
38
|
+
| Feature | Description |
|
|
39
|
+
|---|---|
|
|
40
|
+
| **Multi-session** | Isolated sessions with unique IDs โ run parallel workflows |
|
|
41
|
+
| **Persistent namespace** | Variables survive across calls within a session |
|
|
42
|
+
| **Timeout protection** | Configurable execution timeout (SIGALRM on Unix) |
|
|
43
|
+
| **Sandboxing** | Optional mode blocks dangerous modules (`subprocess`, `socket`, etc.) |
|
|
44
|
+
| **Package install** | Install pip packages on-the-fly (prefers `uv` for speed) |
|
|
45
|
+
| **File execution** | Run `.py` files inside the persistent session |
|
|
46
|
+
| **Dual transport** | stdio (local) and streamable-http (remote) |
|
|
47
|
+
| **Full introspection** | List variables, get history, check server status |
|
|
48
|
+
| **Env-based config** | All settings via `REPL_*` environment variables |
|
|
49
|
+
|
|
50
|
+
## ๐ Quick Start
|
|
51
|
+
|
|
52
|
+
### With Claude Desktop / Cursor (stdio)
|
|
53
|
+
|
|
54
|
+
Add to your MCP config:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"python-repl": {
|
|
60
|
+
"command": "uvx",
|
|
61
|
+
"args": ["mcp-python-repl"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### With uv (local dev)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Clone and run
|
|
71
|
+
git clone https://github.com/soufiane-aazizi/mcp-python-repl.git
|
|
72
|
+
cd mcp-python-repl
|
|
73
|
+
uv run mcp-python-repl
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### HTTP transport (remote / multi-client)
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
REPL_TRANSPORT=streamable-http REPL_PORT=8000 uv run mcp-python-repl
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## ๐ ๏ธ Tools
|
|
83
|
+
|
|
84
|
+
### Code Execution
|
|
85
|
+
|
|
86
|
+
| Tool | Description |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `repl_run_code` | Execute Python code with persistent namespace |
|
|
89
|
+
| `repl_run_file` | Execute a `.py` file in the session |
|
|
90
|
+
| `repl_install_package` | Install a pip package (uses `uv` if available) |
|
|
91
|
+
|
|
92
|
+
### Namespace Management
|
|
93
|
+
|
|
94
|
+
| Tool | Description |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `repl_list_namespace` | List all variables in a session |
|
|
97
|
+
| `repl_get_variable` | Get the full value of a variable |
|
|
98
|
+
| `repl_set_variable` | Inject a variable from JSON |
|
|
99
|
+
| `repl_delete_variable` | Delete a specific variable |
|
|
100
|
+
| `repl_clear_namespace` | Clear all variables in a session |
|
|
101
|
+
|
|
102
|
+
### Session Management
|
|
103
|
+
|
|
104
|
+
| Tool | Description |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `repl_list_sessions` | List all active sessions |
|
|
107
|
+
| `repl_delete_session` | Delete a session and its data |
|
|
108
|
+
|
|
109
|
+
### Debugging
|
|
110
|
+
|
|
111
|
+
| Tool | Description |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `repl_get_history` | Get execution history for a session |
|
|
114
|
+
| `repl_server_status` | Server config, Python version, session count |
|
|
115
|
+
|
|
116
|
+
## ๐ How Persistence Works
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Call 1: repl_run_code(code="data = [1,2,3]; total = sum(data); result = total")
|
|
120
|
+
โ returns: {"result": 6, "session_id": "a1b2c3d4e5f6", "new_variables": ["data", "total"]}
|
|
121
|
+
|
|
122
|
+
Call 2: repl_run_code(code="doubled = [x*2 for x in data]; result = doubled", session_id="a1b2c3d4e5f6")
|
|
123
|
+
โ returns: {"result": [2,4,6], "new_variables": ["doubled"]}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
> **Important:** The `result` variable is for returning output to the caller. It does **NOT** persist. Use named variables instead.
|
|
127
|
+
|
|
128
|
+
## โ๏ธ Configuration
|
|
129
|
+
|
|
130
|
+
All settings are configurable via environment variables:
|
|
131
|
+
|
|
132
|
+
| Variable | Default | Description |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `REPL_TIMEOUT` | `30` | Max execution time in seconds |
|
|
135
|
+
| `REPL_MAX_SESSIONS` | `50` | Maximum concurrent sessions |
|
|
136
|
+
| `REPL_SESSION_TTL` | `120` | Session expiry in minutes |
|
|
137
|
+
| `REPL_MAX_OUTPUT` | `1048576` | Max stdout/stderr capture (bytes) |
|
|
138
|
+
| `REPL_SANDBOX` | `false` | Enable sandboxing (`true`/`false`) |
|
|
139
|
+
| `REPL_TRANSPORT` | `stdio` | Transport: `stdio` or `streamable-http` |
|
|
140
|
+
| `REPL_HOST` | `127.0.0.1` | HTTP host (when using HTTP transport) |
|
|
141
|
+
| `REPL_PORT` | `8000` | HTTP port (when using HTTP transport) |
|
|
142
|
+
| `REPL_WORKDIR` | `cwd` | Working directory for executions |
|
|
143
|
+
|
|
144
|
+
### Sandbox Mode
|
|
145
|
+
|
|
146
|
+
When `REPL_SANDBOX=true`, the following modules are blocked:
|
|
147
|
+
|
|
148
|
+
`subprocess`, `shutil`, `ctypes`, `socket`, `http.server`, `xmlrpc`, `ftplib`, `smtplib`, `telnetlib`, `webbrowser`
|
|
149
|
+
|
|
150
|
+
And the following builtins are removed: `exec`, `eval`, `compile`, `__import__` (replaced with a restricted version).
|
|
151
|
+
|
|
152
|
+
## ๐งช Development
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# Install dev dependencies
|
|
156
|
+
uv sync --extra dev
|
|
157
|
+
|
|
158
|
+
# Run tests
|
|
159
|
+
uv run pytest -v
|
|
160
|
+
|
|
161
|
+
# Lint
|
|
162
|
+
uv run ruff check src/ tests/
|
|
163
|
+
|
|
164
|
+
# Test with MCP Inspector
|
|
165
|
+
npx @modelcontextprotocol/inspector uv run mcp-python-repl
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## ๐ฆ Project Structure
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
mcp-python-repl/
|
|
172
|
+
โโโ src/mcp_python_repl/
|
|
173
|
+
โ โโโ __init__.py # Package metadata
|
|
174
|
+
โ โโโ config.py # Env-based configuration
|
|
175
|
+
โ โโโ session.py # Multi-session manager with TTL
|
|
176
|
+
โ โโโ executor.py # Python code executor (timeout + sandbox)
|
|
177
|
+
โ โโโ server.py # MCP server with all tools
|
|
178
|
+
โโโ tests/
|
|
179
|
+
โ โโโ test_core.py # Unit + integration tests
|
|
180
|
+
โโโ pyproject.toml # uv/hatch project config
|
|
181
|
+
โโโ LICENSE # MIT
|
|
182
|
+
โโโ README.md
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## ๐ License
|
|
186
|
+
|
|
187
|
+
MIT โ See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mcp_python_repl/config.py,sha256=V1bcy6z8fcTUMzKlhZSPDE2wAguG9Ug32b79BdCCXMg,2570
|
|
2
|
+
mcp_python_repl/executor.py,sha256=uFfw4P6fzAuE4zgKM6VEy82voQslGU3zxI1rrvOAsrw,7575
|
|
3
|
+
mcp_python_repl/server.py,sha256=Shnug32hOh31Qr7TQjnxVrPmg-VxaJGMOhV4m3rn9qQ,21998
|
|
4
|
+
mcp_python_repl/session.py,sha256=GkljC4OtmHojF51grUDYYJ5NAhEW00zPUQmtTPUxmNQ,4671
|
|
5
|
+
mcp_python_repl-0.1.0.dist-info/METADATA,sha256=S_UJIR7zgFeJJV6l334yf3N7oD5P4iObAxnJB0uzPlI,6309
|
|
6
|
+
mcp_python_repl-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
mcp_python_repl-0.1.0.dist-info/entry_points.txt,sha256=DHFkUiJlWbQ8SDZpEvCaH1EvOfeuJcDwJ1rWKwTvMww,64
|
|
8
|
+
mcp_python_repl-0.1.0.dist-info/RECORD,,
|