mathmethods-mcp 0.1.0__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.
@@ -0,0 +1,7 @@
1
+ """MathematicalMethods MCP server.
2
+
3
+ Exposes the vendored numerical-methods core as Model Context Protocol (MCP)
4
+ tools for LLM agents.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running the MCP server with ``python -m mathmethods``."""
2
+
3
+ from mathmethods.server import run
4
+
5
+ if __name__ == "__main__":
6
+ run()
@@ -0,0 +1,202 @@
1
+ """Hardened math expression validation for the MCP server.
2
+
3
+ The vendored math core trusts the strings it receives. Since MCP tools are
4
+ driven by an LLM (which can be tricked by prompt injection), this module adds
5
+ defense in depth before any string reaches the math core:
6
+
7
+ - Length cap (mitigates denial-of-service on pathological inputs)
8
+ - Symbol whitelist (no undeclared variables)
9
+ - Function whitelist (only standard math functions)
10
+ - Notation normalization (``e^x`` -> ``E**x``, ``sen`` -> ``sin``, ``^`` -> ``**``)
11
+ - Strict lexical check BEFORE parsing (this is what stops code execution)
12
+
13
+ **Important:** SymPy's ``sympify`` evaluates strings with ``eval`` internally
14
+ and, on some versions, allows attribute access and ``__import__`` (verified:
15
+ ``sympify("__import__('os').system('echo HACK')")`` executes the command).
16
+ Therefore the lexical whitelist below runs FIRST and rejects any token that is
17
+ not a number, an allowed variable/constant or an allowed function name, as well
18
+ as any ``.`` or ``__``. Only after that gate is it safe to call ``sympify``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from collections.abc import Callable, Iterable, Sequence
25
+
26
+ import sympy as sp
27
+
28
+ MAX_EXPRESSION_LENGTH = 200
29
+
30
+ # Reject integer literals with more than this many digits to keep sympify's
31
+ # eager evaluation (e.g. factorial(99999999)) from exhausting memory.
32
+ MAX_INTEGER_DIGITS = 6
33
+
34
+ # SymPy canonical function names allowed inside expressions.
35
+ ALLOWED_FUNCTIONS: frozenset[str] = frozenset(
36
+ {
37
+ # trigonometric
38
+ "sin", "cos", "tan", "cot", "sec", "csc",
39
+ "asin", "acos", "atan", "acot", "asec", "acsc", "atan2",
40
+ "sinh", "cosh", "tanh", "coth", "sech", "csch",
41
+ "asinh", "acosh", "atanh",
42
+ # exponential / logarithmic / roots
43
+ "exp", "log", "ln", "sqrt", "cbrt", "root",
44
+ # absolute value / sign / rounding
45
+ "Abs", "sign", "floor", "ceiling", "frac",
46
+ # gamma family
47
+ "gamma", "loggamma", "factorial", "binomial", "subfactorial",
48
+ # special functions
49
+ "erf", "erfc", "erfi",
50
+ "Min", "Max",
51
+ }
52
+ )
53
+
54
+ # Numeric constants always available regardless of the allowed symbol list.
55
+ _BUILTIN_CONSTANTS = {"e": sp.E, "pi": sp.pi, "E": sp.E}
56
+
57
+ _SINGLE_VAR = re.compile(r"(?<![A-Za-z0-9_])[eE]\^")
58
+ # Unicode letters (incl. Greek symbols like α, β) start identifiers; digits
59
+ # and leading underscores are rejected by the lexical gate.
60
+ _IDENTIFIER_RE = re.compile(r"[^\W\d_]\w*", re.UNICODE)
61
+ _NUMBER_RE = re.compile(r"(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
62
+ _ALLOWED_PUNCTUATION = set("()+-,*/%<>!=:")
63
+
64
+ # Logic keywords SymPy understands; harmless in expressions.
65
+ _LOGIC_KEYWORDS = frozenset({"and", "or", "not"})
66
+
67
+
68
+ def normalize(expr: str) -> str:
69
+ """Normalize common math notations to SymPy syntax.
70
+
71
+ Converts ``e^x`` / ``e^(-x)`` into ``E**x`` / ``E**(-x)`` (the old
72
+ ``exp(`` replacement left unbalanced parentheses), ``sen`` into ``sin``,
73
+ ``ln`` into ``log`` and every remaining ``^`` into ``**``.
74
+ """
75
+ out = expr.replace("sen", "sin")
76
+ out = out.replace("ln", "log")
77
+ out = _SINGLE_VAR.sub("E**", out)
78
+ out = out.replace("^", "**")
79
+ return out
80
+
81
+
82
+ def _check_lexical(expr: str, allowed_identifiers: set[str]) -> None:
83
+ """Reject any token that is not a number, variable, constant or allowed
84
+ function, and any attribute access (``.`` / ``__``).
85
+
86
+ Runs *before* ``sympify`` so arbitrary Python code can never be evaluated.
87
+ """
88
+ allowed_words = (
89
+ allowed_identifiers
90
+ | set(ALLOWED_FUNCTIONS)
91
+ | set(_BUILTIN_CONSTANTS)
92
+ | _LOGIC_KEYWORDS
93
+ )
94
+
95
+ i, n = 0, len(expr)
96
+ while i < n:
97
+ ch = expr[i]
98
+ if ch.isspace():
99
+ i += 1
100
+ continue
101
+
102
+ number = _NUMBER_RE.match(expr, i)
103
+ if number:
104
+ text = number.group()
105
+ int_part = text.split(".")[0].split("e")[0].split("E")[0].lstrip("+-")
106
+ if len(int_part) > MAX_INTEGER_DIGITS:
107
+ raise ValueError(
108
+ f"Integer literal too large ({len(int_part)} digits, max {MAX_INTEGER_DIGITS})."
109
+ )
110
+ i = number.end()
111
+ continue
112
+
113
+ ident = _IDENTIFIER_RE.match(expr, i)
114
+ if ident:
115
+ token = ident.group()
116
+ if token not in allowed_words:
117
+ raise ValueError(f"Unknown or forbidden token: {token}")
118
+ i = ident.end()
119
+ continue
120
+
121
+ if ch in _ALLOWED_PUNCTUATION:
122
+ i += 1
123
+ continue
124
+ if ch == ".":
125
+ raise ValueError("Attribute access is not allowed.")
126
+ if ch == "_":
127
+ raise ValueError("Underscore identifiers are not allowed.")
128
+ raise ValueError(f"Invalid character: {ch!r}")
129
+
130
+
131
+ def validate(expr_str: str, variables: Sequence[str] = ("x",)) -> str:
132
+ """Validate a math expression string and return its normalized form.
133
+
134
+ Raises ``ValueError`` if the expression is too long, malformed, uses
135
+ forbidden functions or references symbols outside ``variables``.
136
+ """
137
+ if not expr_str or not expr_str.strip():
138
+ raise ValueError("Expression cannot be empty.")
139
+ if len(expr_str) > MAX_EXPRESSION_LENGTH:
140
+ raise ValueError(
141
+ f"Expression too long ({len(expr_str)} chars, max {MAX_EXPRESSION_LENGTH})."
142
+ )
143
+
144
+ normalized = normalize(expr_str)
145
+
146
+ _check_lexical(normalized, {str(name) for name in variables})
147
+
148
+ local_dict: dict[str, sp.Symbol] = {}
149
+ for name in variables:
150
+ local_dict[str(name)] = sp.Symbol(str(name), real=True)
151
+ local_dict.update(_BUILTIN_CONSTANTS)
152
+
153
+ try:
154
+ expr = sp.sympify(normalized, locals=local_dict)
155
+ except Exception as exc: # SymPy raises a variety of parse errors
156
+ raise ValueError(f"Invalid math expression: {exc}") from exc
157
+
158
+ allowed_symbols = {str(name) for name in variables}
159
+ extra = {str(sym) for sym in expr.free_symbols} - allowed_symbols
160
+ if extra:
161
+ raise ValueError(f"Unknown symbol(s): {sorted(extra)}")
162
+
163
+ for fn in expr.atoms(sp.Function):
164
+ name = fn.func.__name__
165
+ if name not in ALLOWED_FUNCTIONS:
166
+ raise ValueError(f"Function not allowed: {name}")
167
+
168
+ return normalized
169
+
170
+
171
+ def compile_callable(
172
+ expr_str: str,
173
+ variables: Sequence[str] = ("x",),
174
+ modules: Sequence[str] = ("numpy",),
175
+ ) -> Callable:
176
+ """Validate an expression and return a vectorized NumPy callable.
177
+
178
+ The returned callable accepts the variable(s) in the same order as
179
+ ``variables`` (a single callable for one variable, multiple positional
180
+ arguments for several variables).
181
+ """
182
+ normalized = validate(expr_str, variables)
183
+ symbols = [sp.Symbol(str(name), real=True) for name in variables]
184
+ local_dict = {str(name): sym for name, sym in zip(variables, symbols, strict=True)}
185
+ local_dict.update(_BUILTIN_CONSTANTS)
186
+ expr = sp.sympify(normalized, locals=local_dict)
187
+ return sp.lambdify(symbols, expr, modules=list(modules))
188
+
189
+
190
+ def sanitize_and_check(
191
+ expr_str: str, variables: Iterable[str] = ("x",)
192
+ ) -> tuple[str, sp.Expr]:
193
+ """Return ``(normalized_str, parsed_expr)`` after validation.
194
+
195
+ Useful for tests and for callers that need the symbolic expression too.
196
+ """
197
+ normalized = validate(expr_str, list(variables))
198
+ symbols = [sp.Symbol(str(name), real=True) for name in variables]
199
+ local_dict = {str(name): sym for name, sym in zip(variables, symbols, strict=True)}
200
+ local_dict.update(_BUILTIN_CONSTANTS)
201
+ expr = sp.sympify(normalized, locals=local_dict)
202
+ return normalized, expr
@@ -0,0 +1,36 @@
1
+ """Vendored numerical-methods core.
2
+
3
+ These modules are vendored from ``modeladoYsimulacion-web/backend/app/methods``
4
+ so this MCP server is fully self-contained and publishable. Keep them in sync
5
+ with the upstream repo when that code changes.
6
+ """
7
+
8
+ from . import (
9
+ differentiation,
10
+ dynamic_1d,
11
+ dynamic_2d_conservative,
12
+ dynamic_2d_lanchester,
13
+ dynamic_2d_linear,
14
+ dynamic_2d_non_homogeneous,
15
+ dynamic_2d_nonlinear,
16
+ integration,
17
+ interpolation,
18
+ monte_carlo,
19
+ ode,
20
+ root_finding,
21
+ )
22
+
23
+ __all__ = [
24
+ "differentiation",
25
+ "dynamic_1d",
26
+ "dynamic_2d_conservative",
27
+ "dynamic_2d_lanchester",
28
+ "dynamic_2d_linear",
29
+ "dynamic_2d_non_homogeneous",
30
+ "dynamic_2d_nonlinear",
31
+ "integration",
32
+ "interpolation",
33
+ "monte_carlo",
34
+ "ode",
35
+ "root_finding",
36
+ ]
@@ -0,0 +1,122 @@
1
+ """
2
+ Derivación Numérica - Diferencias Finitas
3
+ - Primera derivada
4
+ - Segunda derivada
5
+ """
6
+ import sympy as sp
7
+ import warnings
8
+ from typing import Dict
9
+ from typing import Dict, Callable
10
+ from .utils import safe_sympify
11
+
12
+ class DifferentiationService:
13
+
14
+ # --- TUS MÉTODOS ORIGINALES (INTACTOS POR COMPATIBILIDAD) ---
15
+ @staticmethod
16
+ def compilar_funcion(texto_funcion: str) -> Callable:
17
+ """Convierte string a función callable."""
18
+ try:
19
+ x = sp.Symbol('x')
20
+ expr = safe_sympify(texto_funcion, allowed_symbols=['x'])
21
+ return sp.lambdify(x, expr, 'numpy'), expr
22
+ except Exception as e:
23
+ raise ValueError(f"Error compilando función: {str(e)}")
24
+
25
+ @staticmethod
26
+ def diferencias_finitas(f: Callable, x_val: float, h: float = 1e-5, precision: int = 8) -> Dict:
27
+ """Calcula primera y segunda derivada con diferencias centrales."""
28
+ f1_aprox = (f(x_val + h) - f(x_val - h)) / (2 * h)
29
+ f2_aprox = (f(x_val + h) - 2*f(x_val) + f(x_val - h)) / (h**2)
30
+ return {
31
+ "x": round(x_val, precision), "h": round(h, precision),
32
+ "primera_derivada": {"aproximada": round(f1_aprox, precision), "formula": "(f(x+h) - f(x-h)) / (2h)"},
33
+ "segunda_derivada": {"aproximada": round(f2_aprox, precision), "formula": "(f(x+h) - 2f(x) + f(x-h)) / h²"}
34
+ }
35
+
36
+ @staticmethod
37
+ def diferencias_finitas_con_exacto(f_str: str, x_val: float, h: float = 1e-5, precision: int = 8) -> Dict:
38
+ """Calcula derivadas numéricas Y las compara con exactas."""
39
+ f, expr = DifferentiationService.compilar_funcion(f_str)
40
+ x = sp.Symbol('x')
41
+ df1_expr = sp.diff(expr, x)
42
+ df2_expr = sp.diff(df1_expr, x)
43
+ f1_exacta = float(df1_expr.subs(x, x_val).evalf())
44
+ f2_exacta = float(df2_expr.subs(x, x_val).evalf())
45
+ f1_aprox = (f(x_val + h) - f(x_val - h)) / (2 * h)
46
+ f2_aprox = (f(x_val + h) - 2*f(x_val) + f(x_val - h)) / (h**2)
47
+ # Aquí también deberíamos usar .evalf() si queremos máxima precisión, pero el problema
48
+ # estaba en el pre-redondeo que hacíamos para la nueva web.
49
+ error_1 = abs(f1_exacta - f1_aprox)
50
+ error_2 = abs(f2_exacta - f2_aprox)
51
+ return {
52
+ "x": round(x_val, precision), "h": round(h, precision),
53
+ "primera_derivada": {"numerica": round(f1_aprox, precision), "exacta": round(f1_exacta, precision), "error": round(error_1, 8), "formula_numerica": "(f(x+h) - f(x-h)) / (2h)"},
54
+ "segunda_derivada": {"numerica": round(f2_aprox, precision), "exacta": round(f2_exacta, precision), "error": round(error_2, 8), "formula_numerica": "(f(x+h) - 2f(x) + f(x-h)) / h²"}
55
+ }
56
+
57
+ # --- NUESTRO MÉTODO NUEVO UNIFICADO (ACTUALIZADO PARA FORMATOS) ---
58
+ @staticmethod
59
+ def calcular_diferencias_completas(func_str: str, x_val: float, h: float, precision: int = 8) -> Dict:
60
+ """Calcula diferencias Progresivas, Regresivas y Centrales enviando error crudo."""
61
+ try:
62
+ # Tratamiento especial para 'log' en SymPy (por defecto es ln)
63
+ # si el usuario escribe 'log10', SymPy lo manejará. 'ln' es el estándar.
64
+
65
+ x_sym = sp.Symbol('x')
66
+ diccionario_local = {'e': sp.E, 'pi': sp.pi}
67
+ expr = safe_sympify(func_str, local_dict=diccionario_local, allowed_symbols=['x'])
68
+ f = sp.lambdify(x_sym, expr, 'numpy')
69
+
70
+ dev_expr = sp.diff(expr, x_sym)
71
+ dev_exacta = float(dev_expr.subs(x_sym, x_val).evalf())
72
+
73
+ with warnings.catch_warnings():
74
+ warnings.simplefilter("ignore")
75
+ fx = float(f(x_val))
76
+ fx_mas_h = float(f(x_val + h))
77
+ fx_menos_h = float(f(x_val - h))
78
+
79
+ progresiva = (fx_mas_h - fx) / h
80
+ regresiva = (fx - fx_menos_h) / h
81
+ central = (fx_mas_h - fx_menos_h) / (2 * h)
82
+
83
+ dev2_expr = sp.diff(dev_expr, x_sym)
84
+ dev2_exacta = float(dev2_expr.subs(x_sym, x_val).evalf())
85
+ segunda_central = (fx_mas_h - 2*fx + fx_menos_h) / (h**2)
86
+
87
+ return {
88
+ "x_evaluado": round(x_val, precision),
89
+ "h_usado": h,
90
+ "f_x": round(fx, precision),
91
+ "derivada_exacta": round(dev_exacta, precision),
92
+ "segunda_derivada_exacta": round(dev2_exacta, precision),
93
+ "resultados": [
94
+ {
95
+ "metodo": "Diferencia Progresiva",
96
+ "formula": "[f(x+h) - f(x)] / h",
97
+ "valor": round(progresiva, precision),
98
+ # CONEXIÓN: Enviamos el error crudo sin redondear
99
+ "error_raw": abs(dev_exacta - progresiva)
100
+ },
101
+ {
102
+ "metodo": "Diferencia Regresiva",
103
+ "formula": "[f(x) - f(x-h)] / h",
104
+ "valor": round(regresiva, precision),
105
+ "error_raw": abs(dev_exacta - regresiva)
106
+ },
107
+ {
108
+ "metodo": "Diferencia Central",
109
+ "formula": "[f(x+h) - f(x-h)] / 2h",
110
+ "valor": round(central, precision),
111
+ "error_raw": abs(dev_exacta - central)
112
+ },
113
+ {
114
+ "metodo": "Segunda Derivada (Central)",
115
+ "formula": "[f(x+h) - 2f(x) + f(x-h)] / h²",
116
+ "valor": round(segunda_central, precision),
117
+ "error_raw": abs(dev2_exacta - segunda_central)
118
+ }
119
+ ]
120
+ }
121
+ except Exception as e:
122
+ raise ValueError(f"Error evaluando la función: {str(e)}")