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.
mathmethods/server.py ADDED
@@ -0,0 +1,1414 @@
1
+ """FastMCP application and tool definitions for MathematicalMethods.
2
+
3
+ Kept inside the package so it can be imported by tests and by the thin
4
+ ``server.py`` entry point at the repository root.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import os
11
+ import re
12
+ from collections.abc import Callable
13
+
14
+ import numpy as np
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from mathmethods import compiler
18
+ from mathmethods.core import (
19
+ differentiation,
20
+ dynamic_1d,
21
+ dynamic_2d_conservative,
22
+ dynamic_2d_lanchester,
23
+ dynamic_2d_linear,
24
+ dynamic_2d_non_homogeneous,
25
+ dynamic_2d_nonlinear,
26
+ integration,
27
+ interpolation,
28
+ monte_carlo,
29
+ ode,
30
+ root_finding,
31
+ )
32
+
33
+ # --------------------------------------------------------------------------
34
+ # Resource / input caps (denial-of-service mitigation, see README security)
35
+ # --------------------------------------------------------------------------
36
+ MAX_ITERATIONS = 500
37
+ MAX_SUBINTERVALS = 2000
38
+ MAX_ODE_STEPS = 2000
39
+ MAX_INTERPOLATION_POINTS = 30
40
+ MAX_PRECISION = 15
41
+ MAX_PLOT_POINTS = 400
42
+
43
+
44
+ # --------------------------------------------------------------------------
45
+ # Helpers
46
+ # --------------------------------------------------------------------------
47
+ def _require(condition: bool, message: str) -> None:
48
+ if not condition:
49
+ raise ValueError(message)
50
+
51
+
52
+ def _clean_float(value: float, name: str, *, positive: bool = False) -> float:
53
+ try:
54
+ value = float(value)
55
+ except (TypeError, ValueError) as exc:
56
+ raise ValueError(f"{name} must be a number.") from exc
57
+ if not math.isfinite(value):
58
+ raise ValueError(f"{name} must be finite.")
59
+ if positive and value <= 0:
60
+ raise ValueError(f"{name} must be positive.")
61
+ return value
62
+
63
+
64
+ def _clean_int(value: int, name: str, *, minimum: int, maximum: int) -> int:
65
+ try:
66
+ value = int(value)
67
+ except (TypeError, ValueError) as exc:
68
+ raise ValueError(f"{name} must be an integer.") from exc
69
+ if not (minimum <= value <= maximum):
70
+ raise ValueError(f"{name} must be between {minimum} and {maximum}.")
71
+ return value
72
+
73
+
74
+ def _json_safe(value, _depth: int = 0) -> object:
75
+ """Recursively convert a result into plain JSON-serializable data.
76
+
77
+ NumPy scalars/arrays become Python values, NaN/Inf become ``None`` and
78
+ anything else unsupported falls back to its string representation.
79
+ """
80
+ if _depth > 64:
81
+ return str(value)
82
+ if isinstance(value, dict):
83
+ return {str(k): _json_safe(v, _depth + 1) for k, v in value.items()}
84
+ if isinstance(value, np.ndarray):
85
+ return _json_safe(value.tolist(), _depth + 1)
86
+ if isinstance(value, (list, tuple)):
87
+ return [_json_safe(v, _depth + 1) for v in value]
88
+ if isinstance(value, np.generic):
89
+ value = value.item()
90
+ if isinstance(value, bool):
91
+ return value
92
+ if isinstance(value, (int, float)):
93
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
94
+ return None
95
+ return value
96
+ if value is None or isinstance(value, str):
97
+ return value
98
+ return str(value)
99
+
100
+
101
+ def _downsample(values, max_points: int = MAX_PLOT_POINTS) -> object:
102
+ if not isinstance(values, list) or len(values) <= max_points:
103
+ return values
104
+ step = len(values) / max_points
105
+ return [values[int(i * step)] for i in range(max_points)]
106
+
107
+
108
+ def _finalize(result, *, downsample_keys: tuple[str, ...] = ()) -> dict:
109
+ clean = _json_safe(result)
110
+ for key in downsample_keys:
111
+ if isinstance(clean, dict) and key in clean:
112
+ clean[key] = _downsample(clean[key])
113
+ return clean
114
+
115
+
116
+ def _trim_large_arrays(value) -> object:
117
+ """Recursively downsample long numeric lists to keep responses bounded."""
118
+ if isinstance(value, list):
119
+ if value and all(isinstance(v, (int, float)) for v in value):
120
+ return _downsample(value)
121
+ return [_trim_large_arrays(v) for v in value]
122
+ if isinstance(value, dict):
123
+ return {k: _trim_large_arrays(v) for k, v in value.items()}
124
+ return value
125
+
126
+
127
+ def _finalize_deep(result) -> dict:
128
+ return _trim_large_arrays(_json_safe(result))
129
+
130
+
131
+ # --------------------------------------------------------------------------
132
+ # MCP application
133
+ # --------------------------------------------------------------------------
134
+ _HTTP_HOST = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
135
+ _HTTP_PORT = int(os.environ.get("MCP_HTTP_PORT", "8000"))
136
+
137
+ mcp = FastMCP(
138
+ "MathematicalMethods",
139
+ host=_HTTP_HOST,
140
+ port=_HTTP_PORT,
141
+ instructions=(
142
+ "Numerical-methods server: root finding (bisection), numerical "
143
+ "integration (Simpson 1/3), ordinary differential equations "
144
+ "(Runge-Kutta 4) and Lagrange interpolation. Math expressions use "
145
+ "Python/SymPy syntax: x**2, sin(x), exp(x), sqrt(x), log(x). "
146
+ "Also accepted: e^x, sen(x) and the caret (^) for powers."
147
+ ),
148
+ )
149
+
150
+
151
+ @mcp.tool()
152
+ def root_bisection(
153
+ func_str: str,
154
+ a: float,
155
+ b: float,
156
+ tol: float = 1e-6,
157
+ max_iter: int = 100,
158
+ precision: int = 8,
159
+ ) -> dict:
160
+ """Find a root of f(x) = 0 in [a, b] using the bisection method.
161
+
162
+ Requires f(a) and f(b) to have opposite signs (Bolzano's theorem).
163
+
164
+ Args:
165
+ func_str: Math expression in x, e.g. "x**2 - 4".
166
+ a: Left endpoint of the interval.
167
+ b: Right endpoint of the interval (must be > a).
168
+ tol: Convergence tolerance on the residual/error.
169
+ max_iter: Maximum number of iterations.
170
+ precision: Rounding digits for the reported root.
171
+
172
+ Returns:
173
+ Dict with the root, convergence flag, iteration table and errors.
174
+ """
175
+ a = _clean_float(a, "a")
176
+ b = _clean_float(b, "b")
177
+ tol = _clean_float(tol, "tol", positive=True)
178
+ max_iter = _clean_int(max_iter, "max_iter", minimum=1, maximum=MAX_ITERATIONS)
179
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
180
+ _require(b > a, "b must be greater than a.")
181
+
182
+ normalized = compiler.validate(func_str, variables=("x",))
183
+ func = root_finding.RootFindingService.compilar_funcion(normalized)
184
+ result = root_finding.RootFindingService.biseccion(
185
+ func, a, b, tol=tol, max_iter=max_iter, precision=precision
186
+ )
187
+ return _finalize(result)
188
+
189
+
190
+ @mcp.tool()
191
+ def integral_simpson13(
192
+ func_str: str,
193
+ a: float,
194
+ b: float,
195
+ n: int,
196
+ epsilon: float | None = None,
197
+ precision: int = 8,
198
+ ) -> dict:
199
+ """Approximate the definite integral of f(x) on [a, b] with composite Simpson 1/3.
200
+
201
+ Args:
202
+ func_str: Math expression in x, e.g. "sin(x)/x".
203
+ a: Lower integration limit.
204
+ b: Upper integration limit (must be > a).
205
+ n: Number of subintervals (must be even). Error is O(h^4).
206
+ epsilon: Point in [a, b] used to report the truncation error bound.
207
+ precision: Rounding digits.
208
+
209
+ Returns:
210
+ Dict with the approximated integral, error estimates and table.
211
+ """
212
+ a = _clean_float(a, "a")
213
+ b = _clean_float(b, "b")
214
+ n = _clean_int(n, "n", minimum=2, maximum=MAX_SUBINTERVALS)
215
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
216
+ _require(b > a, "b must be greater than a.")
217
+ _require(n % 2 == 0, "n must be even for Simpson 1/3.")
218
+ if epsilon is not None:
219
+ epsilon = _clean_float(epsilon, "epsilon")
220
+
221
+ normalized = compiler.validate(func_str, variables=("x",))
222
+ func = integration.IntegrationService.compilar_funcion(normalized)
223
+ result = integration.IntegrationService.simpson_13_compuesto(
224
+ func, a, b, n=n, precision=precision, epsilon=epsilon
225
+ )
226
+ return _finalize(result)
227
+
228
+
229
+ @mcp.tool()
230
+ def ode_rk4(
231
+ ecuacion_str: str,
232
+ x0: float,
233
+ y0: float,
234
+ xf: float,
235
+ h: float,
236
+ tol: float | None = None,
237
+ precision: int = 8,
238
+ ) -> dict:
239
+ """Solve an initial value problem y'(x) = f(x, y) with the Runge-Kutta 4 method.
240
+
241
+ Args:
242
+ ecuacion_str: Right-hand side f(x, y), e.g. "y" or "-2*x*y".
243
+ x0: Initial x.
244
+ y0: Initial y(x0).
245
+ xf: Final x (must be > x0).
246
+ h: Step size (must be positive). The endpoint xf is always reached.
247
+ tol: Optional tolerance to flag whether the final error meets it.
248
+ precision: Rounding digits.
249
+
250
+ Returns:
251
+ Dict with the numerical solution table, exact solution (when SymPy can
252
+ solve it) and per-step errors.
253
+ """
254
+ return _ode_solve("rk4", ecuacion_str, x0, y0, xf, h, tol, precision)
255
+
256
+
257
+ @mcp.tool()
258
+ def interpolation_lagrange(
259
+ puntos_x: list[float],
260
+ x_eval: float | None = None,
261
+ func_str: str | None = None,
262
+ puntos_y: list[float] | None = None,
263
+ precision: int = 8,
264
+ ) -> dict:
265
+ """Build the Lagrange interpolating polynomial through a set of points.
266
+
267
+ Provide either the point values ``puntos_y`` or the generating function
268
+ ``func_str`` evaluated at ``puntos_x``.
269
+
270
+ Args:
271
+ puntos_x: Distinct abscissas of the interpolation points.
272
+ x_eval: Optional x where the polynomial is evaluated.
273
+ func_str: Optional generating function f(x) for the y values.
274
+ puntos_y: Optional y values matching ``puntos_x`` one-to-one.
275
+ precision: Rounding digits.
276
+
277
+ Returns:
278
+ Dict with the polynomial, its degree, the points table and error
279
+ bounds when ``func_str`` is provided.
280
+ """
281
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
282
+ _require(
283
+ isinstance(puntos_x, list) and len(puntos_x) >= 2,
284
+ "puntos_x must be a list with at least 2 points.",
285
+ )
286
+ n_points = len(puntos_x)
287
+ _require(
288
+ n_points <= MAX_INTERPOLATION_POINTS,
289
+ f"Too many points (max {MAX_INTERPOLATION_POINTS}); Lagrange expansion grows combinatorially.",
290
+ )
291
+
292
+ cleaned_x = [_clean_float(xi, f"puntos_x[{i}]") for i, xi in enumerate(puntos_x)]
293
+ _require(len(set(cleaned_x)) == n_points, "puntos_x values must be distinct.")
294
+ puntos_x = cleaned_x
295
+
296
+ if x_eval is not None:
297
+ x_eval = _clean_float(x_eval, "x_eval")
298
+
299
+ normalized_func_str = None
300
+ if func_str is not None:
301
+ normalized_func_str = compiler.validate(func_str, variables=("x",))
302
+ elif puntos_y is None:
303
+ raise ValueError("Provide either func_str or puntos_y.")
304
+
305
+ if puntos_y is not None:
306
+ _require(
307
+ isinstance(puntos_y, list) and len(puntos_y) == n_points,
308
+ "puntos_y must be a list with the same length as puntos_x.",
309
+ )
310
+ puntos_y = [_clean_float(yi, f"puntos_y[{i}]") for i, yi in enumerate(puntos_y)]
311
+
312
+ result = interpolation.InterpolationService.lagrange(
313
+ puntos_x,
314
+ x_eval=x_eval,
315
+ func_str=normalized_func_str,
316
+ puntos_y=puntos_y,
317
+ precision=precision,
318
+ )
319
+ return _finalize(result)
320
+
321
+
322
+ # --------------------------------------------------------------------------
323
+ # Additional caps for the dynamic / Monte Carlo tools
324
+ # --------------------------------------------------------------------------
325
+ MAX_MC_N = 200_000
326
+ MAX_MC_M = 500
327
+ MAX_DYN_GRID = 2000
328
+ MAX_DYN_STEPS = 5000
329
+ MAX_TRAJECTORIES = 50
330
+ MAX_BIF_STEPS = 500
331
+
332
+
333
+ # --------------------------------------------------------------------------
334
+ # Monte Carlo tools
335
+ # --------------------------------------------------------------------------
336
+ @mcp.tool()
337
+ def mc_hit_or_miss_1d(
338
+ func_str: str,
339
+ a: float,
340
+ b: float,
341
+ N: int = 10000,
342
+ seed: int | None = None,
343
+ precision: int = 8,
344
+ nivel_confianza: float = 0.95,
345
+ ) -> dict:
346
+ """Estimate ∫ₐᵇ f(x) dx with the hit-or-miss Monte Carlo method.
347
+
348
+ Handles sign-changing integrands correctly (returns the signed integral).
349
+
350
+ Args:
351
+ func_str: Math expression in x.
352
+ a, b: Integration limits (b > a).
353
+ N: Number of samples.
354
+ seed: Optional RNG seed for reproducibility.
355
+ precision: Rounding digits.
356
+ nivel_confianza: Confidence level in (0, 1) for the interval.
357
+
358
+ Returns:
359
+ Dict with the estimate, interval, sample statistics and history.
360
+ """
361
+ a = _clean_float(a, "a")
362
+ b = _clean_float(b, "b")
363
+ N = _clean_int(N, "N", minimum=1, maximum=MAX_MC_N)
364
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
365
+ nivel_confianza = _clean_float(nivel_confianza, "nivel_confianza")
366
+ _require(0 < nivel_confianza < 1, "nivel_confianza must be in (0, 1).")
367
+ _require(b > a, "b must be greater than a.")
368
+ if seed is not None:
369
+ seed = _clean_int(seed, "seed", minimum=0, maximum=2**31 - 1)
370
+
371
+ normalized = compiler.validate(func_str, variables=("x",))
372
+ func = monte_carlo.MonteCarloService.compilar_funcion(normalized, variables="x")
373
+ result = monte_carlo.MonteCarloService.hit_or_miss_1d(
374
+ func, a, b, N=N, seed=seed, precision=precision, nivel_confianza=nivel_confianza
375
+ )
376
+ result["historial"] = _downsample(result.get("historial") or [], 100)
377
+ return _finalize_deep(result)
378
+
379
+
380
+ @mcp.tool()
381
+ def mc_valor_promedio_1d(
382
+ func_str: str,
383
+ a: float,
384
+ b: float,
385
+ N: int = 10000,
386
+ seed: int | None = None,
387
+ precision: int = 8,
388
+ nivel_confianza: float = 0.95,
389
+ ) -> dict:
390
+ """Estimate ∫ₐᵇ f(x) dx with the mean-value Monte Carlo method.
391
+
392
+ Args:
393
+ func_str: Math expression in x.
394
+ a, b: Integration limits (b > a).
395
+ N: Number of samples.
396
+ seed: Optional RNG seed for reproducibility.
397
+ precision: Rounding digits.
398
+ nivel_confianza: Confidence level in (0, 1).
399
+
400
+ Returns:
401
+ Dict with the estimate, confidence interval and sample statistics.
402
+ """
403
+ a = _clean_float(a, "a")
404
+ b = _clean_float(b, "b")
405
+ N = _clean_int(N, "N", minimum=1, maximum=MAX_MC_N)
406
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
407
+ nivel_confianza = _clean_float(nivel_confianza, "nivel_confianza")
408
+ _require(0 < nivel_confianza < 1, "nivel_confianza must be in (0, 1).")
409
+ _require(b > a, "b must be greater than a.")
410
+ if seed is not None:
411
+ seed = _clean_int(seed, "seed", minimum=0, maximum=2**31 - 1)
412
+
413
+ normalized = compiler.validate(func_str, variables=("x",))
414
+ func = monte_carlo.MonteCarloService.compilar_funcion(normalized, variables="x")
415
+ result = monte_carlo.MonteCarloService.valor_promedio_1d(
416
+ func, a, b, N=N, seed=seed, precision=precision, nivel_confianza=nivel_confianza
417
+ )
418
+ return _finalize_deep(result)
419
+
420
+
421
+ @mcp.tool()
422
+ def mc_valor_promedio_2d(
423
+ func_str: str,
424
+ x_a: float,
425
+ x_b: float,
426
+ y_a: float,
427
+ y_b: float,
428
+ N: int = 10000,
429
+ seed: int | None = None,
430
+ precision: int = 8,
431
+ nivel_confianza: float = 0.95,
432
+ ) -> dict:
433
+ """Estimate the double integral of f(x, y) over [x_a,x_b]×[y_a,y_b] by mean value."""
434
+ x_a, x_b = _clean_float(x_a, "x_a"), _clean_float(x_b, "x_b")
435
+ y_a, y_b = _clean_float(y_a, "y_a"), _clean_float(y_b, "y_b")
436
+ N = _clean_int(N, "N", minimum=1, maximum=MAX_MC_N)
437
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
438
+ nivel_confianza = _clean_float(nivel_confianza, "nivel_confianza")
439
+ _require(0 < nivel_confianza < 1, "nivel_confianza must be in (0, 1).")
440
+ _require(x_b > x_a and y_b > y_a, "upper limits must be greater than lower limits.")
441
+ if seed is not None:
442
+ seed = _clean_int(seed, "seed", minimum=0, maximum=2**31 - 1)
443
+
444
+ normalized = compiler.validate(func_str, variables=("x", "y"))
445
+ func = monte_carlo.MonteCarloService.compilar_funcion(normalized, variables="x y")
446
+ result = monte_carlo.MonteCarloService.valor_promedio_2d(
447
+ func, (x_a, x_b), (y_a, y_b), N=N, seed=seed, precision=precision, nivel_confianza=nivel_confianza
448
+ )
449
+ return _finalize_deep(result)
450
+
451
+
452
+ @mcp.tool()
453
+ def mc_valor_promedio_3d(
454
+ func_str: str,
455
+ x_a: float,
456
+ x_b: float,
457
+ y_a: float,
458
+ y_b: float,
459
+ z_a: float,
460
+ z_b: float,
461
+ N: int = 10000,
462
+ seed: int | None = None,
463
+ precision: int = 8,
464
+ nivel_confianza: float = 0.95,
465
+ ) -> dict:
466
+ """Estimate the triple integral of f(x,y,z) over the box [x_a,x_b]×[y_a,y_b]×[z_a,z_b]."""
467
+ x_a, x_b = _clean_float(x_a, "x_a"), _clean_float(x_b, "x_b")
468
+ y_a, y_b = _clean_float(y_a, "y_a"), _clean_float(y_b, "y_b")
469
+ z_a, z_b = _clean_float(z_a, "z_a"), _clean_float(z_b, "z_b")
470
+ N = _clean_int(N, "N", minimum=1, maximum=MAX_MC_N)
471
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
472
+ nivel_confianza = _clean_float(nivel_confianza, "nivel_confianza")
473
+ _require(0 < nivel_confianza < 1, "nivel_confianza must be in (0, 1).")
474
+ _require(x_b > x_a and y_b > y_a and z_b > z_a, "upper limits must be greater than lower limits.")
475
+ if seed is not None:
476
+ seed = _clean_int(seed, "seed", minimum=0, maximum=2**31 - 1)
477
+
478
+ normalized = compiler.validate(func_str, variables=("x", "y", "z"))
479
+ func = monte_carlo.MonteCarloService.compilar_funcion(normalized, variables="x y z")
480
+ result = monte_carlo.MonteCarloService.valor_promedio_3d(
481
+ func,
482
+ (x_a, x_b),
483
+ (y_a, y_b),
484
+ (z_a, z_b),
485
+ N=N,
486
+ seed=seed,
487
+ precision=precision,
488
+ nivel_confianza=nivel_confianza,
489
+ )
490
+ return _finalize_deep(result)
491
+
492
+
493
+ @mcp.tool()
494
+ def mc_estadistico_1d(
495
+ func_str: str,
496
+ a: float,
497
+ b: float,
498
+ N: int = 1000,
499
+ M: int = 100,
500
+ nivel_confianza: float = 0.95,
501
+ seed: int | None = None,
502
+ precision: int = 8,
503
+ ) -> dict:
504
+ """Replicated Monte Carlo experiment (M repetitions of N samples) with statistical analysis.
505
+
506
+ Args:
507
+ func_str: Math expression in x.
508
+ a, b: Integration limits (b > a).
509
+ N: Samples per replication.
510
+ M: Number of replications.
511
+ nivel_confianza: Confidence level in (0, 1).
512
+ seed: Optional RNG seed.
513
+ precision: Rounding digits.
514
+
515
+ Returns:
516
+ Dict with the distribution of estimates and confidence intervals.
517
+ """
518
+ a = _clean_float(a, "a")
519
+ b = _clean_float(b, "b")
520
+ N = _clean_int(N, "N", minimum=2, maximum=MAX_MC_N // 10)
521
+ M = _clean_int(M, "M", minimum=2, maximum=MAX_MC_M)
522
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
523
+ nivel_confianza = _clean_float(nivel_confianza, "nivel_confianza")
524
+ _require(0 < nivel_confianza < 1, "nivel_confianza must be in (0, 1).")
525
+ _require(b > a, "b must be greater than a.")
526
+ if seed is not None:
527
+ seed = _clean_int(seed, "seed", minimum=0, maximum=2**31 - 1)
528
+
529
+ normalized = compiler.validate(func_str, variables=("x",))
530
+ func = monte_carlo.MonteCarloService.compilar_funcion(normalized, variables="x")
531
+ result = monte_carlo.MonteCarloService.analisis_estadistico_1d(
532
+ func, a, b, N=N, M=M, nivel_confianza=nivel_confianza, seed=seed, precision=precision
533
+ )
534
+ return _finalize_deep(result)
535
+
536
+
537
+ # --------------------------------------------------------------------------
538
+ # Dynamic 1D tools
539
+ # --------------------------------------------------------------------------
540
+ @mcp.tool()
541
+ def dynamic_1d_solve(
542
+ func_str: str = "x",
543
+ model: str = "custom",
544
+ params: dict[str, float] | None = None,
545
+ x_min: float = -1.0,
546
+ x_max: float = 3.0,
547
+ t_max: float = 10.0,
548
+ n_phase: int = 400,
549
+ n_time: int = 200,
550
+ initial_conditions: list[float] | None = None,
551
+ control_enabled: bool = False,
552
+ ) -> dict:
553
+ """Analyze the 1D autonomous system x' = f(x): equilibria, stability, phase portrait and time series.
554
+
555
+ Args:
556
+ func_str: f(x) used when model is 'custom'.
557
+ model: 'custom' | 'malthus' (r*x) | 'verhulst' (mu*x*(1-x/K)) | 'newton' (-k*(x-Ta)).
558
+ params: parameter values, e.g. {'r': 1.5} or {'mu': 1.0, 'K': 2.0}.
559
+ x_min, x_max: state window.
560
+ t_max: final time of the time series.
561
+ n_phase: resolution of the phase analysis.
562
+ n_time: number of time steps.
563
+ initial_conditions: starting states, e.g. [0.1, 1.0].
564
+ control_enabled: add a constant control term -h (verhulst/custom only).
565
+
566
+ Returns:
567
+ Dict with equilibria, their stability, the phase portrait data and time solutions.
568
+ """
569
+ params = params or {}
570
+ x_min = _clean_float(x_min, "x_min")
571
+ x_max = _clean_float(x_max, "x_max")
572
+ t_max = _clean_float(t_max, "t_max", positive=True)
573
+ n_phase = _clean_int(n_phase, "n_phase", minimum=10, maximum=MAX_DYN_GRID)
574
+ n_time = _clean_int(n_time, "n_time", minimum=2, maximum=MAX_DYN_GRID)
575
+ _require(x_max > x_min, "x_max must be greater than x_min.")
576
+ if initial_conditions is None:
577
+ initial_conditions = [0.5]
578
+
579
+ if model == "custom":
580
+ compiler.validate(func_str, variables=("x", *params.keys()))
581
+ elif model not in ("malthus", "verhulst", "newton"):
582
+ raise ValueError("model must be one of: custom, malthus, verhulst, newton.")
583
+
584
+ payload = {
585
+ "model": model,
586
+ "func_str": func_str,
587
+ "params": params,
588
+ "control_enabled": control_enabled,
589
+ "x_min": x_min,
590
+ "x_max": x_max,
591
+ "t_max": t_max,
592
+ "n_phase": n_phase,
593
+ "n_time": n_time,
594
+ "initial_conditions": initial_conditions,
595
+ }
596
+ result = dynamic_1d.Dynamic1DService.solve(payload)
597
+ return _finalize_deep(result)
598
+
599
+
600
+ @mcp.tool()
601
+ def dynamic_1d_equilibria(
602
+ func_str: str = "x",
603
+ model: str = "custom",
604
+ params: dict[str, float] | None = None,
605
+ x_min: float = -1.0,
606
+ x_max: float = 3.0,
607
+ n_phase: int = 400,
608
+ control_enabled: bool = False,
609
+ ) -> dict:
610
+ """Find the equilibria of x' = f(x) and classify their stability."""
611
+ params = params or {}
612
+ x_min = _clean_float(x_min, "x_min")
613
+ x_max = _clean_float(x_max, "x_max")
614
+ n_phase = _clean_int(n_phase, "n_phase", minimum=10, maximum=MAX_DYN_GRID)
615
+ _require(x_max > x_min, "x_max must be greater than x_min.")
616
+
617
+ if model == "custom":
618
+ compiler.validate(func_str, variables=("x", *params.keys()))
619
+ elif model not in ("malthus", "verhulst", "newton"):
620
+ raise ValueError("model must be one of: custom, malthus, verhulst, newton.")
621
+
622
+ payload = {
623
+ "model": model,
624
+ "func_str": func_str,
625
+ "params": params,
626
+ "control_enabled": control_enabled,
627
+ "x_min": x_min,
628
+ "x_max": x_max,
629
+ "n_phase": n_phase,
630
+ }
631
+ result = dynamic_1d.Dynamic1DService.solve(payload)
632
+ return _finalize_deep(result)
633
+
634
+
635
+ @mcp.tool()
636
+ def dynamic_1d_bifurcation(
637
+ func_str: str = "x",
638
+ model: str = "custom",
639
+ params: dict[str, float] | None = None,
640
+ bif_param: str = "r",
641
+ bif_min: float = -1.0,
642
+ bif_max: float = 1.0,
643
+ bif_steps: int = 60,
644
+ x_min: float = -1.0,
645
+ x_max: float = 3.0,
646
+ n_phase: int = 400,
647
+ phase_params: list[float] | None = None,
648
+ control_enabled: bool = False,
649
+ ) -> dict:
650
+ """Bifurcation analysis of x' = f(x; bif_param): equilibria vs parameter sweep.
651
+
652
+ Args:
653
+ func_str: f(x) used when model is 'custom'.
654
+ model: 'custom' | 'malthus' | 'verhulst' | 'newton'.
655
+ params: values of the other model parameters, e.g. {'K': 2.0}.
656
+ bif_param: name of the bifurcation parameter swept between bif_min and bif_max.
657
+ bif_min, bif_max, bif_steps: parameter sweep range/resolution.
658
+ x_min, x_max, n_phase: state window for finding equilibria.
659
+ phase_params: optional explicit parameter values for phase slices.
660
+ control_enabled: add a constant control term -h (verhulst/custom only).
661
+
662
+ Returns:
663
+ Dict with the equilibria-vs-parameter table, exact symbolic analysis and phase slices.
664
+ """
665
+ params = params or {}
666
+ bif_min = _clean_float(bif_min, "bif_min")
667
+ bif_max = _clean_float(bif_max, "bif_max")
668
+ bif_steps = _clean_int(bif_steps, "bif_steps", minimum=2, maximum=MAX_BIF_STEPS)
669
+ x_min = _clean_float(x_min, "x_min")
670
+ x_max = _clean_float(x_max, "x_max")
671
+ n_phase = _clean_int(n_phase, "n_phase", minimum=10, maximum=MAX_DYN_GRID)
672
+ _require(bif_max > bif_min, "bif_max must be greater than bif_min.")
673
+ _require(x_max > x_min, "x_max must be greater than x_min.")
674
+
675
+ if model == "custom":
676
+ compiler.validate(func_str, variables=("x", bif_param, *params.keys()))
677
+ elif model not in ("malthus", "verhulst", "newton"):
678
+ raise ValueError("model must be one of: custom, malthus, verhulst, newton.")
679
+ _require(
680
+ bool(bif_param.strip()) and bif_param.strip().isidentifier(),
681
+ "bif_param must be a valid symbol name.",
682
+ )
683
+
684
+ payload = {
685
+ "model": model,
686
+ "func_str": func_str,
687
+ "params": params,
688
+ "control_enabled": control_enabled,
689
+ "bif_param": bif_param,
690
+ "bif_min": bif_min,
691
+ "bif_max": bif_max,
692
+ "bif_steps": bif_steps,
693
+ "x_min": x_min,
694
+ "x_max": x_max,
695
+ "n_phase": n_phase,
696
+ "phase_params": phase_params,
697
+ }
698
+ result = dynamic_1d.Dynamic1DService.bifurcation(payload)
699
+ result = _finalize_deep(result)
700
+ # The equilibria-vs-parameter table can be huge; keep it digestible for the LLM.
701
+ bif = result.get("bifurcation")
702
+ if isinstance(bif, dict):
703
+ bif["equilibria"] = _downsample(bif.get("equilibria") or [], 300)
704
+ return result
705
+
706
+
707
+ # --------------------------------------------------------------------------
708
+ # Dynamic 2D tools
709
+ # --------------------------------------------------------------------------
710
+ def _clean_dynamic2d_inputs(x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, traj):
711
+ x0 = _clean_float(x0, "x0")
712
+ y0 = _clean_float(y0, "y0")
713
+ t0 = _clean_float(t0, "t0")
714
+ t_fin = _clean_float(t_fin, "t_fin")
715
+ h = _clean_float(h, "h", positive=True)
716
+ x_min = _clean_float(x_min, "x_min")
717
+ x_max = _clean_float(x_max, "x_max")
718
+ y_min = _clean_float(y_min, "y_min")
719
+ y_max = _clean_float(y_max, "y_max")
720
+ traj = _clean_int(traj, "cantidad_trayectorias", minimum=1, maximum=MAX_TRAJECTORIES)
721
+ _require(t_fin > t0, "t_fin must be greater than t0.")
722
+ _require(x_max > x_min and y_max > y_min, "max limits must be greater than min limits.")
723
+ steps = round((t_fin - t0) / h)
724
+ _require(1 <= steps <= MAX_DYN_STEPS, "step count must be between 1 and 5000.")
725
+ return x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, traj
726
+
727
+
728
+ @mcp.tool()
729
+ def dynamic_2d_linear_solve(
730
+ a: float = 3.0,
731
+ b: float = 1.0,
732
+ c: float = 1.0,
733
+ d: float = 3.0,
734
+ e: float = 0.0,
735
+ f: float = 0.0,
736
+ x0: float = 1.0,
737
+ y0: float = 1.0,
738
+ t0: float = 0.0,
739
+ t_fin: float = 10.0,
740
+ h: float = 0.01,
741
+ x_min: float = -5.0,
742
+ x_max: float = 5.0,
743
+ y_min: float = -5.0,
744
+ y_max: float = 5.0,
745
+ cantidad_trayectorias: int = 16,
746
+ ) -> dict:
747
+ """Solve and classify the linear 2D system X' = A·X + B with constant matrix A.
748
+
749
+ Args:
750
+ a, b, c, d: entries of A = [[a, b], [c, d]].
751
+ e, f: constant forcing vector B = [e, f].
752
+ x0, y0, t0, t_fin, h: initial condition and integration grid.
753
+ x_min..y_max: window for the phase portrait.
754
+ cantidad_trayectorias: number of sample trajectories.
755
+
756
+ Returns:
757
+ Dict with the classification, eigenvalues, nullclines, analytic solution and trajectories.
758
+ """
759
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, traj = _clean_dynamic2d_inputs(
760
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, cantidad_trayectorias
761
+ )
762
+ payload = {
763
+ "a": a, "b": b, "c": c, "d": d, "e": e, "f": f,
764
+ "x0": x0, "y0": y0, "t0": t0, "t_fin": t_fin, "h": h,
765
+ "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max,
766
+ "cantidad_trayectorias": traj,
767
+ }
768
+ result = dynamic_2d_linear.Dynamic2DLinearService.solve(payload)
769
+ return _finalize_deep(result)
770
+
771
+
772
+ @mcp.tool()
773
+ def dynamic_2d_nonlinear_solve(
774
+ eq_x: str = "y - x",
775
+ eq_y: str = "x**2 - 1",
776
+ params: dict[str, float] | None = None,
777
+ mu: float = 0.0,
778
+ x0: float = 0.5,
779
+ y0: float = 0.5,
780
+ t0: float = 0.0,
781
+ t_fin: float = 10.0,
782
+ h: float = 0.02,
783
+ x_min: float = -3.0,
784
+ x_max: float = 3.0,
785
+ y_min: float = -3.0,
786
+ y_max: float = 3.0,
787
+ cantidad_trayectorias: int = 25,
788
+ ) -> dict:
789
+ """Solve and analyze the nonlinear 2D system x' = f(x,y), y' = g(x,y).
790
+
791
+ Args:
792
+ eq_x, eq_y: Math expressions for f and g (variables x, y, mu and extra params).
793
+ params: extra parameter values (e.g. {'alpha': 0.5}).
794
+ mu: bifurcation parameter value.
795
+ x0..y_max, cantidad_trayectorias: integration and portrait settings.
796
+
797
+ Returns:
798
+ Dict with equilibria, Jacobian-based classification, nullclines and trajectories.
799
+ """
800
+ params = params or {}
801
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, traj = _clean_dynamic2d_inputs(
802
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, cantidad_trayectorias
803
+ )
804
+ gate = ["x", "y", "mu", *list(params.keys())]
805
+ compiler.validate(eq_x, variables=tuple(dict.fromkeys(gate)))
806
+ compiler.validate(eq_y, variables=tuple(dict.fromkeys(gate)))
807
+ payload = {
808
+ "eq_x": eq_x, "eq_y": eq_y, "params": params, "mu": mu,
809
+ "x0": x0, "y0": y0, "t0": t0, "t_fin": t_fin, "h": h,
810
+ "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max,
811
+ "cantidad_trayectorias": traj,
812
+ }
813
+ result = dynamic_2d_nonlinear.Dynamic2DNonLinearService.solve(payload)
814
+ return _finalize_deep(result)
815
+
816
+
817
+ @mcp.tool()
818
+ def dynamic_2d_conservative_solve(
819
+ eq_x: str = "y",
820
+ eq_y: str = "x - x**3",
821
+ mu: float = 0.0,
822
+ x0: float = 0.1,
823
+ y0: float = 0.0,
824
+ t0: float = 0.0,
825
+ t_fin: float = 15.0,
826
+ h: float = 0.02,
827
+ x_min: float = -2.5,
828
+ x_max: float = 2.5,
829
+ y_min: float = -2.5,
830
+ y_max: float = 2.5,
831
+ cantidad_trayectorias: int = 25,
832
+ ) -> dict:
833
+ """Analyze a 2D system checking conservativeness (divergence-free), Hamiltonian/energy and closed orbits.
834
+
835
+ Args:
836
+ eq_x, eq_y: Math expressions for x' and y'.
837
+ mu: parameter value.
838
+ x0..y_max, cantidad_trayectorias: integration and portrait settings.
839
+
840
+ Returns:
841
+ Dict with the divergence check, the Hamiltonian reconstruction, the
842
+ equilibrium classification and the trajectories.
843
+ """
844
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, traj = _clean_dynamic2d_inputs(
845
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, cantidad_trayectorias
846
+ )
847
+ compiler.validate(eq_x, variables=("x", "y", "mu"))
848
+ compiler.validate(eq_y, variables=("x", "y", "mu"))
849
+ payload = {
850
+ "eq_x": eq_x, "eq_y": eq_y, "mu": mu,
851
+ "x0": x0, "y0": y0, "t0": t0, "t_fin": t_fin, "h": h,
852
+ "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max,
853
+ "cantidad_trayectorias": traj,
854
+ }
855
+ result = dynamic_2d_conservative.Dynamic2DConservativeService.solve(payload)
856
+ return _finalize_deep(result)
857
+
858
+
859
+ @mcp.tool()
860
+ def dynamic_2d_lanchester_solve(
861
+ eq_x: str = "-α * y",
862
+ eq_y: str = "-β * x",
863
+ alpha: float = 1.0,
864
+ beta: float = 2.0,
865
+ gamma: float = 0.0,
866
+ epsilon: float = 0.0,
867
+ mu: float = 0.0,
868
+ delta: float = 0.0,
869
+ x0: float = 100.0,
870
+ y0: float = 80.0,
871
+ t0: float = 0.0,
872
+ t_fin: float = 5.0,
873
+ h: float = 0.01,
874
+ ) -> dict:
875
+ """Simulate a Lanchester combat model x' = f(x,y), y' = g(x,y) with analytic time-to-annihilation.
876
+
877
+ Args:
878
+ eq_x, eq_y: Math expressions (classic default is x'=-αy, y'=-βx).
879
+ alpha, beta, gamma, epsilon, mu, delta: Greek parameter values.
880
+ x0, y0: initial force sizes.
881
+ t0, t_fin, h: simulation grid.
882
+
883
+ Returns:
884
+ Dict with the analytic winner/survivors, state equation and the numerical trajectories.
885
+ """
886
+ alpha = _clean_float(alpha, "alpha")
887
+ beta = _clean_float(beta, "beta")
888
+ gamma = _clean_float(gamma, "gamma")
889
+ epsilon = _clean_float(epsilon, "epsilon")
890
+ mu = _clean_float(mu, "mu")
891
+ delta = _clean_float(delta, "delta")
892
+ x0 = _clean_float(x0, "x0")
893
+ y0 = _clean_float(y0, "y0")
894
+ t0 = _clean_float(t0, "t0")
895
+ t_fin = _clean_float(t_fin, "t_fin")
896
+ h = _clean_float(h, "h", positive=True)
897
+ _require(t_fin > t0, "t_fin must be greater than t0.")
898
+ steps = round((t_fin - t0) / h)
899
+ _require(1 <= steps <= MAX_DYN_STEPS, "step count must be between 1 and 5000.")
900
+
901
+ # Aceptar tanto los símbolos griegos (α) como sus nombres latinos (alpha).
902
+ _GREEK_NAMES = {
903
+ "alpha": "α", "beta": "β", "gamma": "γ",
904
+ "epsilon": "ε", "mu": "μ", "delta": "δ",
905
+ }
906
+ for latin, greek in _GREEK_NAMES.items():
907
+ eq_x = re.sub(rf"\b{latin}\b", greek, eq_x)
908
+ eq_y = re.sub(rf"\b{latin}\b", greek, eq_y)
909
+
910
+ gate = ("x", "y", "α", "β", "γ", "ε", "μ", "δ")
911
+ compiler.validate(eq_x, variables=gate)
912
+ compiler.validate(eq_y, variables=gate)
913
+ payload = {
914
+ "eq_x": eq_x, "eq_y": eq_y,
915
+ "alpha": alpha, "beta": beta, "gamma": gamma, "epsilon": epsilon, "mu": mu, "delta": delta,
916
+ "x0": x0, "y0": y0, "t0": t0, "t_fin": t_fin, "h": h,
917
+ }
918
+ result = dynamic_2d_lanchester.Dynamic2DLanchesterService.solve(payload)
919
+ return _finalize_deep(result)
920
+
921
+
922
+ @mcp.tool()
923
+ def dynamic_2d_nonhomogeneous_solve(
924
+ a: float = 0.0,
925
+ b: float = -1.0,
926
+ c: float = -9.0,
927
+ d: float = 0.0,
928
+ e: str | float = 1.0,
929
+ f: str | float = 9.0,
930
+ x0: float = 2.0,
931
+ y0: float = 2.0,
932
+ t0: float = 0.0,
933
+ t_fin: float = 5.0,
934
+ h: float = 0.01,
935
+ x_min: float = -5.0,
936
+ x_max: float = 5.0,
937
+ y_min: float = -5.0,
938
+ y_max: float = 5.0,
939
+ cantidad_trayectorias: int = 16,
940
+ ) -> dict:
941
+ """Solve the non-homogeneous 2D system X' = A·X + B(t) with constant or time-varying forcing.
942
+
943
+ Args:
944
+ a, b, c, d: entries of A = [[a, b], [c, d]].
945
+ e, f: forcing components B(t); a number or an expression in t (e.g. "sin(2*t)").
946
+ x0, y0, t0, t_fin, h: integration settings.
947
+ x_min..y_max, cantidad_trayectorias: portrait settings.
948
+
949
+ Returns:
950
+ Dict with the classification, equilibrium/particular solution, analytic solution and trajectories.
951
+ """
952
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, traj = _clean_dynamic2d_inputs(
953
+ x0, y0, t0, t_fin, h, x_min, x_max, y_min, y_max, cantidad_trayectorias
954
+ )
955
+
956
+ def _forcing(value, name):
957
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
958
+ return float(value)
959
+ text = str(value)
960
+ normalized = compiler.validate(text, variables=("t",))
961
+ return normalized
962
+
963
+ e_val = _forcing(e, "e")
964
+ f_val = _forcing(f, "f")
965
+
966
+ payload = {
967
+ "a": a, "b": b, "c": c, "d": d, "e": e_val, "f": f_val,
968
+ "x0": x0, "y0": y0, "t0": t0, "t_fin": t_fin, "h": h,
969
+ "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max,
970
+ "cantidad_trayectorias": traj,
971
+ }
972
+ result = dynamic_2d_non_homogeneous.Dynamic2DNonHomogeneousService.solve(payload)
973
+ return _finalize_deep(result)
974
+
975
+
976
+ # --------------------------------------------------------------------------
977
+ # Root finding (extended)
978
+ # --------------------------------------------------------------------------
979
+ def _compile_root(func_str: str) -> Callable:
980
+ normalized = compiler.validate(func_str, variables=("x",))
981
+ return root_finding.RootFindingService.compilar_funcion(normalized)
982
+
983
+
984
+ @mcp.tool()
985
+ def root_newton_raphson(
986
+ func_str: str,
987
+ x0: float,
988
+ tol: float = 1e-6,
989
+ max_iter: int = 100,
990
+ precision: int = 8,
991
+ ) -> dict:
992
+ """Find a root of f(x) = 0 with the Newton-Raphson method (numeric derivative).
993
+
994
+ Args:
995
+ func_str: Math expression in x.
996
+ x0: Initial guess.
997
+ tol: Convergence tolerance.
998
+ max_iter: Maximum iterations.
999
+ precision: Rounding digits.
1000
+
1001
+ Returns:
1002
+ Dict with the root, iteration table and convergence flag.
1003
+ """
1004
+ x0 = _clean_float(x0, "x0")
1005
+ tol = _clean_float(tol, "tol", positive=True)
1006
+ max_iter = _clean_int(max_iter, "max_iter", minimum=1, maximum=MAX_ITERATIONS)
1007
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1008
+
1009
+ func = _compile_root(func_str)
1010
+ result = root_finding.RootFindingService.newton_raphson(
1011
+ func, x0, tol=tol, max_iter=max_iter, precision=precision
1012
+ )
1013
+ return _finalize(result)
1014
+
1015
+
1016
+ @mcp.tool()
1017
+ def root_punto_fijo(
1018
+ g_str: str,
1019
+ x0: float,
1020
+ tol: float = 1e-6,
1021
+ max_iter: int = 100,
1022
+ precision: int = 8,
1023
+ ) -> dict:
1024
+ """Find a fixed point of g(x) = x with the fixed-point iteration method.
1025
+
1026
+ Converges when |g'(x)| < 1 near the root (Lipschitz check is reported).
1027
+
1028
+ Args:
1029
+ g_str: Math expression for the iteration function g(x).
1030
+ x0: Initial guess.
1031
+ tol: Convergence tolerance.
1032
+ max_iter: Maximum iterations.
1033
+ precision: Rounding digits.
1034
+
1035
+ Returns:
1036
+ Dict with the fixed point, the Lipschitz check, iteration table and convergence flag.
1037
+ """
1038
+ x0 = _clean_float(x0, "x0")
1039
+ tol = _clean_float(tol, "tol", positive=True)
1040
+ max_iter = _clean_int(max_iter, "max_iter", minimum=1, maximum=MAX_ITERATIONS)
1041
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1042
+
1043
+ func = _compile_root(g_str)
1044
+ result = root_finding.RootFindingService.punto_fijo(
1045
+ func, x0, tol=tol, max_iter=max_iter, precision=precision
1046
+ )
1047
+ return _finalize(result)
1048
+
1049
+
1050
+ @mcp.tool()
1051
+ def root_aitken(
1052
+ g_str: str,
1053
+ x0: float,
1054
+ tol: float = 1e-6,
1055
+ max_iter: int = 100,
1056
+ precision: int = 8,
1057
+ ) -> dict:
1058
+ """Accelerate fixed-point iteration with Aitken's delta-squared method.
1059
+
1060
+ Args:
1061
+ g_str: Math expression for the iteration function g(x).
1062
+ x0: Initial guess.
1063
+ tol: Convergence tolerance.
1064
+ max_iter: Maximum iterations.
1065
+ precision: Rounding digits.
1066
+
1067
+ Returns:
1068
+ Dict with the accelerated root, iteration table and convergence flag.
1069
+ """
1070
+ x0 = _clean_float(x0, "x0")
1071
+ tol = _clean_float(tol, "tol", positive=True)
1072
+ max_iter = _clean_int(max_iter, "max_iter", minimum=1, maximum=MAX_ITERATIONS)
1073
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1074
+
1075
+ func = _compile_root(g_str)
1076
+ result = root_finding.RootFindingService.aitken(
1077
+ func, x0, tol=tol, max_iter=max_iter, precision=precision
1078
+ )
1079
+ return _finalize(result)
1080
+
1081
+
1082
+ @mcp.tool()
1083
+ def root_comparar(
1084
+ func_str: str,
1085
+ g_str: str,
1086
+ a: float,
1087
+ b: float,
1088
+ x0: float,
1089
+ tol: float = 1e-6,
1090
+ max_iter: int = 100,
1091
+ precision: int = 8,
1092
+ ) -> dict:
1093
+ """Compare bisection, fixed-point, Newton-Raphson and Aitken on the same problem.
1094
+
1095
+ Args:
1096
+ func_str: Math expression for f(x) (used by bisection/Newton).
1097
+ g_str: Math expression for g(x) (used by fixed-point/Aitken).
1098
+ a, b: Bracket for bisection (f(a) and f(b) must differ in sign).
1099
+ x0: Initial guess.
1100
+ tol, max_iter, precision: Shared tolerances.
1101
+
1102
+ Returns:
1103
+ Dict with per-method results.
1104
+ """
1105
+ a = _clean_float(a, "a")
1106
+ b = _clean_float(b, "b")
1107
+ x0 = _clean_float(x0, "x0")
1108
+ tol = _clean_float(tol, "tol", positive=True)
1109
+ max_iter = _clean_int(max_iter, "max_iter", minimum=1, maximum=MAX_ITERATIONS)
1110
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1111
+ _require(b > a, "b must be greater than a.")
1112
+
1113
+ compiler.validate(func_str, variables=("x",))
1114
+ compiler.validate(g_str, variables=("x",))
1115
+ result = root_finding.RootFindingService.comparar_metodos(
1116
+ func_str, g_str, a, b, x0, tol=tol, max_iter=max_iter, precision=precision
1117
+ )
1118
+ return _finalize(result)
1119
+
1120
+
1121
+ # --------------------------------------------------------------------------
1122
+ # Integration (extended)
1123
+ # --------------------------------------------------------------------------
1124
+ def _compile_integrand(func_str: str) -> Callable:
1125
+ normalized = compiler.validate(func_str, variables=("x",))
1126
+ return integration.IntegrationService.compilar_funcion(normalized)
1127
+
1128
+
1129
+ def _integral_bounds(a: float, b: float, n: int, precision: int, epsilon):
1130
+ a = _clean_float(a, "a")
1131
+ b = _clean_float(b, "b")
1132
+ n = _clean_int(n, "n", minimum=1, maximum=MAX_SUBINTERVALS)
1133
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1134
+ _require(b > a, "b must be greater than a.")
1135
+ if epsilon is not None:
1136
+ epsilon = _clean_float(epsilon, "epsilon")
1137
+ return a, b, n, precision, epsilon
1138
+
1139
+
1140
+ @mcp.tool()
1141
+ def integral_rectangulo(
1142
+ func_str: str,
1143
+ a: float,
1144
+ b: float,
1145
+ n: int,
1146
+ epsilon: float | None = None,
1147
+ precision: int = 8,
1148
+ ) -> dict:
1149
+ """Approximate ∫ₐᵇ f(x) dx with the composite midpoint (rectangle) rule.
1150
+
1151
+ Args:
1152
+ func_str: Math expression in x.
1153
+ a, b: Integration limits (b > a).
1154
+ n: Number of subintervals.
1155
+ epsilon: Optional point in [a, b] for the truncation error bound.
1156
+ precision: Rounding digits.
1157
+
1158
+ Returns:
1159
+ Dict with the integral, error estimates and table.
1160
+ """
1161
+ a, b, n, precision, epsilon = _integral_bounds(a, b, n, precision, epsilon)
1162
+ func = _compile_integrand(func_str)
1163
+ result = integration.IntegrationService.rectangulo_compuesto(
1164
+ func, a, b, n=n, precision=precision, epsilon=epsilon
1165
+ )
1166
+ return _finalize(result)
1167
+
1168
+
1169
+ @mcp.tool()
1170
+ def integral_trapecio(
1171
+ func_str: str,
1172
+ a: float,
1173
+ b: float,
1174
+ n: int,
1175
+ epsilon: float | None = None,
1176
+ precision: int = 8,
1177
+ ) -> dict:
1178
+ """Approximate ∫ₐᵇ f(x) dx with the composite trapezoidal rule.
1179
+
1180
+ Args:
1181
+ func_str: Math expression in x.
1182
+ a, b: Integration limits (b > a).
1183
+ n: Number of subintervals.
1184
+ epsilon: Optional point in [a, b] for the truncation error bound.
1185
+ precision: Rounding digits.
1186
+
1187
+ Returns:
1188
+ Dict with the integral, error estimates and table.
1189
+ """
1190
+ a, b, n, precision, epsilon = _integral_bounds(a, b, n, precision, epsilon)
1191
+ func = _compile_integrand(func_str)
1192
+ result = integration.IntegrationService.trapecio_compuesto(
1193
+ func, a, b, n=n, precision=precision, epsilon=epsilon
1194
+ )
1195
+ return _finalize(result)
1196
+
1197
+
1198
+ @mcp.tool()
1199
+ def integral_simpson38(
1200
+ func_str: str,
1201
+ a: float,
1202
+ b: float,
1203
+ n: int,
1204
+ epsilon: float | None = None,
1205
+ precision: int = 8,
1206
+ ) -> dict:
1207
+ """Approximate ∫ₐᵇ f(x) dx with the composite Simpson 3/8 rule.
1208
+
1209
+ Args:
1210
+ func_str: Math expression in x.
1211
+ a, b: Integration limits (b > a).
1212
+ n: Number of subintervals (must be a multiple of 3).
1213
+ epsilon: Optional point in [a, b] for the truncation error bound.
1214
+ precision: Rounding digits.
1215
+
1216
+ Returns:
1217
+ Dict with the integral, error estimates and table.
1218
+ """
1219
+ a, b, n, precision, epsilon = _integral_bounds(a, b, n, precision, epsilon)
1220
+ _require(n % 3 == 0, "n must be a multiple of 3 for Simpson 3/8.")
1221
+ func = _compile_integrand(func_str)
1222
+ result = integration.IntegrationService.simpson_38_compuesto(
1223
+ func, a, b, n=n, precision=precision, epsilon=epsilon
1224
+ )
1225
+ return _finalize(result)
1226
+
1227
+
1228
+ @mcp.tool()
1229
+ def integral_comparar(
1230
+ func_str: str,
1231
+ a: float,
1232
+ b: float,
1233
+ n: int,
1234
+ epsilon: float | None = None,
1235
+ precision: int = 8,
1236
+ ) -> dict:
1237
+ """Compare rectangle, trapezoid, Simpson 1/3 and Simpson 3/8 on the same integral.
1238
+
1239
+ Args:
1240
+ func_str: Math expression in x.
1241
+ a, b: Integration limits (b > a).
1242
+ n: Number of subintervals (Simpson 1/3 needs even, 3/8 needs multiple of 3).
1243
+ epsilon, precision: Error-bound and rounding settings.
1244
+
1245
+ Returns:
1246
+ Dict with per-method results and success flags.
1247
+ """
1248
+ a, b, n, precision, epsilon = _integral_bounds(a, b, n, precision, epsilon)
1249
+ compiler.validate(func_str, variables=("x",))
1250
+ result = integration.IntegrationService.comparar_metodos(
1251
+ func_str, a, b, n=n, precision=precision, epsilon=epsilon
1252
+ )
1253
+ return _finalize(result)
1254
+
1255
+
1256
+ # --------------------------------------------------------------------------
1257
+ # ODE (Euler / Heun) and Monte Carlo convergence
1258
+ # --------------------------------------------------------------------------
1259
+ def _ode_solve(metodo: str, ecuacion_str, x0, y0, xf, h, tol, precision) -> dict:
1260
+ x0 = _clean_float(x0, "x0")
1261
+ y0 = _clean_float(y0, "y0")
1262
+ xf = _clean_float(xf, "xf")
1263
+ h = _clean_float(h, "h", positive=True)
1264
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1265
+ _require(xf > x0, "xf must be greater than x0.")
1266
+ if tol is not None:
1267
+ tol = _clean_float(tol, "tol", positive=True)
1268
+
1269
+ n_steps = round((xf - x0) / h)
1270
+ _require(1 <= n_steps <= MAX_ODE_STEPS, "step count must be between 1 and 2000.")
1271
+
1272
+ normalized = compiler.validate(ecuacion_str, variables=("x", "y"))
1273
+ result = ode.ODEService.ejecutar_metodo(
1274
+ metodo, normalized, x0, y0, xf, h, precision=precision, tol=tol
1275
+ )
1276
+ return _finalize(result, downsample_keys=("x_plot", "y_plot", "y_exacta_plot"))
1277
+
1278
+
1279
+ @mcp.tool()
1280
+ def ode_euler(
1281
+ ecuacion_str: str,
1282
+ x0: float,
1283
+ y0: float,
1284
+ xf: float,
1285
+ h: float,
1286
+ tol: float | None = None,
1287
+ precision: int = 8,
1288
+ ) -> dict:
1289
+ """Solve y'(x) = f(x, y), y(x0) = y0 with the explicit Euler method (1st order).
1290
+
1291
+ Args:
1292
+ ecuacion_str: Right-hand side f(x, y).
1293
+ x0, y0: Initial condition.
1294
+ xf: Final x (must be > x0).
1295
+ h: Step size (must be positive).
1296
+ tol: Optional tolerance flag for the final error.
1297
+ precision: Rounding digits.
1298
+
1299
+ Returns:
1300
+ Dict with the numerical solution, exact solution (when available) and errors.
1301
+ """
1302
+ return _ode_solve("euler", ecuacion_str, x0, y0, xf, h, tol, precision)
1303
+
1304
+
1305
+ @mcp.tool()
1306
+ def ode_heun(
1307
+ ecuacion_str: str,
1308
+ x0: float,
1309
+ y0: float,
1310
+ xf: float,
1311
+ h: float,
1312
+ tol: float | None = None,
1313
+ precision: int = 8,
1314
+ ) -> dict:
1315
+ """Solve y'(x) = f(x, y), y(x0) = y0 with Heun's predictor-corrector method (2nd order).
1316
+
1317
+ Args:
1318
+ ecuacion_str: Right-hand side f(x, y).
1319
+ x0, y0: Initial condition.
1320
+ xf: Final x (must be > x0).
1321
+ h: Step size (must be positive).
1322
+ tol: Optional tolerance flag for the final error.
1323
+ precision: Rounding digits.
1324
+
1325
+ Returns:
1326
+ Dict with the numerical solution, exact solution (when available) and errors.
1327
+ """
1328
+ return _ode_solve("heun", ecuacion_str, x0, y0, xf, h, tol, precision)
1329
+
1330
+
1331
+ @mcp.tool()
1332
+ def mc_convergencia_1d(
1333
+ func_str: str,
1334
+ a: float,
1335
+ b: float,
1336
+ N: int = 10000,
1337
+ seed: int | None = None,
1338
+ precision: int = 8,
1339
+ ) -> dict:
1340
+ """Show how the mean-value Monte Carlo estimate converges as samples accumulate.
1341
+
1342
+ Args:
1343
+ func_str: Math expression in x.
1344
+ a, b: Integration limits (b > a).
1345
+ N: Total number of samples.
1346
+ seed: Optional RNG seed.
1347
+ precision: Rounding digits.
1348
+
1349
+ Returns:
1350
+ Dict with the cumulative running average over the samples.
1351
+ """
1352
+ a = _clean_float(a, "a")
1353
+ b = _clean_float(b, "b")
1354
+ N = _clean_int(N, "N", minimum=1, maximum=MAX_MC_N)
1355
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1356
+ _require(b > a, "b must be greater than a.")
1357
+ if seed is not None:
1358
+ seed = _clean_int(seed, "seed", minimum=0, maximum=2**31 - 1)
1359
+
1360
+ normalized = compiler.validate(func_str, variables=("x",))
1361
+ func = monte_carlo.MonteCarloService.compilar_funcion(normalized, variables="x")
1362
+ result = monte_carlo.MonteCarloService.convergencia_1d(
1363
+ func, a, b, N=N, seed=seed, precision=precision
1364
+ )
1365
+ return _finalize_deep(result)
1366
+
1367
+
1368
+ # --------------------------------------------------------------------------
1369
+ # Differentiation
1370
+ # --------------------------------------------------------------------------
1371
+ @mcp.tool()
1372
+ def finite_differences(
1373
+ func_str: str,
1374
+ x_val: float,
1375
+ h: float = 1e-5,
1376
+ precision: int = 8,
1377
+ ) -> dict:
1378
+ """Approximate the first and second derivatives of f(x) at x with finite differences.
1379
+
1380
+ Computes forward, backward and central differences for the first derivative,
1381
+ plus the central second derivative, and compares each against the exact
1382
+ derivative from SymPy.
1383
+
1384
+ Args:
1385
+ func_str: Math expression in x.
1386
+ x_val: Point where the derivative is evaluated.
1387
+ h: Step size (small, positive).
1388
+ precision: Rounding digits.
1389
+
1390
+ Returns:
1391
+ Dict with the exact and numerical derivatives and their errors.
1392
+ """
1393
+ x_val = _clean_float(x_val, "x_val")
1394
+ h = _clean_float(h, "h", positive=True)
1395
+ precision = _clean_int(precision, "precision", minimum=0, maximum=MAX_PRECISION)
1396
+ _require(h < 1.0, "h should be small (typically <= 1e-3).")
1397
+
1398
+ normalized = compiler.validate(func_str, variables=("x",))
1399
+ result = differentiation.DifferentiationService.calcular_diferencias_completas(
1400
+ normalized, x_val=x_val, h=h, precision=precision
1401
+ )
1402
+ return _finalize(result)
1403
+
1404
+
1405
+ def run() -> None:
1406
+ """Run the MCP server with the transport from ``MCP_TRANSPORT`` (default stdio)."""
1407
+ transport = os.environ.get("MCP_TRANSPORT", "stdio").lower()
1408
+ if transport not in ("stdio", "streamable-http", "sse"):
1409
+ transport = "stdio"
1410
+ mcp.run(transport=transport)
1411
+
1412
+
1413
+ if __name__ == "__main__":
1414
+ run()