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 ADDED
@@ -0,0 +1,2 @@
1
+ """sym_mcp package."""
2
+
sym_mcp/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from sym_mcp.server import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
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
+ )
@@ -0,0 +1,2 @@
1
+ """Error parsing helpers."""
2
+
@@ -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
@@ -0,0 +1,2 @@
1
+ """Execution and worker pool modules."""
2
+
@@ -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,8 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class SympyInput(BaseModel):
7
+ code: str = Field(..., min_length=1, description="包含 Python/SymPy 逻辑并通过 print 输出结果")
8
+
@@ -0,0 +1,2 @@
1
+ """Security helpers."""
2
+
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sym-mcp = sym_mcp.server:main
@@ -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