MCP compliance, safety and math fixes; English tool docs; symbolic dsolve

Fork of https://github.com/thinkitpossible/CalcMCP with:
- tools/list now publishes inputSchema (upstream used non-standard 'params'
  key, leaving clients with no parameter information)
- tool descriptions and parameter docs rewritten in English with examples
- JSON-RPC notifications are no longer answered; tool failures set isError
- raw eval() of tool arguments removed (json.loads/ast.literal_eval for
  matrices, sympy.lambdify for the numeric ODE right-hand side)
- complex eigenvalues serialize as 'a+bi' strings instead of crashing
- solve accepts '=' equations; ttest accepts {"popmean": ...} params
- new symbolic_tool operation 'dsolve' for symbolic ODE solutions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-10-03 01:27:21 +01:00
parent a17b0c8017
commit e4b5ea649f
3 changed files with 159 additions and 80 deletions

View File

@@ -13,8 +13,18 @@ except Exception:
from scipy.integrate import odeint
from scipy.fftpack import fft, rfft
from typing import Optional
import ast
import json
def _json_default(obj):
"""JSON fallback for numpy/complex values (eigenvalues are often complex)."""
if isinstance(obj, complex):
return obj.real if obj.imag == 0 else f"{obj.real}{obj.imag:+}i"
if hasattr(obj, "item"):
return _json_default(obj.item())
return str(obj)
# ============================================================================
# SYMBOLIC MATHEMATICS (SymPy)
# ============================================================================
@@ -91,9 +101,14 @@ def symbolic_limit(expression: str, variable: str, point: str) -> str:
def solve_equation(equation: str, variable: str) -> str:
"""解方程"""
"""Solve an equation. Accepts 'expr' (treated as expr = 0), 'lhs = rhs' or 'lhs == rhs'."""
try:
expr = sp.sympify(equation)
eq_str = equation.replace("==", "=")
if "=" in eq_str:
lhs, rhs = eq_str.split("=", 1)
expr = sp.Eq(sp.sympify(lhs), sp.sympify(rhs))
else:
expr = sp.sympify(eq_str)
var = sp.Symbol(variable)
result = sp.solve(expr, var)
return str(result)
@@ -101,6 +116,22 @@ def solve_equation(equation: str, variable: str) -> str:
return f"Error: {str(e)}"
def symbolic_dsolve(equation: str, variable: str = "x") -> str:
"""Solve an ODE symbolically. Unknown function must be written as y(x),
e.g. 'Derivative(y(x), x, 2) + y(x) = 0' -> y(x) = C1*sin(x) + C2*cos(x)."""
try:
eq_str = equation.replace("==", "=")
if "=" in eq_str:
lhs, rhs = eq_str.split("=", 1)
eq = sp.Eq(sp.sympify(lhs), sp.sympify(rhs))
else:
eq = sp.sympify(eq_str)
result = sp.dsolve(eq)
return str(result)
except Exception as e:
return f"Error: {str(e)}"
def taylor_series(expression: str, variable: str, point: str, n: int) -> str:
"""泰勒级数展开"""
try:
@@ -185,8 +216,8 @@ def numpy_linear_algebra(matrix_a, operation: str, matrix_b=None) -> str:
if isinstance(matrix_a, str):
try:
mat1 = np.array(json.loads(matrix_a), dtype=float)
except:
mat1 = np.array(eval(matrix_a), dtype=float)
except Exception:
mat1 = np.array(ast.literal_eval(matrix_a), dtype=float)
else:
mat1 = np.array(matrix_a, dtype=float)
@@ -195,27 +226,19 @@ def numpy_linear_algebra(matrix_a, operation: str, matrix_b=None) -> str:
if isinstance(matrix_b, str):
try:
mat2 = np.array(json.loads(matrix_b), dtype=float)
except:
mat2 = np.array(eval(matrix_b), dtype=float)
except Exception:
mat2 = np.array(ast.literal_eval(matrix_b), dtype=float)
else:
mat2 = np.array(matrix_b, dtype=float)
# 执行操作
if operation == "eigenvalues":
if operation in ("eigenvalues", "eigenvectors"):
eigenvalues, eigenvectors = np.linalg.eig(mat1)
result = {
"eigenvalues": eigenvalues.tolist(),
"eigenvectors": eigenvectors.tolist()
}
return json.dumps(result, ensure_ascii=False)
elif operation == "eigenvectors":
eigenvalues, eigenvectors = np.linalg.eig(mat1)
result = {
"eigenvalues": eigenvalues.tolist(),
"eigenvectors": eigenvectors.tolist()
}
return json.dumps(result, ensure_ascii=False)
return json.dumps(result, ensure_ascii=False, default=_json_default)
elif operation == "determinant":
result = float(np.linalg.det(mat1))
@@ -434,21 +457,17 @@ def scipy_solve_ode(expression: str, initial_conditions: str, t_values: str, var
else:
reverse_order = False
# 评估 ODE 函数表达式
try:
# 尝试作为 lambda 函数字符串
ode_func = eval(expression)
except:
# 如果失败,尝试作为符号表达式
var_list = [sp.Symbol(v.strip()) for v in variables.split(",")]
t_sym = sp.Symbol('t')
expr = sp.sympify(expression)
if len(var_list) == 1:
ode_func = sp.lambdify([t_sym, var_list[0]], expr, modules=['numpy'])
ode_func_wrapped = lambda y, t: ode_func(t, y)
else:
return "Error: System ODEs not yet fully supported"
# Parse dy/dt = f(t, y) as a SymPy expression (no eval of raw Python)
var_list = [sp.Symbol(v.strip()) for v in (variables or "y").split(",")]
t_sym = sp.Symbol('t')
expr = sp.sympify(expression)
if len(var_list) == 1:
f = sp.lambdify([t_sym, var_list[0]], expr, modules=['numpy'])
# odeint calls func(y, t)
ode_func = lambda y, t: f(t, y)
else:
return "Error: System ODEs not yet fully supported"
# 调用 odeint
if isinstance(y0, (int, float)):
@@ -489,6 +508,8 @@ def scipy_statistics(operation: str, data: str, params: Optional[str] = None) ->
return str(float(np.std(data_arr)))
elif operation == "ttest":
popmean = json.loads(params) if params else 0
if isinstance(popmean, dict):
popmean = popmean.get("popmean", 0)
result = stats.ttest_1samp(data_arr, popmean)
return f"statistic={result.statistic}, pvalue={result.pvalue}"
elif operation == "pearsonr" and params:
@@ -560,6 +581,8 @@ def symbolic_tool(
return symbolic_limit(expression, variable, point or point_value)
if op in ("solve", "solve_equation"):
return solve_equation(equation or expression, variable)
if op in ("dsolve", "ode", "solve_ode", "differential_equation"):
return symbolic_dsolve(equation or expression, variable or "x")
if op in ("taylor", "taylor_series"):
return taylor_series(expression, variable, point or "0", order)
if op in ("matrix", "symbolic_matrix_operations"):