From e4b5ea649f5b172fbf4cd68dec480cf08616d1ab Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 3 Oct 2026 01:27:21 +0100 Subject: [PATCH] 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 --- README.md | 19 ++++++- calculator.py | 85 +++++++++++++++++++------------ mcp_server.py | 135 ++++++++++++++++++++++++++++++++------------------ 3 files changed, 159 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index e66c9de..11630f4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,26 @@ # 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) +> **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 ### 1. Install Dependencies diff --git a/calculator.py b/calculator.py index 569c1ad..6f87023 100644 --- a/calculator.py +++ b/calculator.py @@ -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"): diff --git a/mcp_server.py b/mcp_server.py index d5edd44..eeb582d 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -58,55 +58,85 @@ class MCPCalculatorServer: # Consolidated tool definitions (3 tools) tool_definitions = { "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": [ - {"name": "operation", "type": "string", "description": "操作类型,如 derivative, integral, solve 等"}, - {"name": "expression", "type": "string", "description": "表达式", "optional": True}, - {"name": "variable", "type": "string", "description": "变量", "optional": True}, - {"name": "order", "type": "integer", "description": "阶数(导数/泰勒)", "optional": True}, - {"name": "lower_limit", "type": "string", "description": "积分下限", "optional": True}, - {"name": "upper_limit", "type": "string", "description": "积分上限", "optional": True}, - {"name": "equation", "type": "string", "description": "方程", "optional": True}, - {"name": "point", "type": "string", "description": "极限或泰勒展开点", "optional": True}, - {"name": "matrix_data", "type": "string", "description": "符号矩阵(JSON)", "optional": True} + {"name": "operation", "type": "string", "description": "One of: simplify, expand, factor, derivative, integral, limit, solve, dsolve, taylor, matrix"}, + {"name": "expression", "type": "string", "description": "Expression in SymPy syntax, e.g. 'sin(x)/x'", "optional": True}, + {"name": "variable", "type": "string", "description": "Variable name, e.g. 'x' (for matrix: the operation name)", "optional": True}, + {"name": "order", "type": "integer", "description": "Order for derivative/taylor (default 1 / 5)", "optional": True}, + {"name": "lower_limit", "type": "string", "description": "Lower bound for definite integral, e.g. '0'", "optional": True}, + {"name": "upper_limit", "type": "string", "description": "Upper bound for definite integral, e.g. 'oo'", "optional": True}, + {"name": "equation", "type": "string", "description": "Equation for solve/dsolve; '=' allowed", "optional": True}, + {"name": "point", "type": "string", "description": "Point for limit/taylor, e.g. '0' or 'oo'", "optional": True}, + {"name": "matrix_data", "type": "string", "description": "Matrix as JSON, e.g. '[[1,2],[3,4]]'", "optional": True} ] }, "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": [ - {"name": "operation", "type": "string", "description": "操作类型,如 eigenvalues, sum, poly_eval 等"}, - {"name": "array_data", "type": "string", "description": "数组数据(JSON)", "optional": True}, - {"name": "axis", "type": "string", "description": "轴参数", "optional": True}, - {"name": "matrix_a", "type": "string", "description": "矩阵A(JSON)", "optional": True}, - {"name": "matrix_b", "type": "string", "description": "矩阵B(JSON)", "optional": True}, - {"name": "values", "type": "string", "description": "三角函数输入(JSON)", "optional": True}, - {"name": "use_degrees", "type": "boolean", "description": "角度制", "optional": True}, - {"name": "coefficients", "type": "string", "description": "多项式系数(JSON)", "optional": True}, - {"name": "x_values", "type": "string", "description": "多项式自变量数组(JSON)", "optional": True} + {"name": "operation", "type": "string", "description": "Operation name, e.g. 'eigenvalues', 'matmul', 'mean', 'poly_eval'"}, + {"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": "Axis for reductions: '0' or '1'", "optional": True}, + {"name": "matrix_a", "type": "string", "description": "First matrix as JSON", "optional": True}, + {"name": "matrix_b", "type": "string", "description": "Second matrix as JSON (solve/matmul/dot/hadamard)", "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": "Treat trig inputs as degrees (default radians)", "optional": True}, + {"name": "coefficients", "type": "string", "description": "Polynomial coefficients as JSON, highest degree first", "optional": True}, + {"name": "x_values", "type": "string", "description": "x values for poly_eval as JSON", "optional": True} ] }, "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": [ - {"name": "operation", "type": "string", "description": "操作类型,如 integrate, optimize_minimize, fft 等"}, - {"name": "expression", "type": "string", "description": "表达式/函数", "optional": True}, - {"name": "variable", "type": "string", "description": "变量", "optional": True}, - {"name": "lower", "type": "string", "description": "积分下限", "optional": True}, - {"name": "upper", "type": "string", "description": "积分上限", "optional": True}, - {"name": "initial_value", "type": "string", "description": "初值/起点", "optional": True}, - {"name": "method", "type": "string", "description": "算法方法", "optional": True}, - {"name": "x_data", "type": "string", "description": "插值 x 数据(JSON)", "optional": True}, - {"name": "y_data", "type": "string", "description": "插值 y 数据(JSON)", "optional": True}, - {"name": "x_eval", "type": "string", "description": "插值求值点(JSON)", "optional": True}, - {"name": "function", "type": "string", "description": "特殊函数名/统计操作名", "optional": True}, - {"name": "parameters", "type": "string", "description": "特殊函数参数(JSON)", "optional": True}, - {"name": "initial_conditions", "type": "string", "description": "ODE 初值(JSON)", "optional": True}, - {"name": "t_values", "type": "string", "description": "ODE 时间点(JSON)", "optional": True}, - {"name": "variables", "type": "string", "description": "ODE 变量名", "optional": True}, - {"name": "data", "type": "string", "description": "统计或 FFT 数据(JSON)", "optional": True}, - {"name": "params", "type": "string", "description": "统计附加参数(JSON)", "optional": True}, - {"name": "data_series", "type": "string", "description": "FFT 数据(JSON)", "optional": True}, - {"name": "matrix_a", "type": "string", "description": "矩阵(JSON)用于特征分解", "optional": True} + {"name": "operation", "type": "string", "description": "Operation name, e.g. 'integrate', 'optimize_minimize', 'solve_ode', 'fft'"}, + {"name": "expression", "type": "string", "description": "Expression/function of the variable, e.g. 'x**2 * exp(-x)'", "optional": True}, + {"name": "variable", "type": "string", "description": "Variable name, e.g. 'x'", "optional": True}, + {"name": "lower", "type": "string", "description": "Lower integration bound (number)", "optional": True}, + {"name": "upper", "type": "string", "description": "Upper integration bound (number or 'oo')", "optional": True}, + {"name": "initial_value", "type": "string", "description": "Starting point for optimization/root finding", "optional": True}, + {"name": "method", "type": "string", "description": "Algorithm, e.g. 'BFGS', 'brentq'", "optional": True}, + {"name": "x_data", "type": "string", "description": "Interpolation x data as JSON", "optional": True}, + {"name": "y_data", "type": "string", "description": "Interpolation y data as JSON", "optional": True}, + {"name": "x_eval", "type": "string", "description": "Points to evaluate the interpolant at, JSON", "optional": True}, + {"name": "function", "type": "string", "description": "Special-function or statistics operation name", "optional": True}, + {"name": "parameters", "type": "string", "description": "Special-function parameters as 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 time points as JSON, e.g. '[0, 0.1, 0.2]'", "optional": True}, + {"name": "variables", "type": "string", "description": "ODE dependent variable name, default 'y'", "optional": True}, + {"name": "data", "type": "string", "description": "Statistics/FFT data as 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 data as 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({ "name": tool_name, "description": definition["description"], - "params": { + "inputSchema": { "type": "object", "properties": { param["name"]: { @@ -170,7 +200,11 @@ class MCPCalculatorServer: method = request_dict.get("method") params = request_dict.get("params", {}) 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": return { @@ -200,7 +234,10 @@ class MCPCalculatorServer: tool_params = params.get("arguments", {}) result = self.call_tool(tool_name, tool_params) - + + is_error = isinstance(result, str) and result.startswith( + ("Error", "Parameter Error", "Execution Error") + ) return { "jsonrpc": "2.0", "result": { @@ -209,7 +246,8 @@ class MCPCalculatorServer: "type": "text", "text": result } - ] + ], + "isError": is_error }, "id": req_id } @@ -252,10 +290,11 @@ class MCPCalculatorServer: try: request = json.loads(line) response = self.handle_rpc_request(request) - - # 写入 JSON-RPC 响应到标准输出 - sys.stdout.write(json.dumps(response, ensure_ascii=False) + '\n') - sys.stdout.flush() + + # 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.flush() except json.JSONDecodeError as e: # 发送 JSON 解析错误