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

@@ -1,9 +1,26 @@
# Scientific Calculator MCP Server # Scientific Calculator MCP Server
> A production-ready Model Context Protocol (MCP) server providing advanced mathematical calculation capabilities for AI models. Supports symbolic math (SymPy), numerical computing (NumPy/SciPy), data analysis (pandas), and image processing. > A Model Context Protocol (MCP) server providing advanced mathematical calculation capabilities for AI models. Supports symbolic math (SymPy), numerical computing (NumPy/SciPy), data analysis (pandas), and image processing.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> **Fork notice** — this is a patched fork of
> [thinkitpossible/CalcMCP](https://github.com/thinkitpossible/CalcMCP).
> Changes relative to upstream:
>
> - **MCP compliance**: tools are published with a proper `inputSchema` (upstream used a
> non-standard `params` key, so clients saw no parameters at all); JSON-RPC notifications
> (`notifications/initialized`) are no longer answered with an error; tool failures set
> `isError: true` on the result.
> - **Tool descriptions rewritten in English**, with per-parameter docs and inline examples.
> - **Security**: removed all raw `eval()` of tool arguments (matrix inputs now parse via
> `json.loads`/`ast.literal_eval`, the numeric-ODE right-hand side via `sympy.lambdify`).
> - **Bug fixes**: eigenvalue/eigenvector results with complex values no longer crash JSON
> serialization (rendered as `a+bi` strings); `solve` accepts equations written with `=`;
> one-sample t-test accepts `params` as `{"popmean": ...}` or a bare number.
> - **New**: `symbolic_tool` operation `dsolve` — symbolic ODE solving
> (e.g. `Derivative(y(x), x, 2) + y(x) = 0` → `y(x) = C1*sin(x) + C2*cos(x)`).
## Quick Start ## Quick Start
### 1. Install Dependencies ### 1. Install Dependencies

View File

@@ -13,8 +13,18 @@ except Exception:
from scipy.integrate import odeint from scipy.integrate import odeint
from scipy.fftpack import fft, rfft from scipy.fftpack import fft, rfft
from typing import Optional from typing import Optional
import ast
import json 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) # 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: def solve_equation(equation: str, variable: str) -> str:
"""解方程""" """Solve an equation. Accepts 'expr' (treated as expr = 0), 'lhs = rhs' or 'lhs == rhs'."""
try: 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) var = sp.Symbol(variable)
result = sp.solve(expr, var) result = sp.solve(expr, var)
return str(result) return str(result)
@@ -101,6 +116,22 @@ def solve_equation(equation: str, variable: str) -> str:
return f"Error: {str(e)}" 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: def taylor_series(expression: str, variable: str, point: str, n: int) -> str:
"""泰勒级数展开""" """泰勒级数展开"""
try: try:
@@ -185,8 +216,8 @@ def numpy_linear_algebra(matrix_a, operation: str, matrix_b=None) -> str:
if isinstance(matrix_a, str): if isinstance(matrix_a, str):
try: try:
mat1 = np.array(json.loads(matrix_a), dtype=float) mat1 = np.array(json.loads(matrix_a), dtype=float)
except: except Exception:
mat1 = np.array(eval(matrix_a), dtype=float) mat1 = np.array(ast.literal_eval(matrix_a), dtype=float)
else: else:
mat1 = np.array(matrix_a, dtype=float) 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): if isinstance(matrix_b, str):
try: try:
mat2 = np.array(json.loads(matrix_b), dtype=float) mat2 = np.array(json.loads(matrix_b), dtype=float)
except: except Exception:
mat2 = np.array(eval(matrix_b), dtype=float) mat2 = np.array(ast.literal_eval(matrix_b), dtype=float)
else: else:
mat2 = np.array(matrix_b, dtype=float) mat2 = np.array(matrix_b, dtype=float)
# 执行操作 # 执行操作
if operation == "eigenvalues": if operation in ("eigenvalues", "eigenvectors"):
eigenvalues, eigenvectors = np.linalg.eig(mat1) eigenvalues, eigenvectors = np.linalg.eig(mat1)
result = { result = {
"eigenvalues": eigenvalues.tolist(), "eigenvalues": eigenvalues.tolist(),
"eigenvectors": eigenvectors.tolist() "eigenvectors": eigenvectors.tolist()
} }
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False, default=_json_default)
elif operation == "eigenvectors":
eigenvalues, eigenvectors = np.linalg.eig(mat1)
result = {
"eigenvalues": eigenvalues.tolist(),
"eigenvectors": eigenvectors.tolist()
}
return json.dumps(result, ensure_ascii=False)
elif operation == "determinant": elif operation == "determinant":
result = float(np.linalg.det(mat1)) result = float(np.linalg.det(mat1))
@@ -434,19 +457,15 @@ def scipy_solve_ode(expression: str, initial_conditions: str, t_values: str, var
else: else:
reverse_order = False reverse_order = False
# 评估 ODE 函数表达式 # Parse dy/dt = f(t, y) as a SymPy expression (no eval of raw Python)
try: var_list = [sp.Symbol(v.strip()) for v in (variables or "y").split(",")]
# 尝试作为 lambda 函数字符串
ode_func = eval(expression)
except:
# 如果失败,尝试作为符号表达式
var_list = [sp.Symbol(v.strip()) for v in variables.split(",")]
t_sym = sp.Symbol('t') t_sym = sp.Symbol('t')
expr = sp.sympify(expression) expr = sp.sympify(expression)
if len(var_list) == 1: if len(var_list) == 1:
ode_func = sp.lambdify([t_sym, var_list[0]], expr, modules=['numpy']) f = sp.lambdify([t_sym, var_list[0]], expr, modules=['numpy'])
ode_func_wrapped = lambda y, t: ode_func(t, y) # odeint calls func(y, t)
ode_func = lambda y, t: f(t, y)
else: else:
return "Error: System ODEs not yet fully supported" return "Error: System ODEs not yet fully supported"
@@ -489,6 +508,8 @@ def scipy_statistics(operation: str, data: str, params: Optional[str] = None) ->
return str(float(np.std(data_arr))) return str(float(np.std(data_arr)))
elif operation == "ttest": elif operation == "ttest":
popmean = json.loads(params) if params else 0 popmean = json.loads(params) if params else 0
if isinstance(popmean, dict):
popmean = popmean.get("popmean", 0)
result = stats.ttest_1samp(data_arr, popmean) result = stats.ttest_1samp(data_arr, popmean)
return f"statistic={result.statistic}, pvalue={result.pvalue}" return f"statistic={result.statistic}, pvalue={result.pvalue}"
elif operation == "pearsonr" and params: elif operation == "pearsonr" and params:
@@ -560,6 +581,8 @@ def symbolic_tool(
return symbolic_limit(expression, variable, point or point_value) return symbolic_limit(expression, variable, point or point_value)
if op in ("solve", "solve_equation"): if op in ("solve", "solve_equation"):
return solve_equation(equation or expression, variable) 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"): if op in ("taylor", "taylor_series"):
return taylor_series(expression, variable, point or "0", order) return taylor_series(expression, variable, point or "0", order)
if op in ("matrix", "symbolic_matrix_operations"): if op in ("matrix", "symbolic_matrix_operations"):

View File

@@ -58,55 +58,85 @@ class MCPCalculatorServer:
# Consolidated tool definitions (3 tools) # Consolidated tool definitions (3 tools)
tool_definitions = { tool_definitions = {
"symbolic_tool": { "symbolic_tool": {
"description": "统一符号计算入口:simplify/expand/factor/derivative/integral/limit/solve/taylor/matrix", "description": (
"Exact symbolic math (SymPy). Set operation to one of: simplify, expand, factor, "
"derivative, integral, limit, solve, dsolve, taylor, matrix. "
"Write expressions in Python/SymPy syntax, e.g. 'x**2 * exp(-x)'; use 'oo' for infinity. "
"integral: optional lower_limit/upper_limit for definite integrals. "
"solve: pass the equation, '=' is allowed (e.g. 'x**2 = 4' or 'x**3 - 6*x**2 + 11*x - 6'). "
"dsolve: solves an ODE symbolically — write the unknown function as y(x), "
"e.g. equation='Derivative(y(x), x, 2) + y(x) = 0'. "
"taylor: expression + variable + point + order. "
"matrix: matrix_data (JSON) with variable=determinant|inverse|rank|trace. "
"Results are exact (e.g. '2', 'E', 'C1*sin(x) + C2*cos(x)')."
),
"params": [ "params": [
{"name": "operation", "type": "string", "description": "操作类型,如 derivative, integral, solve 等"}, {"name": "operation", "type": "string", "description": "One of: simplify, expand, factor, derivative, integral, limit, solve, dsolve, taylor, matrix"},
{"name": "expression", "type": "string", "description": "表达式", "optional": True}, {"name": "expression", "type": "string", "description": "Expression in SymPy syntax, e.g. 'sin(x)/x'", "optional": True},
{"name": "variable", "type": "string", "description": "变量", "optional": True}, {"name": "variable", "type": "string", "description": "Variable name, e.g. 'x' (for matrix: the operation name)", "optional": True},
{"name": "order", "type": "integer", "description": "阶数(导数/泰勒)", "optional": True}, {"name": "order", "type": "integer", "description": "Order for derivative/taylor (default 1 / 5)", "optional": True},
{"name": "lower_limit", "type": "string", "description": "积分下限", "optional": True}, {"name": "lower_limit", "type": "string", "description": "Lower bound for definite integral, e.g. '0'", "optional": True},
{"name": "upper_limit", "type": "string", "description": "积分上限", "optional": True}, {"name": "upper_limit", "type": "string", "description": "Upper bound for definite integral, e.g. 'oo'", "optional": True},
{"name": "equation", "type": "string", "description": "方程", "optional": True}, {"name": "equation", "type": "string", "description": "Equation for solve/dsolve; '=' allowed", "optional": True},
{"name": "point", "type": "string", "description": "极限或泰勒展开点", "optional": True}, {"name": "point", "type": "string", "description": "Point for limit/taylor, e.g. '0' or 'oo'", "optional": True},
{"name": "matrix_data", "type": "string", "description": "符号矩阵(JSON)", "optional": True} {"name": "matrix_data", "type": "string", "description": "Matrix as JSON, e.g. '[[1,2],[3,4]]'", "optional": True}
] ]
}, },
"numpy_tool": { "numpy_tool": {
"description": "统一 NumPy 入口:数组归约、线性代数、三角、多项式", "description": (
"Numeric arrays and linear algebra (NumPy). Set operation to one of: "
"sum, mean, std, max, min (array_data, optional axis); "
"eigenvalues, eigenvectors, determinant, inverse, solve, norm, rank, trace, "
"svd, qr, cholesky, matmul, dot, hadamard (matrix_a, and matrix_b where two matrices are needed); "
"sin, cos, tan, arcsin, arccos, arctan, sinh, cosh, tanh (values, optional use_degrees); "
"poly_eval, poly_derivative, poly_integral (coefficients highest-degree first, optional x_values). "
"All array/matrix inputs are JSON strings, e.g. matrix_a='[[2,1],[1,2]]'. Results are numeric."
),
"params": [ "params": [
{"name": "operation", "type": "string", "description": "操作类型,如 eigenvalues, sum, poly_eval 等"}, {"name": "operation", "type": "string", "description": "Operation name, e.g. 'eigenvalues', 'matmul', 'mean', 'poly_eval'"},
{"name": "array_data", "type": "string", "description": "数组数据(JSON)", "optional": True}, {"name": "array_data", "type": "string", "description": "Array as JSON, e.g. '[1,2,3]' or '[[1,2],[3,4]]'", "optional": True},
{"name": "axis", "type": "string", "description": "轴参数", "optional": True}, {"name": "axis", "type": "string", "description": "Axis for reductions: '0' or '1'", "optional": True},
{"name": "matrix_a", "type": "string", "description": "矩阵A(JSON)", "optional": True}, {"name": "matrix_a", "type": "string", "description": "First matrix as JSON", "optional": True},
{"name": "matrix_b", "type": "string", "description": "矩阵B(JSON)", "optional": True}, {"name": "matrix_b", "type": "string", "description": "Second matrix as JSON (solve/matmul/dot/hadamard)", "optional": True},
{"name": "values", "type": "string", "description": "三角函数输入(JSON)", "optional": True}, {"name": "values", "type": "string", "description": "Input values for trig functions as JSON, e.g. '[0, 90, 180]'", "optional": True},
{"name": "use_degrees", "type": "boolean", "description": "角度制", "optional": True}, {"name": "use_degrees", "type": "boolean", "description": "Treat trig inputs as degrees (default radians)", "optional": True},
{"name": "coefficients", "type": "string", "description": "多项式系数(JSON)", "optional": True}, {"name": "coefficients", "type": "string", "description": "Polynomial coefficients as JSON, highest degree first", "optional": True},
{"name": "x_values", "type": "string", "description": "多项式自变量数组(JSON)", "optional": True} {"name": "x_values", "type": "string", "description": "x values for poly_eval as JSON", "optional": True}
] ]
}, },
"scipy_tool": { "scipy_tool": {
"description": "统一 SciPy 入口:积分、优化、插值、特殊函数、ODE、统计、FFT、特征分解", "description": (
"Numerical methods (SciPy). Set operation to one of: "
"integrate (numeric definite integral: expression, variable, lower, upper); "
"optimize_minimize, optimize_root (expression, initial_value, optional method); "
"interpolate_linear, interpolate_cubic, interpolate_spline (x_data, y_data, x_eval); "
"special (function name + parameters JSON, e.g. gamma, besselj); "
"solve_ode (NUMERIC simulation of dy/dt = f(t,y): expression e.g. '-2*y + t', "
"initial_conditions, t_values; for exact general solutions use symbolic_tool with operation='dsolve'); "
"statistics ops mean, std, describe, ttest, pearsonr (function + data, optional params); "
"fft, rfft (data_series or data); matrix_eigensystem (matrix_a). "
"All list inputs are JSON strings. Results are numeric."
),
"params": [ "params": [
{"name": "operation", "type": "string", "description": "操作类型,如 integrate, optimize_minimize, fft 等"}, {"name": "operation", "type": "string", "description": "Operation name, e.g. 'integrate', 'optimize_minimize', 'solve_ode', 'fft'"},
{"name": "expression", "type": "string", "description": "表达式/函数", "optional": True}, {"name": "expression", "type": "string", "description": "Expression/function of the variable, e.g. 'x**2 * exp(-x)'", "optional": True},
{"name": "variable", "type": "string", "description": "变量", "optional": True}, {"name": "variable", "type": "string", "description": "Variable name, e.g. 'x'", "optional": True},
{"name": "lower", "type": "string", "description": "积分下限", "optional": True}, {"name": "lower", "type": "string", "description": "Lower integration bound (number)", "optional": True},
{"name": "upper", "type": "string", "description": "积分上限", "optional": True}, {"name": "upper", "type": "string", "description": "Upper integration bound (number or 'oo')", "optional": True},
{"name": "initial_value", "type": "string", "description": "初值/起点", "optional": True}, {"name": "initial_value", "type": "string", "description": "Starting point for optimization/root finding", "optional": True},
{"name": "method", "type": "string", "description": "算法方法", "optional": True}, {"name": "method", "type": "string", "description": "Algorithm, e.g. 'BFGS', 'brentq'", "optional": True},
{"name": "x_data", "type": "string", "description": "插值 x 数据(JSON)", "optional": True}, {"name": "x_data", "type": "string", "description": "Interpolation x data as JSON", "optional": True},
{"name": "y_data", "type": "string", "description": "插值 y 数据(JSON)", "optional": True}, {"name": "y_data", "type": "string", "description": "Interpolation y data as JSON", "optional": True},
{"name": "x_eval", "type": "string", "description": "插值求值点(JSON)", "optional": True}, {"name": "x_eval", "type": "string", "description": "Points to evaluate the interpolant at, JSON", "optional": True},
{"name": "function", "type": "string", "description": "特殊函数名/统计操作名", "optional": True}, {"name": "function", "type": "string", "description": "Special-function or statistics operation name", "optional": True},
{"name": "parameters", "type": "string", "description": "特殊函数参数(JSON)", "optional": True}, {"name": "parameters", "type": "string", "description": "Special-function parameters as JSON", "optional": True},
{"name": "initial_conditions", "type": "string", "description": "ODE 初值(JSON)", "optional": True}, {"name": "initial_conditions", "type": "string", "description": "ODE initial value(s) as JSON, e.g. '[1.0]'", "optional": True},
{"name": "t_values", "type": "string", "description": "ODE 时间点(JSON)", "optional": True}, {"name": "t_values", "type": "string", "description": "ODE time points as JSON, e.g. '[0, 0.1, 0.2]'", "optional": True},
{"name": "variables", "type": "string", "description": "ODE 变量名", "optional": True}, {"name": "variables", "type": "string", "description": "ODE dependent variable name, default 'y'", "optional": True},
{"name": "data", "type": "string", "description": "统计或 FFT 数据(JSON)", "optional": True}, {"name": "data", "type": "string", "description": "Statistics/FFT data as JSON", "optional": True},
{"name": "params", "type": "string", "description": "统计附加参数(JSON)", "optional": True}, {"name": "params", "type": "string", "description": "Extra statistics parameter as JSON: for ttest the population mean ('2.3' or '{\"popmean\": 2.3}'), for pearsonr the second data series", "optional": True},
{"name": "data_series", "type": "string", "description": "FFT 数据(JSON)", "optional": True}, {"name": "data_series", "type": "string", "description": "FFT data as JSON", "optional": True},
{"name": "matrix_a", "type": "string", "description": "矩阵(JSON)用于特征分解", "optional": True} {"name": "matrix_a", "type": "string", "description": "Matrix as JSON for matrix_eigensystem", "optional": True}
] ]
}, },
} }
@@ -116,7 +146,7 @@ class MCPCalculatorServer:
tools.append({ tools.append({
"name": tool_name, "name": tool_name,
"description": definition["description"], "description": definition["description"],
"params": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
param["name"]: { param["name"]: {
@@ -171,6 +201,10 @@ class MCPCalculatorServer:
params = request_dict.get("params", {}) params = request_dict.get("params", {})
req_id = request_dict.get("id") req_id = request_dict.get("id")
# JSON-RPC notifications (no id) must not be answered
if req_id is None and isinstance(method, str) and method.startswith("notifications/"):
return None
# 处理特殊方法 # 处理特殊方法
if method == "initialize": if method == "initialize":
return { return {
@@ -201,6 +235,9 @@ class MCPCalculatorServer:
result = self.call_tool(tool_name, tool_params) result = self.call_tool(tool_name, tool_params)
is_error = isinstance(result, str) and result.startswith(
("Error", "Parameter Error", "Execution Error")
)
return { return {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"result": { "result": {
@@ -209,7 +246,8 @@ class MCPCalculatorServer:
"type": "text", "type": "text",
"text": result "text": result
} }
] ],
"isError": is_error
}, },
"id": req_id "id": req_id
} }
@@ -253,7 +291,8 @@ class MCPCalculatorServer:
request = json.loads(line) request = json.loads(line)
response = self.handle_rpc_request(request) response = self.handle_rpc_request(request)
# 写入 JSON-RPC 响应到标准输出 # Notifications return None — nothing must be written back
if response is not None:
sys.stdout.write(json.dumps(response, ensure_ascii=False) + '\n') sys.stdout.write(json.dumps(response, ensure_ascii=False) + '\n')
sys.stdout.flush() sys.stdout.flush()