sym-mcp 0.0.0.post1.dev3__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.
- sym_mcp/__init__.py +2 -0
- sym_mcp/__main__.py +6 -0
- sym_mcp/config.py +27 -0
- sym_mcp/errors/__init__.py +2 -0
- sym_mcp/errors/parser.py +114 -0
- sym_mcp/executor/__init__.py +2 -0
- sym_mcp/executor/pool.py +182 -0
- sym_mcp/executor/sandbox.py +82 -0
- sym_mcp/executor/worker_main.py +65 -0
- sym_mcp/schemas.py +8 -0
- sym_mcp/security/__init__.py +2 -0
- sym_mcp/security/ast_guard.py +190 -0
- sym_mcp/server.py +153 -0
- sym_mcp-0.0.0.post1.dev3.dist-info/METADATA +269 -0
- sym_mcp-0.0.0.post1.dev3.dist-info/RECORD +19 -0
- sym_mcp-0.0.0.post1.dev3.dist-info/WHEEL +5 -0
- sym_mcp-0.0.0.post1.dev3.dist-info/entry_points.txt +2 -0
- sym_mcp-0.0.0.post1.dev3.dist-info/licenses/LICENSE +21 -0
- sym_mcp-0.0.0.post1.dev3.dist-info/top_level.txt +1 -0
sym_mcp/__init__.py
ADDED
sym_mcp/__main__.py
ADDED
sym_mcp/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Settings:
|
|
9
|
+
pool_size: int = 10
|
|
10
|
+
exec_timeout_sec: float = 3.0
|
|
11
|
+
memory_limit_mb: int = 150
|
|
12
|
+
queue_wait_sec: float = 2.0
|
|
13
|
+
log_level: str = "INFO"
|
|
14
|
+
max_output_chars: int = 1200
|
|
15
|
+
hint_level: str = "medium"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_settings() -> Settings:
|
|
19
|
+
return Settings(
|
|
20
|
+
pool_size=int(os.getenv("SYMMCP_POOL_SIZE", "10")),
|
|
21
|
+
exec_timeout_sec=float(os.getenv("SYMMCP_EXEC_TIMEOUT_SEC", "3")),
|
|
22
|
+
memory_limit_mb=int(os.getenv("SYMMCP_MEMORY_LIMIT_MB", "150")),
|
|
23
|
+
queue_wait_sec=float(os.getenv("SYMMCP_QUEUE_WAIT_SEC", "2")),
|
|
24
|
+
log_level=os.getenv("SYMMCP_LOG_LEVEL", "INFO"),
|
|
25
|
+
max_output_chars=max(100, int(os.getenv("SYMMCP_MAX_OUTPUT_CHARS", "1200"))),
|
|
26
|
+
hint_level=os.getenv("SYMMCP_HINT_LEVEL", "medium"),
|
|
27
|
+
)
|
sym_mcp/errors/parser.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
DEFAULT_HINT_LEVEL = "medium"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ParsedError:
|
|
11
|
+
code: str
|
|
12
|
+
line: int | None
|
|
13
|
+
err: str
|
|
14
|
+
hint: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_traceback(tb_text: str, hint_level: str = DEFAULT_HINT_LEVEL) -> ParsedError:
|
|
18
|
+
line = _extract_user_line(tb_text)
|
|
19
|
+
err_text = _extract_error_text(tb_text)
|
|
20
|
+
code = _classify_error(err_text)
|
|
21
|
+
hint = build_hint(code, hint_level=hint_level)
|
|
22
|
+
return ParsedError(code=code, line=line, err=err_text, hint=hint)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_guard_message(message: str, hint_level: str = DEFAULT_HINT_LEVEL) -> ParsedError:
|
|
26
|
+
if message.startswith("语法错误"):
|
|
27
|
+
code = "E_SYNTAX"
|
|
28
|
+
line = _extract_line_from_guard(message)
|
|
29
|
+
err = message
|
|
30
|
+
else:
|
|
31
|
+
code = "E_AST_BLOCK"
|
|
32
|
+
line = _extract_line_from_guard(message)
|
|
33
|
+
err = message
|
|
34
|
+
return ParsedError(code=code, line=line, err=err, hint=build_hint(code, hint_level=hint_level))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_pool_error(message: str, hint_level: str = DEFAULT_HINT_LEVEL) -> ParsedError:
|
|
38
|
+
if "超时" in message:
|
|
39
|
+
code = "E_TIMEOUT"
|
|
40
|
+
else:
|
|
41
|
+
code = "E_WORKER"
|
|
42
|
+
return ParsedError(code=code, line=None, err=message, hint=build_hint(code, hint_level=hint_level))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_internal_error(message: str, hint_level: str = DEFAULT_HINT_LEVEL) -> ParsedError:
|
|
46
|
+
clean = (message or "").strip() or "internal error"
|
|
47
|
+
return ParsedError(code="E_INTERNAL", line=None, err=clean, hint=build_hint("E_INTERNAL", hint_level=hint_level))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_hint(code: str, hint_level: str = DEFAULT_HINT_LEVEL) -> str:
|
|
51
|
+
if hint_level == "none":
|
|
52
|
+
return ""
|
|
53
|
+
if hint_level == "short":
|
|
54
|
+
return "根据错误码与行号最小改动后重试。"
|
|
55
|
+
|
|
56
|
+
hints = {
|
|
57
|
+
"E_AST_BLOCK": "检测到不安全语句。请仅保留 sympy/math 相关计算代码,并移除系统调用后重试。",
|
|
58
|
+
"E_SYNTAX": "代码存在语法错误。请先修正报错行附近的括号、缩进或符号,再重新执行。",
|
|
59
|
+
"E_TIMEOUT": "计算超时。请减少计算规模、拆分步骤或先做代数化简后再求解。",
|
|
60
|
+
"E_MEMORY": "内存不足。请降低矩阵维度/展开规模,避免一次性构造超大对象。",
|
|
61
|
+
"E_RUNTIME": "运行时错误。请根据行号检查变量类型、零除、未定义变量等问题后重试。",
|
|
62
|
+
"E_WORKER": "执行进程异常。请保持代码简洁后重试;若持续失败请重新发起调用。",
|
|
63
|
+
"E_INTERNAL": "服务内部异常。请重试一次;若仍失败请保留输入代码用于排查。",
|
|
64
|
+
}
|
|
65
|
+
return hints.get(code, hints["E_RUNTIME"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_user_line(tb_text: str) -> int | None:
|
|
69
|
+
if not tb_text.strip():
|
|
70
|
+
return None
|
|
71
|
+
lines = tb_text.strip().splitlines()
|
|
72
|
+
for ln in lines:
|
|
73
|
+
s = ln.strip()
|
|
74
|
+
if s.startswith('File "<user_code>", line '):
|
|
75
|
+
try:
|
|
76
|
+
return int(s.split("line ")[1].split(",")[0])
|
|
77
|
+
except (IndexError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_error_text(tb_text: str) -> str:
|
|
83
|
+
default = "RuntimeError: 未知错误"
|
|
84
|
+
if not tb_text.strip():
|
|
85
|
+
return default
|
|
86
|
+
tail = tb_text.strip().splitlines()[-1].strip()
|
|
87
|
+
# 兼容无消息的异常尾行,例如 "MemoryError"
|
|
88
|
+
if ":" not in tail and re.fullmatch(r"[A-Za-z_]\w*", tail):
|
|
89
|
+
return f"{tail}: 未知错误"
|
|
90
|
+
if ":" not in tail:
|
|
91
|
+
return default
|
|
92
|
+
etype, msg = tail.split(":", 1)
|
|
93
|
+
etype = etype.strip() or "RuntimeError"
|
|
94
|
+
msg = msg.strip() or "未知错误"
|
|
95
|
+
return f"{etype}: {msg}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _classify_error(err_text: str) -> str:
|
|
99
|
+
etype = err_text.split(":", 1)[0]
|
|
100
|
+
if etype in {"MemoryError"}:
|
|
101
|
+
return "E_MEMORY"
|
|
102
|
+
if etype in {"TimeoutError"}:
|
|
103
|
+
return "E_TIMEOUT"
|
|
104
|
+
return "E_RUNTIME"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _extract_line_from_guard(message: str) -> int | None:
|
|
108
|
+
m = re.search(r"第\s*(\d+)\s*行", message)
|
|
109
|
+
if not m:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return int(m.group(1))
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
sym_mcp/executor/pool.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import multiprocessing as mp
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from multiprocessing.connection import Connection
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sym_mcp.executor.worker_main import run_worker
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkerPoolError(RuntimeError):
|
|
17
|
+
"""Errors from worker pool."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Worker:
|
|
22
|
+
worker_id: int
|
|
23
|
+
process: mp.Process
|
|
24
|
+
conn: Connection
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkerPool:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
size: int,
|
|
31
|
+
exec_timeout_sec: float,
|
|
32
|
+
queue_wait_sec: float,
|
|
33
|
+
memory_limit_mb: int,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._size = size
|
|
36
|
+
self._exec_timeout_sec = exec_timeout_sec
|
|
37
|
+
self._queue_wait_sec = queue_wait_sec
|
|
38
|
+
self._memory_limit_mb = memory_limit_mb
|
|
39
|
+
self._ctx = mp.get_context("spawn")
|
|
40
|
+
self._queue: asyncio.Queue[Worker] = asyncio.Queue(maxsize=size)
|
|
41
|
+
self._workers: dict[int, Worker] = {}
|
|
42
|
+
self._lock = asyncio.Lock()
|
|
43
|
+
self._started = False
|
|
44
|
+
self.timeout_count = 0
|
|
45
|
+
self.rebuild_count = 0
|
|
46
|
+
self.reject_count = 0
|
|
47
|
+
|
|
48
|
+
async def start(self) -> None:
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if self._started:
|
|
51
|
+
return
|
|
52
|
+
for i in range(self._size):
|
|
53
|
+
worker = self._spawn_worker(worker_id=i)
|
|
54
|
+
await self._wait_worker_ready(worker)
|
|
55
|
+
await self._queue.put(worker)
|
|
56
|
+
self._workers[worker.worker_id] = worker
|
|
57
|
+
self._started = True
|
|
58
|
+
LOGGER.info("Worker pool started with size=%s", self._size)
|
|
59
|
+
|
|
60
|
+
async def close(self) -> None:
|
|
61
|
+
async with self._lock:
|
|
62
|
+
workers = list(self._workers.values())
|
|
63
|
+
self._workers.clear()
|
|
64
|
+
self._started = False
|
|
65
|
+
while not self._queue.empty():
|
|
66
|
+
self._queue.get_nowait()
|
|
67
|
+
|
|
68
|
+
for worker in workers:
|
|
69
|
+
await self._shutdown_worker(worker)
|
|
70
|
+
|
|
71
|
+
async def exec(self, code: str) -> dict[str, Any]:
|
|
72
|
+
if not self._started:
|
|
73
|
+
raise WorkerPoolError("worker pool not started")
|
|
74
|
+
borrow_start = time.perf_counter()
|
|
75
|
+
try:
|
|
76
|
+
worker = await asyncio.wait_for(self._queue.get(), timeout=self._queue_wait_sec)
|
|
77
|
+
except TimeoutError as exc:
|
|
78
|
+
self.reject_count += 1
|
|
79
|
+
raise WorkerPoolError("执行排队超时,请稍后重试。") from exc
|
|
80
|
+
borrow_ms = (time.perf_counter() - borrow_start) * 1000
|
|
81
|
+
LOGGER.debug("borrow worker=%s cost=%.2fms", worker.worker_id, borrow_ms)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = await self._exec_on_worker(worker, code)
|
|
85
|
+
finally:
|
|
86
|
+
if self._is_alive(worker):
|
|
87
|
+
await self._queue.put(worker)
|
|
88
|
+
else:
|
|
89
|
+
await self._replace_worker(worker)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
async def health_check(self) -> None:
|
|
93
|
+
for worker in list(self._workers.values()):
|
|
94
|
+
if not self._is_alive(worker):
|
|
95
|
+
await self._replace_worker(worker)
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
await self._request(worker, {"cmd": "ping"}, timeout=1.0)
|
|
99
|
+
except Exception:
|
|
100
|
+
await self._replace_worker(worker)
|
|
101
|
+
|
|
102
|
+
def _spawn_worker(self, worker_id: int) -> Worker:
|
|
103
|
+
parent_conn, child_conn = self._ctx.Pipe(duplex=True)
|
|
104
|
+
proc = self._ctx.Process(
|
|
105
|
+
target=run_worker,
|
|
106
|
+
args=(child_conn, self._memory_limit_mb, self._exec_timeout_sec),
|
|
107
|
+
daemon=True,
|
|
108
|
+
name=f"sym-worker-{worker_id}",
|
|
109
|
+
)
|
|
110
|
+
proc.start()
|
|
111
|
+
child_conn.close()
|
|
112
|
+
return Worker(worker_id=worker_id, process=proc, conn=parent_conn)
|
|
113
|
+
|
|
114
|
+
async def _replace_worker(self, old_worker: Worker) -> None:
|
|
115
|
+
self.rebuild_count += 1
|
|
116
|
+
LOGGER.warning("replace dead worker=%s", old_worker.worker_id)
|
|
117
|
+
await self._shutdown_worker(old_worker)
|
|
118
|
+
new_worker = self._spawn_worker(old_worker.worker_id)
|
|
119
|
+
await self._wait_worker_ready(new_worker)
|
|
120
|
+
self._workers[new_worker.worker_id] = new_worker
|
|
121
|
+
await self._queue.put(new_worker)
|
|
122
|
+
|
|
123
|
+
async def _shutdown_worker(self, worker: Worker) -> None:
|
|
124
|
+
try:
|
|
125
|
+
if self._is_alive(worker):
|
|
126
|
+
await self._request(worker, {"cmd": "stop"}, timeout=0.5)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
try:
|
|
130
|
+
if self._is_alive(worker):
|
|
131
|
+
worker.process.terminate()
|
|
132
|
+
await asyncio.to_thread(worker.process.join, 0.5)
|
|
133
|
+
if self._is_alive(worker):
|
|
134
|
+
worker.process.kill()
|
|
135
|
+
await asyncio.to_thread(worker.process.join, 0.5)
|
|
136
|
+
finally:
|
|
137
|
+
if worker.worker_id in self._workers and self._workers[worker.worker_id] is worker:
|
|
138
|
+
self._workers.pop(worker.worker_id, None)
|
|
139
|
+
try:
|
|
140
|
+
worker.conn.close()
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
async def _exec_on_worker(self, worker: Worker, code: str) -> dict[str, Any]:
|
|
145
|
+
start = time.perf_counter()
|
|
146
|
+
try:
|
|
147
|
+
result = await self._request(worker, {"cmd": "exec", "code": code}, timeout=self._exec_timeout_sec)
|
|
148
|
+
exec_ms = (time.perf_counter() - start) * 1000
|
|
149
|
+
LOGGER.debug("exec worker=%s cost=%.2fms", worker.worker_id, exec_ms)
|
|
150
|
+
return result
|
|
151
|
+
except TimeoutError as exc:
|
|
152
|
+
self.timeout_count += 1
|
|
153
|
+
LOGGER.warning("worker=%s timeout, terminate", worker.worker_id)
|
|
154
|
+
await self._kill_worker(worker)
|
|
155
|
+
raise WorkerPoolError("代码执行超时(超过限制)。") from exc
|
|
156
|
+
except (EOFError, BrokenPipeError, ConnectionError, OSError) as exc:
|
|
157
|
+
LOGGER.warning("worker=%s communication broken, terminate", worker.worker_id)
|
|
158
|
+
await self._kill_worker(worker)
|
|
159
|
+
raise WorkerPoolError("执行进程异常,请重试。") from exc
|
|
160
|
+
|
|
161
|
+
async def _kill_worker(self, worker: Worker) -> None:
|
|
162
|
+
if self._is_alive(worker):
|
|
163
|
+
worker.process.terminate()
|
|
164
|
+
await asyncio.to_thread(worker.process.join, 0.5)
|
|
165
|
+
if self._is_alive(worker):
|
|
166
|
+
worker.process.kill()
|
|
167
|
+
await asyncio.to_thread(worker.process.join, 0.5)
|
|
168
|
+
|
|
169
|
+
async def _request(self, worker: Worker, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
170
|
+
worker.conn.send(payload)
|
|
171
|
+
ready = await asyncio.wait_for(asyncio.to_thread(worker.conn.poll, timeout), timeout=timeout + 0.2)
|
|
172
|
+
if not ready:
|
|
173
|
+
raise TimeoutError("worker response timeout")
|
|
174
|
+
return worker.conn.recv()
|
|
175
|
+
|
|
176
|
+
async def _wait_worker_ready(self, worker: Worker) -> None:
|
|
177
|
+
startup_timeout = max(15.0, self._exec_timeout_sec * 5)
|
|
178
|
+
await self._request(worker, {"cmd": "ping"}, timeout=startup_timeout)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _is_alive(worker: Worker) -> bool:
|
|
182
|
+
return worker.process.is_alive()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import traceback
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import sympy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SAFE_BUILTINS = {
|
|
14
|
+
"abs": abs,
|
|
15
|
+
"all": all,
|
|
16
|
+
"any": any,
|
|
17
|
+
"bool": bool,
|
|
18
|
+
"dict": dict,
|
|
19
|
+
"enumerate": enumerate,
|
|
20
|
+
"float": float,
|
|
21
|
+
"int": int,
|
|
22
|
+
"len": len,
|
|
23
|
+
"list": list,
|
|
24
|
+
"max": max,
|
|
25
|
+
"min": min,
|
|
26
|
+
"pow": pow,
|
|
27
|
+
"print": print,
|
|
28
|
+
"range": range,
|
|
29
|
+
"reversed": reversed,
|
|
30
|
+
"round": round,
|
|
31
|
+
"set": set,
|
|
32
|
+
"sorted": sorted,
|
|
33
|
+
"str": str,
|
|
34
|
+
"sum": sum,
|
|
35
|
+
"tuple": tuple,
|
|
36
|
+
"zip": zip,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
ALLOWED_IMPORT_ROOTS = {"sympy", "math"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ExecResult:
|
|
44
|
+
success: bool
|
|
45
|
+
stdout: str = ""
|
|
46
|
+
stderr: str = ""
|
|
47
|
+
traceback_text: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_exec_globals() -> dict[str, Any]:
|
|
51
|
+
def _safe_import(name: str, globals=None, locals=None, fromlist=(), level=0):
|
|
52
|
+
root = name.split(".")[0]
|
|
53
|
+
if root not in ALLOWED_IMPORT_ROOTS:
|
|
54
|
+
raise ImportError(f"禁止导入模块: {name}")
|
|
55
|
+
return __import__(name, globals, locals, fromlist, level)
|
|
56
|
+
|
|
57
|
+
safe_builtins = dict(SAFE_BUILTINS)
|
|
58
|
+
safe_builtins["__import__"] = _safe_import
|
|
59
|
+
return {
|
|
60
|
+
"__builtins__": safe_builtins,
|
|
61
|
+
"sympy": sympy,
|
|
62
|
+
"math": math,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def execute_user_code(code: str) -> ExecResult:
|
|
67
|
+
glb = build_exec_globals()
|
|
68
|
+
loc: dict[str, Any] = {}
|
|
69
|
+
out_buf = io.StringIO()
|
|
70
|
+
err_buf = io.StringIO()
|
|
71
|
+
try:
|
|
72
|
+
compiled = compile(code, "<user_code>", "exec")
|
|
73
|
+
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
|
|
74
|
+
exec(compiled, glb, loc)
|
|
75
|
+
except Exception:
|
|
76
|
+
return ExecResult(
|
|
77
|
+
success=False,
|
|
78
|
+
stdout=out_buf.getvalue(),
|
|
79
|
+
stderr=err_buf.getvalue(),
|
|
80
|
+
traceback_text=traceback.format_exc(),
|
|
81
|
+
)
|
|
82
|
+
return ExecResult(success=True, stdout=out_buf.getvalue(), stderr=err_buf.getvalue())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from multiprocessing.connection import Connection
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sym_mcp.executor.sandbox import execute_user_code
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import resource
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
resource = None # type: ignore[assignment]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_worker(conn: Connection, memory_limit_mb: int, cpu_limit_sec: float) -> None:
|
|
16
|
+
_apply_resource_limits(memory_limit_mb=memory_limit_mb, cpu_limit_sec=cpu_limit_sec)
|
|
17
|
+
_preload_heavy_modules()
|
|
18
|
+
while True:
|
|
19
|
+
msg = conn.recv()
|
|
20
|
+
cmd = msg.get("cmd")
|
|
21
|
+
if cmd == "ping":
|
|
22
|
+
conn.send({"ok": True, "pong": True})
|
|
23
|
+
continue
|
|
24
|
+
if cmd == "stop":
|
|
25
|
+
conn.send({"ok": True, "stopped": True})
|
|
26
|
+
break
|
|
27
|
+
if cmd != "exec":
|
|
28
|
+
conn.send({"ok": False, "error": f"unknown command: {cmd}"})
|
|
29
|
+
continue
|
|
30
|
+
code = msg.get("code", "")
|
|
31
|
+
result = execute_user_code(code)
|
|
32
|
+
conn.send(
|
|
33
|
+
{
|
|
34
|
+
"ok": True,
|
|
35
|
+
"success": result.success,
|
|
36
|
+
"stdout": result.stdout,
|
|
37
|
+
"stderr": result.stderr,
|
|
38
|
+
"traceback": result.traceback_text,
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _preload_heavy_modules() -> None:
|
|
44
|
+
import sympy # noqa: F401
|
|
45
|
+
import math as _math # noqa: F401
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_resource_limits(memory_limit_mb: int, cpu_limit_sec: float) -> None:
|
|
49
|
+
if resource is None:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
memory_bytes = memory_limit_mb * 1024 * 1024
|
|
53
|
+
for limit_name in ("RLIMIT_AS", "RLIMIT_DATA"):
|
|
54
|
+
limit = getattr(resource, limit_name, None)
|
|
55
|
+
if limit is None:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
resource.setrlimit(limit, (memory_bytes, memory_bytes))
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
os.nice(5)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
sym_mcp/schemas.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ALLOWED_MODULES = {"sympy", "math"}
|
|
8
|
+
BLOCKED_NAMES = {
|
|
9
|
+
"eval",
|
|
10
|
+
"exec",
|
|
11
|
+
"open",
|
|
12
|
+
"compile",
|
|
13
|
+
"input",
|
|
14
|
+
"globals",
|
|
15
|
+
"locals",
|
|
16
|
+
"vars",
|
|
17
|
+
"getattr",
|
|
18
|
+
"setattr",
|
|
19
|
+
"delattr",
|
|
20
|
+
"__import__",
|
|
21
|
+
"help",
|
|
22
|
+
"dir",
|
|
23
|
+
"type",
|
|
24
|
+
"super",
|
|
25
|
+
}
|
|
26
|
+
BLOCKED_ROOT_NAMES = {"os", "sys", "subprocess", "pathlib", "socket", "importlib", "builtins"}
|
|
27
|
+
|
|
28
|
+
ALLOWED_NODES = {
|
|
29
|
+
ast.Module,
|
|
30
|
+
ast.Expr,
|
|
31
|
+
ast.Assign,
|
|
32
|
+
ast.AugAssign,
|
|
33
|
+
ast.Name,
|
|
34
|
+
ast.Load,
|
|
35
|
+
ast.Store,
|
|
36
|
+
ast.Call,
|
|
37
|
+
ast.BinOp,
|
|
38
|
+
ast.UnaryOp,
|
|
39
|
+
ast.Compare,
|
|
40
|
+
ast.BoolOp,
|
|
41
|
+
ast.If,
|
|
42
|
+
ast.For,
|
|
43
|
+
ast.While,
|
|
44
|
+
ast.Break,
|
|
45
|
+
ast.Continue,
|
|
46
|
+
ast.Pass,
|
|
47
|
+
ast.Return,
|
|
48
|
+
ast.List,
|
|
49
|
+
ast.Tuple,
|
|
50
|
+
ast.Dict,
|
|
51
|
+
ast.Set,
|
|
52
|
+
ast.Subscript,
|
|
53
|
+
ast.Slice,
|
|
54
|
+
ast.Constant,
|
|
55
|
+
ast.Import,
|
|
56
|
+
ast.ImportFrom,
|
|
57
|
+
ast.alias,
|
|
58
|
+
ast.FunctionDef,
|
|
59
|
+
ast.arguments,
|
|
60
|
+
ast.arg,
|
|
61
|
+
ast.keyword,
|
|
62
|
+
ast.IfExp,
|
|
63
|
+
ast.ListComp,
|
|
64
|
+
ast.SetComp,
|
|
65
|
+
ast.DictComp,
|
|
66
|
+
ast.GeneratorExp,
|
|
67
|
+
ast.comprehension,
|
|
68
|
+
ast.Attribute,
|
|
69
|
+
ast.Try,
|
|
70
|
+
ast.ExceptHandler,
|
|
71
|
+
ast.Raise,
|
|
72
|
+
ast.Assert,
|
|
73
|
+
ast.Lambda,
|
|
74
|
+
ast.NamedExpr,
|
|
75
|
+
ast.JoinedStr,
|
|
76
|
+
ast.FormattedValue,
|
|
77
|
+
ast.Delete,
|
|
78
|
+
ast.With,
|
|
79
|
+
ast.withitem,
|
|
80
|
+
ast.Starred,
|
|
81
|
+
ast.And,
|
|
82
|
+
ast.Or,
|
|
83
|
+
ast.Not,
|
|
84
|
+
ast.Add,
|
|
85
|
+
ast.Sub,
|
|
86
|
+
ast.Mult,
|
|
87
|
+
ast.Div,
|
|
88
|
+
ast.FloorDiv,
|
|
89
|
+
ast.Mod,
|
|
90
|
+
ast.Pow,
|
|
91
|
+
ast.MatMult,
|
|
92
|
+
ast.USub,
|
|
93
|
+
ast.UAdd,
|
|
94
|
+
ast.Eq,
|
|
95
|
+
ast.NotEq,
|
|
96
|
+
ast.Lt,
|
|
97
|
+
ast.LtE,
|
|
98
|
+
ast.Gt,
|
|
99
|
+
ast.GtE,
|
|
100
|
+
ast.Is,
|
|
101
|
+
ast.IsNot,
|
|
102
|
+
ast.In,
|
|
103
|
+
ast.NotIn,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class GuardResult:
|
|
109
|
+
ok: bool
|
|
110
|
+
message: str = ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SecurityViolation(ValueError):
|
|
114
|
+
"""Code failed AST security validation."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_code(code: str) -> GuardResult:
|
|
118
|
+
try:
|
|
119
|
+
tree = ast.parse(code, mode="exec")
|
|
120
|
+
except SyntaxError as exc:
|
|
121
|
+
return GuardResult(ok=False, message=f"语法错误: 第 {exc.lineno} 行, {exc.msg}")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
_AstValidator().visit(tree)
|
|
125
|
+
except SecurityViolation as exc:
|
|
126
|
+
return GuardResult(ok=False, message=str(exc))
|
|
127
|
+
|
|
128
|
+
return GuardResult(ok=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _AstValidator(ast.NodeVisitor):
|
|
132
|
+
def generic_visit(self, node: ast.AST) -> None:
|
|
133
|
+
if type(node) not in ALLOWED_NODES:
|
|
134
|
+
raise SecurityViolation(f"安全拦截: 不允许的语法节点 `{type(node).__name__}`。")
|
|
135
|
+
super().generic_visit(node)
|
|
136
|
+
|
|
137
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
138
|
+
for alias in node.names:
|
|
139
|
+
root = alias.name.split(".")[0]
|
|
140
|
+
if root not in ALLOWED_MODULES:
|
|
141
|
+
raise SecurityViolation(f"安全拦截: 禁止导入模块 `{alias.name}`。")
|
|
142
|
+
self.generic_visit(node)
|
|
143
|
+
|
|
144
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
145
|
+
module = (node.module or "").split(".")[0]
|
|
146
|
+
if module not in ALLOWED_MODULES:
|
|
147
|
+
raise SecurityViolation(f"安全拦截: 禁止从模块 `{node.module}` 导入。")
|
|
148
|
+
self.generic_visit(node)
|
|
149
|
+
|
|
150
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
151
|
+
name = node.id
|
|
152
|
+
self._check_identifier(name, node.lineno)
|
|
153
|
+
if name in BLOCKED_ROOT_NAMES:
|
|
154
|
+
raise SecurityViolation(f"安全拦截: 第 {node.lineno} 行禁止访问 `{name}`。")
|
|
155
|
+
self.generic_visit(node)
|
|
156
|
+
|
|
157
|
+
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
158
|
+
self._check_identifier(node.attr, node.lineno)
|
|
159
|
+
root = _root_name(node)
|
|
160
|
+
if root in BLOCKED_ROOT_NAMES:
|
|
161
|
+
raise SecurityViolation(f"安全拦截: 第 {node.lineno} 行禁止访问 `{root}`。")
|
|
162
|
+
self.generic_visit(node)
|
|
163
|
+
|
|
164
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
165
|
+
func_name = _callable_name(node.func)
|
|
166
|
+
if func_name and func_name in BLOCKED_NAMES:
|
|
167
|
+
raise SecurityViolation(f"安全拦截: 第 {node.lineno} 行禁止调用 `{func_name}`。")
|
|
168
|
+
self.generic_visit(node)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _check_identifier(name: str, lineno: int) -> None:
|
|
172
|
+
if "__" in name:
|
|
173
|
+
raise SecurityViolation(f"安全拦截: 第 {lineno} 行出现双下划线标识符。")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _root_name(node: ast.AST) -> str | None:
|
|
177
|
+
cur = node
|
|
178
|
+
while isinstance(cur, ast.Attribute):
|
|
179
|
+
cur = cur.value
|
|
180
|
+
if isinstance(cur, ast.Name):
|
|
181
|
+
return cur.id
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _callable_name(node: ast.AST) -> str | None:
|
|
186
|
+
if isinstance(node, ast.Name):
|
|
187
|
+
return node.id
|
|
188
|
+
if isinstance(node, ast.Attribute):
|
|
189
|
+
return node.attr
|
|
190
|
+
return None
|
sym_mcp/server.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
|
|
12
|
+
class FastMCP: # type: ignore[override]
|
|
13
|
+
def __init__(self, _: str) -> None:
|
|
14
|
+
self._tools: dict[str, Callable] = {}
|
|
15
|
+
|
|
16
|
+
def tool(self, name: str):
|
|
17
|
+
def decorator(func: Callable):
|
|
18
|
+
self._tools[name] = func
|
|
19
|
+
return func
|
|
20
|
+
|
|
21
|
+
return decorator
|
|
22
|
+
|
|
23
|
+
def run(self) -> None:
|
|
24
|
+
raise RuntimeError("fastmcp 未安装,无法启动 MCP 服务。")
|
|
25
|
+
|
|
26
|
+
from sym_mcp.config import Settings, load_settings
|
|
27
|
+
from sym_mcp.errors.parser import (
|
|
28
|
+
parse_guard_message,
|
|
29
|
+
parse_internal_error,
|
|
30
|
+
parse_pool_error,
|
|
31
|
+
parse_traceback,
|
|
32
|
+
)
|
|
33
|
+
from sym_mcp.executor.pool import WorkerPool, WorkerPoolError
|
|
34
|
+
from sym_mcp.security.ast_guard import validate_code
|
|
35
|
+
|
|
36
|
+
LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
settings: Settings = load_settings()
|
|
39
|
+
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
|
40
|
+
|
|
41
|
+
mcp = FastMCP("SymPy Sandbox MCP")
|
|
42
|
+
|
|
43
|
+
_POOL: Optional[WorkerPool] = None
|
|
44
|
+
_POOL_INIT_LOCK = asyncio.Lock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _get_pool() -> WorkerPool:
|
|
48
|
+
global _POOL
|
|
49
|
+
if _POOL is not None:
|
|
50
|
+
return _POOL
|
|
51
|
+
async with _POOL_INIT_LOCK:
|
|
52
|
+
if _POOL is not None:
|
|
53
|
+
return _POOL
|
|
54
|
+
pool = WorkerPool(
|
|
55
|
+
size=settings.pool_size,
|
|
56
|
+
exec_timeout_sec=settings.exec_timeout_sec,
|
|
57
|
+
queue_wait_sec=settings.queue_wait_sec,
|
|
58
|
+
memory_limit_mb=settings.memory_limit_mb,
|
|
59
|
+
)
|
|
60
|
+
await pool.start()
|
|
61
|
+
_POOL = pool
|
|
62
|
+
LOGGER.info("worker pool initialized")
|
|
63
|
+
return pool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool(name="sympy")
|
|
67
|
+
async def sympy_tool(code: str) -> str:
|
|
68
|
+
"""SymPy sandbox tool: execute Python/SymPy math code.
|
|
69
|
+
|
|
70
|
+
Safety boundaries:
|
|
71
|
+
- Only sympy/math imports and calls are allowed.
|
|
72
|
+
- System calls, file I/O, network access, and dynamic execution are blocked.
|
|
73
|
+
|
|
74
|
+
Input rules:
|
|
75
|
+
- Single argument: code (str).
|
|
76
|
+
- You must print() the final answer; otherwise out may be empty.
|
|
77
|
+
- Use multiple print() lines for multiple outputs.
|
|
78
|
+
|
|
79
|
+
Recommended workflow:
|
|
80
|
+
1) Define symbols and assumptions.
|
|
81
|
+
2) Derive/solve step by step.
|
|
82
|
+
3) Simplify intermediate expressions (simplify/factor/expand).
|
|
83
|
+
4) Print final results.
|
|
84
|
+
|
|
85
|
+
Retry guidance:
|
|
86
|
+
- E_AST_BLOCK: remove unsafe statements and keep pure math code only.
|
|
87
|
+
- E_TIMEOUT: reduce problem size, split steps, simplify before solving.
|
|
88
|
+
- E_MEMORY: reduce dimensions or avoid constructing huge objects at once.
|
|
89
|
+
"""
|
|
90
|
+
guard = validate_code(code)
|
|
91
|
+
if not guard.ok:
|
|
92
|
+
parsed = parse_guard_message(guard.message, hint_level=settings.hint_level)
|
|
93
|
+
return _build_error_response(parsed.code, parsed.line, parsed.err, parsed.hint)
|
|
94
|
+
|
|
95
|
+
pool = await _get_pool()
|
|
96
|
+
try:
|
|
97
|
+
result = await pool.exec(code)
|
|
98
|
+
except WorkerPoolError as exc:
|
|
99
|
+
parsed = parse_pool_error(str(exc), hint_level=settings.hint_level)
|
|
100
|
+
return _build_error_response(parsed.code, parsed.line, parsed.err, parsed.hint)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
LOGGER.exception("unexpected pool error")
|
|
103
|
+
parsed = parse_internal_error(str(exc), hint_level=settings.hint_level)
|
|
104
|
+
return _build_error_response(parsed.code, parsed.line, parsed.err, parsed.hint)
|
|
105
|
+
|
|
106
|
+
if not result.get("ok", False):
|
|
107
|
+
parsed = parse_pool_error("worker执行失败", hint_level=settings.hint_level)
|
|
108
|
+
return _build_error_response(parsed.code, parsed.line, parsed.err, parsed.hint)
|
|
109
|
+
|
|
110
|
+
if result.get("success", False):
|
|
111
|
+
stdout = (result.get("stdout") or "").rstrip()
|
|
112
|
+
out, _ = _truncate(stdout)
|
|
113
|
+
return _json_compact({"out": out})
|
|
114
|
+
|
|
115
|
+
tb_text = result.get("traceback", "") or ""
|
|
116
|
+
parsed = parse_traceback(tb_text, hint_level=settings.hint_level)
|
|
117
|
+
return _build_error_response(parsed.code, parsed.line, parsed.err, parsed.hint)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> None:
|
|
121
|
+
mcp.run()
|
|
122
|
+
|
|
123
|
+
def _build_error_response(code: str, line: int | None, err: str, hint: str) -> str:
|
|
124
|
+
err, trunc_err = _truncate(err)
|
|
125
|
+
hint, trunc_hint = _truncate(hint)
|
|
126
|
+
if trunc_err and "truncated" not in err:
|
|
127
|
+
err = f"{err}...[truncated]"
|
|
128
|
+
if trunc_hint and "truncated" not in hint:
|
|
129
|
+
hint = f"{hint}...[truncated]"
|
|
130
|
+
return _json_compact(
|
|
131
|
+
{
|
|
132
|
+
"code": code,
|
|
133
|
+
"line": line,
|
|
134
|
+
"err": err,
|
|
135
|
+
"hint": hint,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _truncate(text: str) -> tuple[str, int]:
|
|
141
|
+
text = text or ""
|
|
142
|
+
max_chars = settings.max_output_chars
|
|
143
|
+
if len(text) <= max_chars:
|
|
144
|
+
return text, 0
|
|
145
|
+
return f"{text[: max_chars - 12]}...[truncated]", 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _json_compact(data: dict) -> str:
|
|
149
|
+
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sym-mcp
|
|
3
|
+
Version: 0.0.0.post1.dev3
|
|
4
|
+
Summary: SymPy sandbox MCP server based on FastMCP
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Eis4TY/Sym-MCP
|
|
7
|
+
Project-URL: Repository, https://github.com/Eis4TY/Sym-MCP
|
|
8
|
+
Project-URL: Issues, https://github.com/Eis4TY/Sym-MCP/issues
|
|
9
|
+
Keywords: mcp,sympy,sandbox,agent,llm,math
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: fastmcp>=3.0.0
|
|
21
|
+
Requires-Dist: sympy>=1.13.0
|
|
22
|
+
Requires-Dist: pydantic>=2.8.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# SymPy Sandbox MCP
|
|
29
|
+
|
|
30
|
+
English | [中文版](README.zh.md)
|
|
31
|
+
|
|
32
|
+
A production-focused MCP service that lets agents/LLMs run SymPy safely and efficiently.
|
|
33
|
+
It combines AST policy checks, runtime resource limits, and prewarmed workers to deliver low-noise, parse-friendly results.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- Single tool: `sympy` (input only requires `code`)
|
|
38
|
+
- Prewarmed worker pool to avoid repeated `import sympy`
|
|
39
|
+
- Two-layer safety: AST guard + runtime resource limits
|
|
40
|
+
- Compact structured JSON output for low token overhead
|
|
41
|
+
- Standardized error codes for reliable auto-retry workflows
|
|
42
|
+
|
|
43
|
+
## Typical Use Cases
|
|
44
|
+
|
|
45
|
+
- Symbolic algebra, differentiation, integration, equation solving
|
|
46
|
+
- MCP tool integration for Codex / Cursor / Claude Desktop / custom MCP clients
|
|
47
|
+
- Agent workflows that need controllable failures and clean error signals
|
|
48
|
+
|
|
49
|
+
## Recommended Integration (MCP client via stdio)
|
|
50
|
+
|
|
51
|
+
Call example:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
fastmcp call \
|
|
55
|
+
--command 'python -m sym_mcp.server' \
|
|
56
|
+
--target sympy \
|
|
57
|
+
--input-json '{"code":"import sympy as sp\\nx=sp.Symbol(\"x\")\\nprint(sp.factor(x**2-1))"}'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Client config (`python -m`, recommended):
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"sympy-sandbox": {
|
|
66
|
+
"command": "python",
|
|
67
|
+
"args": ["-m", "sym_mcp.server"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Client config (installed as `sym-mcp`):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"sympy-sandbox": {
|
|
79
|
+
"command": "sym-mcp",
|
|
80
|
+
"args": []
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Client config (`uvx`):
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"sympy-sandbox": {
|
|
92
|
+
"command": "uvx",
|
|
93
|
+
"args": ["sym-mcp"]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Quick Start
|
|
100
|
+
|
|
101
|
+
### 1) Requirements
|
|
102
|
+
|
|
103
|
+
- Python 3.11+
|
|
104
|
+
- Linux / macOS (Linux recommended for production)
|
|
105
|
+
|
|
106
|
+
### 2) Install (Tsinghua mirror first)
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -e .
|
|
110
|
+
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -e ".[dev]"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3) Run server (stdio)
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python -m sym_mcp.server
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 4) Verify tool
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
fastmcp list --command 'python -m sym_mcp.server'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Tool Contract
|
|
126
|
+
|
|
127
|
+
### Tool name
|
|
128
|
+
|
|
129
|
+
- `sympy`
|
|
130
|
+
|
|
131
|
+
### Input
|
|
132
|
+
|
|
133
|
+
- `code: str`
|
|
134
|
+
|
|
135
|
+
Notes:
|
|
136
|
+
- You must `print()` final outputs.
|
|
137
|
+
- If nothing is printed, `out` may be empty.
|
|
138
|
+
|
|
139
|
+
### Output (always compact JSON string)
|
|
140
|
+
|
|
141
|
+
Success:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{"out":"x**2/2"}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Failure:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{"code":"E_RUNTIME","line":3,"err":"ZeroDivisionError: division by zero","hint":"Runtime error. Check variable types, division-by-zero, or undefined names near the reported line."}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Field definitions:
|
|
154
|
+
|
|
155
|
+
- `out`: stdout text on success
|
|
156
|
+
- `code`: error code
|
|
157
|
+
- `line`: user code error line, or `null`
|
|
158
|
+
- `err`: compact error message (traceback noise removed)
|
|
159
|
+
- `hint`: fix hint (based on configured hint level)
|
|
160
|
+
- If `out` / `err` / `hint` is too long, it will be truncated with `...[truncated]`
|
|
161
|
+
|
|
162
|
+
## Error Codes
|
|
163
|
+
|
|
164
|
+
- `E_AST_BLOCK`: blocked by AST safety policy
|
|
165
|
+
- `E_SYNTAX`: syntax error
|
|
166
|
+
- `E_TIMEOUT`: timeout
|
|
167
|
+
- `E_MEMORY`: memory limit triggered
|
|
168
|
+
- `E_RUNTIME`: general runtime error
|
|
169
|
+
- `E_WORKER`: worker communication/state failure
|
|
170
|
+
- `E_INTERNAL`: internal server error
|
|
171
|
+
|
|
172
|
+
## Recommended Agent Prompt Rules
|
|
173
|
+
|
|
174
|
+
1. Use math-only Python code.
|
|
175
|
+
2. Only import `sympy` or `math`.
|
|
176
|
+
3. Always `print()` final answers.
|
|
177
|
+
4. For multiple outputs, use multiple `print()` lines.
|
|
178
|
+
5. On failure, patch minimally near `line` and retry.
|
|
179
|
+
6. For `E_TIMEOUT`, reduce scale first; for `E_MEMORY`, reduce object size/dimension; for `E_AST_BLOCK`, remove unsafe statements.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import sympy as sp
|
|
185
|
+
x = sp.Symbol("x")
|
|
186
|
+
expr = (x + 1)**5
|
|
187
|
+
print(sp.expand(expr))
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Security Model
|
|
191
|
+
|
|
192
|
+
### Before execution (AST policy)
|
|
193
|
+
|
|
194
|
+
- Only `sympy` / `math` imports are allowed
|
|
195
|
+
- Dangerous capabilities are blocked (`eval`, `exec`, `open`, `__import__`, etc.)
|
|
196
|
+
- Dunder attribute traversal is blocked (e.g. `__class__`)
|
|
197
|
+
|
|
198
|
+
### During execution (OS resource limits)
|
|
199
|
+
|
|
200
|
+
- Per-task CPU time limit + timeout kill
|
|
201
|
+
- Per-worker memory limit via `setrlimit`
|
|
202
|
+
- Worker auto-rebuild on failure to keep server healthy
|
|
203
|
+
|
|
204
|
+
## Architecture
|
|
205
|
+
|
|
206
|
+
- `src/sym_mcp/server.py`: MCP entrypoint and tool registration
|
|
207
|
+
- `src/sym_mcp/security/ast_guard.py`: AST validation
|
|
208
|
+
- `src/sym_mcp/executor/worker_main.py`: worker loop
|
|
209
|
+
- `src/sym_mcp/executor/pool.py`: async prewarmed process pool
|
|
210
|
+
- `src/sym_mcp/executor/sandbox.py`: restricted execution and stdout capture
|
|
211
|
+
- `src/sym_mcp/errors/parser.py`: error normalization and code mapping
|
|
212
|
+
- `src/sym_mcp/config.py`: runtime configuration
|
|
213
|
+
|
|
214
|
+
## Configuration (Environment Variables)
|
|
215
|
+
|
|
216
|
+
- `SYMMCP_POOL_SIZE`: worker pool size, default `10`
|
|
217
|
+
- `SYMMCP_EXEC_TIMEOUT_SEC`: per execution timeout (sec), default `3`
|
|
218
|
+
- `SYMMCP_MEMORY_LIMIT_MB`: memory cap per worker (MB), default `150`
|
|
219
|
+
- `SYMMCP_QUEUE_WAIT_SEC`: queue wait timeout (sec), default `2`
|
|
220
|
+
- `SYMMCP_LOG_LEVEL`: log level, default `INFO`
|
|
221
|
+
- `SYMMCP_MAX_OUTPUT_CHARS`: output truncation threshold, default `1200`
|
|
222
|
+
- `SYMMCP_HINT_LEVEL`: hint level (`none/short/medium`), default `medium`
|
|
223
|
+
|
|
224
|
+
## FAQ
|
|
225
|
+
|
|
226
|
+
### Why is `out` empty?
|
|
227
|
+
|
|
228
|
+
Most likely the code does not `print()` the final result.
|
|
229
|
+
|
|
230
|
+
### Why return compact JSON string?
|
|
231
|
+
|
|
232
|
+
It is easier for agents to parse reliably and reduces token cost.
|
|
233
|
+
|
|
234
|
+
### Is memory limiting always stable on macOS?
|
|
235
|
+
|
|
236
|
+
`setrlimit` behavior differs by OS. Linux is preferred for production.
|
|
237
|
+
|
|
238
|
+
### Does it support HTTP/SSE?
|
|
239
|
+
|
|
240
|
+
Current primary delivery is `stdio`. HTTP/SSE can be added later via FastMCP transport extensions.
|
|
241
|
+
|
|
242
|
+
## Known Limits
|
|
243
|
+
|
|
244
|
+
- This is restricted Python execution, not VM/container-grade isolation
|
|
245
|
+
- Memory limit behavior is OS-dependent
|
|
246
|
+
- Output is truncated at threshold, with `...[truncated]` suffix
|
|
247
|
+
|
|
248
|
+
## Development
|
|
249
|
+
|
|
250
|
+
### Run tests
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
PYTHONPATH=src pytest -q
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Benchmark
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
PYTHONPATH=src python scripts/benchmark.py --concurrency 100 --total 500
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Contributing
|
|
263
|
+
|
|
264
|
+
- Run `PYTHONPATH=src pytest -q` before submitting PRs
|
|
265
|
+
- When adding new capabilities, update:
|
|
266
|
+
- error code docs
|
|
267
|
+
- README examples
|
|
268
|
+
- related unit/integration tests
|
|
269
|
+
- Publishing process: [PUBLISHING.md](./PUBLISHING.md)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
sym_mcp/__init__.py,sha256=cfJSdc0srnrKJnnS6OI6amaQ6TyUq2afc5g5M1vfPyg,24
|
|
2
|
+
sym_mcp/__main__.py,sha256=8t_7qNzDS451pU9538_IL95boYzah6iEvNJkZPpUQRc,73
|
|
3
|
+
sym_mcp/config.py,sha256=J7_FsPgXgLMBzb-HiNBeKdJatLr-s1AaBb0J5bsGXys,880
|
|
4
|
+
sym_mcp/schemas.py,sha256=iw9TxgzS2pjv3QVQnlpPL2Qty7RtdkOG4oXtBCecp94,217
|
|
5
|
+
sym_mcp/server.py,sha256=KgaTcFm5sjWiN8pC-C6aU2pTIQilOIsf2mS-Ybpo-Gg,4870
|
|
6
|
+
sym_mcp/errors/__init__.py,sha256=DLvDGcRWqvGvtQgVphXqDn345KGV6aCDnuw14Z3vs8A,30
|
|
7
|
+
sym_mcp/errors/parser.py,sha256=Mfc4CTguxJRw9zNZrNL4TJMBtNa5CgcpvJG47pFzvnI,4083
|
|
8
|
+
sym_mcp/executor/__init__.py,sha256=u3n7axSA-Q_EAbGqwI0LKWT_LBol3eqb4c3r5PHMscI,42
|
|
9
|
+
sym_mcp/executor/pool.py,sha256=vWUBXsSo61dTGnQVPMCJAxttpxjGuF9Rso5f6YNV8KQ,6903
|
|
10
|
+
sym_mcp/executor/sandbox.py,sha256=w-soUQFxLqMhO629xgEzKAKG84LxRh6Q37fMdOaA4cI,1956
|
|
11
|
+
sym_mcp/executor/worker_main.py,sha256=BgtUhwuT2Aoybn2-3hUF45FSoSoTlVOXAK7mbR0Nla8,1849
|
|
12
|
+
sym_mcp/security/__init__.py,sha256=crg8OP2VqDDtBD4hSQ26UMcO221ZL_iWgz1JTyLnA7s,25
|
|
13
|
+
sym_mcp/security/ast_guard.py,sha256=MwKMblKmjij4Wj-epcHw4LavIFu0I9vx665Pp6qocF4,4571
|
|
14
|
+
sym_mcp-0.0.0.post1.dev3.dist-info/licenses/LICENSE,sha256=o9sdgeg7UifBr_DA96ba5IelGoYdY2WSJN8FC6G1nrs,1060
|
|
15
|
+
sym_mcp-0.0.0.post1.dev3.dist-info/METADATA,sha256=8FuAlCk5q00jak5lAlsSAgr1eWfoIXJg3h_dUo-MTGo,6942
|
|
16
|
+
sym_mcp-0.0.0.post1.dev3.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
17
|
+
sym_mcp-0.0.0.post1.dev3.dist-info/entry_points.txt,sha256=Z_o4e7ExuOKT1BI15WGdYFcehGDRmQ_jai6P2Gu6VWw,48
|
|
18
|
+
sym_mcp-0.0.0.post1.dev3.dist-info/top_level.txt,sha256=N7kyUBZ3yBKUzno6Wwd45c1qDEy37nMBoI0_CByMXDg,8
|
|
19
|
+
sym_mcp-0.0.0.post1.dev3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tyy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sym_mcp
|