Back to Discover

sagemath-mcp

connector

XBP-Europe

Stateful SageMath MCP server with 33 symbolic math tools and a sandboxed Sage session per client.

View on GitHub
0 starsSynced Aug 17, 2026

Install to Claude Code

/plugin marketplace add XBP-Europe/sagemath-mcp

README

SageMath MCP Server

CI Release PyPI GHCR License Python MCP FastMCP SageMath Ruff Typed Coverage Downloads MCP Registry Signed Dependabot Last commit

A universal mathematics Model Context Protocol (MCP) server that gives LLM clients full access to SageMath --- one of the most comprehensive open-source mathematics systems available. Built on FastMCP 3.x, the server maintains a dedicated SageMath process for each MCP session so variables, functions, and assumptions persist across tool calls.

Whether the task is symbolic calculus, number theory, linear algebra, differential equations, plotting, combinatorics, graph theory, group theory, or basic arithmetic, the server provides 37 MCP tools --- all math tools backed by the full SageMath engine, plus evaluate_sage_streaming (streaming wrapper) and an HTTP /health endpoint.


Table of Contents


Features at a Glance

CategoryToolsBackendCapabilities
Core executionevaluate_sage, evaluate_sage_streamingSageRun any SageMath code with persistent state, LaTeX output, stdout capture, progress heartbeats, per-call timeouts, and line-by-line streaming
Calculusdifferentiate_expression, integrate_expression, limit_expression, series_expansionSageDerivatives of any order, indefinite & definite integrals, one-sided limits, Taylor/Laurent series
Algebrasolve_equation, simplify_expression, expand_expression, factor_expression, calculate_expressionSageSingle equations & systems, symbolic simplification, expansion, factoring, numeric evaluation
Symbolic sumssymbolic_sumSageSymbolic summation and products (finite and infinite series)
Linear algebramatrix_multiply, matrix_operationSageMatrix products, determinants, inverses, eigenvalues, rank, RREF, transpose
Differential equationssolve_odeSageFirst- and higher-order ODEs via Sage's desolve()
Number theorynumber_theory_operationSagePrimality testing, integer factorization, next prime, GCD, LCM
Combinatoricscombinatorics_operationSageBinomial, permutations, combinations, partitions, factorial, Catalan, Fibonacci, Bell numbers
Graph theorygraph_operationSageNamed graphs including parameterised constructors (CompleteGraph(4)) and adjacency dicts; chromatic number, connectivity, planarity, diameter, shortest path
Group theorygroup_operationSageSymmetric, dihedral, cyclic, alternating groups; order, abelian/cyclic test, center, exponent
Elliptic curveselliptic_curve_operationSageRank, torsion, discriminant, j-invariant, conductor, generators
Coding theorycoding_theory_operationSageHamming and generalized Reed-Solomon codes; length, dimension, minimum distance, generator matrix, rate
Polynomial ringspolynomial_ring_operationSageGroebner bases, ideal dimension/variety, reduction, Groebner test
Boolean algebraboolean_algebra_operationSageBoolean polynomial ring, addressed as x, y, z or x0, x1, x2; evaluate, variables, degree, zero/one test
Geometrygeometry_operationSageDistance, polygon area, polytope volume, convex hull, compactness via Polyhedron
Statisticsstatistics_summarySageMean, median, population & sample variance/std dev, min, max
Probabilitydistribution_operationSageNormal, exponential, Poisson, chi-squared, Student-t, uniform, beta, gamma; PDF, CDF, quantile, analytic mean/variance, sampling
Visualizationplot_expression, plot3d_expression, plot_multi_expressionSage2D plots, 3D surface plots, multi-function overlays as base64-encoded PNG
Numeric methodsfind_rootSageNumeric root-finding in an interval via Sage's find_root(), from an expression or an equation
Vector calculusvector_calculus_operationSageGradient, divergence, curl, Laplacian on scalar/vector fields
Session controlreset_sage_session, interrupt_sage_session, cancel_sage_sessionWorkerClear state, or stop a computation with or without keeping variables
Named workspacesstart_sage_session, list_sage_sessions, stop_sage_sessionWorkerSeveral independent variable namespaces per client
Infrastructure/health endpoint, 3 MCP resourcesServerHealth check, session snapshots, aggregated metrics, documentation links

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│  MCP Client (Claude Desktop, Gemini CLI, Codex CLI, etc.)       │
└─────────────────────────────────────────────────────────────────┘
                      │  MCP protocol (stdio or HTTP)
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  app.py + tools/ --- FastMCP 3.x Application                    │
│                                                                 │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────────────┐  │
│  │ 37 MCP Tools│  │ 3 Resources  │  │ Middleware             │  │
│  │ (evaluate,  │  │ (session,    │  │ - Request logging      │  │
│  │  solve,     │  │  monitoring, │  │ - Catalogue cache only │  │
│  │  diff, ...) │  │  docs)       │  │ - Progress heartbeats  │  │
│  └──────┬──────┘  └──────────────┘  └────────────────────────┘  │
│         │                                                       │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  session.py --- SageSessionManager                          ││
│  │   Per-client session map with asyncio locks, idle culling   ││
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          ││
│  │  │ Session A   │  │ Session B   │  │ Session C   │  ...     ││
│  │  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘          ││
│  └─────────┼────────────────┼────────────────┼─────────────────┘│
└────────────┼────────────────┼────────────────┼──────────────────┘
             │                │                │
             ▼                ▼                ▼
     ┌────────────────────────────────────────────────┐
     │  _sage_worker.py --- Subprocess Workers        │
     │  JSON stdin/stdout protocol                    │
     │                                                │
     │  ┌────────────┐   ┌──────────────────────┐     │
     │  │allowlist.py│──▶│ is this name offered?│     │
     │  │security.py │   │ AST checks, then     │     │
     │  │            │   │ exec() -- or refuse  │     │
     │  └────────────┘   └──────────────────────┘     │
     │                                                │
     │  Namespace scrubbed at startup, then           │
     │  persistent: vars, functions and classes       │
     │  survive across calls                          │
     └────────────────────────────────────────────────┘

Request flow: MCP client → a tool in tools/SageSessionManager.get_or_create()SageSession.evaluate() → JSON request to _sage_worker.py subprocess → AST validation → exec() in persistent namespace → JSON response back.

Key design decisions:

  • Process isolation: Each session runs SageMath in a separate subprocess. A crash or timeout in one session cannot affect others.
  • Stateful sessions: Variables, functions, and assumptions persist across tool calls within the same MCP session, enabling multi-step mathematical workflows.
  • Deny-by-default for caller code: a name is refused unless the generated allowlist offers it or the caller's own code bound it. Every snippet also passes an AST validator before execution, whichever tool it arrived through. The allowlist came after a run of bypasses that shared one shape — a name nobody had thought to forbid — and it means a helper a future SageMath adds is refused until someone reviews it, rather than reachable the day it lands.
  • Caching is deliberately narrow: only the tool, resource and prompt catalogues are cached. Tool-call and resource-response caching are off, because the cache key does not include the client identity and two clients making the same call would collide.
  • Progress heartbeats: Long-running computations emit periodic progress events (~1.5s) so clients can display activity indicators and detect stalls.

Quick Start

Install from PyPI

pip install sagemath-mcp

# Run the server over stdio (default)
sagemath-mcp

# Or expose an HTTP endpoint
sagemath-mcp --transport streamable-http --host 127.0.0.1 --port 8314

If the command is not on your PATH, run python -m sagemath_mcp.server --help.

Develop from source

git clone https://github.com/XBP-Europe/sagemath-mcp.git
cd sagemath-mcp

# Install dependencies (use uv or pip)
uv pip install -e .[dev]

# Run the server over stdio (default)
uv run sagemath-mcp

# Run with streaming-friendly HTTP transport
uv run sagemath-mcp --transport streamable-http --host 127.0.0.1 --port 8314

Optional: start a Sage container automatically

If you'd like a ready-to-use Sage runtime without installing it locally, run:

make sage-container  # or ./scripts/setup_sage_container.sh

On Windows PowerShell:

pwsh -File scripts/setup_sage_container.ps1

Docker Image

Build a ready-to-run container with the MCP server baked in:

docker build -t sagemath-mcp:latest .
docker run -p 8314:8314 sagemath-mcp:latest --transport streamable-http

Released images are published to ghcr.io/xbp-europe/sagemath-mcp and signed with Cosign. Verify a downloaded artifact with:

cosign verify ghcr.io/xbp-europe/sagemath-mcp:latest \
  --certificate-identity "https://github.com/XBP-Europe/sagemath-mcp/.github/workflows/release.yml@refs/tags/vX.Y.Z" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"

Docker Compose

docker compose up --build

The compose service exposes port 8314 on both host and container and mounts the repository at /workspace. Containers run as the non-root sage user (UID/GID 1001) to match the base image. Tweak runtime settings by editing the environment block (for example, increase SAGEMATH_MCP_EVAL_TIMEOUT or adjust SAGEMATH_MCP_MAX_STDOUT) before launch.


Detailed Tool Reference

evaluate_sage --- Open-Ended SageMath Execution

The primary tool. Executes arbitrary SageMath code inside a persistent worker process. Variables, functions, classes, and assumptions defined in one call survive into subsequent calls within the same MCP session.

ParameterTypeDefaultDescription
codestringrequiredSageMath code to execute. Multi-line strings are supported.
want_latexboolfalseWhen true, the server generates a LaTeX representation of the final expression result (if one exists) via Sage's latex() function. Returned in the latex field.
capture_stdoutbooltrueWhen true, any output from print() statements is captured and returned in the stdout field. Set to false for faster execution when stdout is not needed.
timeoutfloatnullOverride the per-evaluation timeout in seconds. If omitted, the global default (SAGEMATH_MCP_EVAL_TIMEOUT, 30 s) applies. Must be > 0.

Returns an EvaluateResult object:

FieldTypeDescription
result_type"expression" or "statement""expression" when the code ends with an expression whose value is captured; "statement" when it ends with an assignment or side effect.
resultstring or nullThe repr() of the final expression value, or null for statement-type code.
latexstring or nullLaTeX representation of the result (only when want_latex=true and the result is non-null).
stdoutstringCaptured stdout output (empty string if nothing was printed or capture_stdout=false). Truncated to SAGEMATH_MCP_MAX_STDOUT characters.
elapsed_msfloatWall-clock execution time in milliseconds.

Behavior details:

  • While code is running, the server emits progress heartbeats roughly every 1.5 seconds so clients can display activity indicators.
  • If the evaluation exceeds the timeout, the worker process is restarted and a TimeoutError is raised. All session state from prior calls is lost.
  • If the startup code (from sage.all import * by default) failed when the worker launched, every subsequent evaluate_sage call returns a clear StartupError instead of a confusing NameError.
  • Caller code is checked against an allowlist, so a name works only if SageMath preloads it for mathematics, it is a safe builtin, or your own code defined it — including earlier in the same session. Anything else is refused, and the message names the fix where there is one. The AST validator runs on top of that (see Security Sandbox).
  • x, y, z and t are predefined, and evaluate_sage auto-declares any other symbol-shaped name (w, x_2, alpha) as a symbol the way SageMath's SR does — so w^2 + 1 just works. The shape is narrow and typo-guarded: a multi-letter name like sinn stays an error, and a name you assigned earlier keeps its value.
  • Indentation shared by every line is stripped before parsing, so a snippet pasted out of a markdown block is accepted rather than failing as a syntax error.

Domain-specific examples (these are included in the tool description LLMs see):

DomainExample Sage code
Combinatoricsbinomial(10, 3), Permutations(4).cardinality(), Combinations([1,2,3,4], 2).list()
Graph theoryG = graphs.PetersenGraph(); G.chromatic_number()
Number theoryprime_range(100), euler_phi(60), continued_fraction(pi, nterms=10)
Geometrypolytopes.cube().volume(), EllipticCurve([0,0,1,-1,0]).rank()
ProbabilityRealDistribution('gaussian', 1).cum_distribution_function(1.96)
Group theorySymmetricGroup(5).order(), AlternatingGroup(4).is_abelian()
Polynomial ringsR.<a,b> = PolynomialRing(QQ); (a+b)^3
Coding theorycodes.HammingCode(GF(2), 3).minimum_distance()

Stateful multi-step workflow:

> evaluate_sage(code="var('a'); f = (a + 1)^5")
  result_type: "statement", result: null

> evaluate_sage(code="expand(f)")
  result_type: "expression", result: "a^5 + 5*a^4 + 10*a^3 + 10*a^2 + 5*a + 1"

> evaluate_sage(code="diff(f, a, 2)")
  result_type: "expression", result: "20*(a + 1)^3"

Calculus Tools

differentiate_expression

Compute the symbolic derivative of an expression. Calls Sage's diff(expr, var, order) internally.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to differentiate (e.g. "sin(x)*e^x", "x^3 + 2*x").
variablestring"x"The variable to differentiate with respect to.
orderint (>= 1)1Differentiation order. 1 = first derivative, 2 = second derivative, etc.

Returns: {"derivative": "...", "order": N}

> differentiate_expression(expression="x^5", variable="x", order=3)
  {"derivative": "60*x^2", "order": 3}

> differentiate_expression(expression="sin(x)*cos(x)")
  {"derivative": "cos(x)^2 - sin(x)^2", "order": 1}

integrate_expression

Compute indefinite or definite integrals. Calls Sage's integrate() function.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to integrate.
variablestring"x"The integration variable.
lower_boundstring or nullnullLower bound for definite integrals. Accepts symbolic values like "0", "-oo" (negative infinity), or expressions like "-pi".
upper_boundstring or nullnullUpper bound for definite integrals. Accepts "1", "oo" (infinity), "pi/2", etc.

Bounds may also be free symbols, so upper_bound="a" integrates to a symbolic limit. Names Sage already defines keep their meaning: e, pi and oo are the constants, not new variables.

Both lower_bound and upper_bound must be provided together for a definite integral, or both omitted for an indefinite integral. Providing only one raises an error.

Returns: {"integral": "...", "definite": true/false}

> integrate_expression(expression="x^2")
  {"integral": "1/3*x^3", "definite": false}

> integrate_expression(expression="x^2", lower_bound="0", upper_bound="1")
  {"integral": "1/3", "definite": true}

> integrate_expression(expression="e^(-x^2)", lower_bound="-oo", upper_bound="oo")
  {"integral": "sqrt(pi)", "definite": true}

> integrate_expression(expression="x", lower_bound="0", upper_bound="a")
  {"integral": "1/2*a^2", "definite": true}

limit_expression

Compute the limit of an expression as a variable approaches a point. Calls Sage's limit() function.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to take the limit of.
variablestring"x"The variable approaching the point.
pointstring"0"The point to approach. Use "oo" for positive infinity, "-oo" for negative infinity, or any symbolic expression.
directionstring or nullnullOne-sided limit direction: "plus" (approach from the right, x -> a+), "minus" (approach from the left, x -> a-), or null for both sides.

Returns: {"limit": "..."}

> limit_expression(expression="sin(x)/x", point="0")
  {"limit": "1"}

> limit_expression(expression="1/x", point="0", direction="plus")
  {"limit": "+Infinity"}

> limit_expression(expression="(1 + 1/n)^n", variable="n", point="oo")
  {"limit": "e"}

series_expansion

Compute a Taylor or Laurent series expansion around a point. Calls Sage's .series() method.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to expand.
variablestring"x"The expansion variable.
pointstring"0"Center of the expansion (Maclaurin series when "0").
orderint (>= 1)6Number of terms in the expansion.

Returns: {"series": "...", "point": "...", "order": N}

> series_expansion(expression="e^x", order=5)
  {"series": "1 + x + 1/2*x^2 + 1/6*x^3 + 1/24*x^4 + O(x^5)", "point": "0", "order": 5}

> series_expansion(expression="1/(1-x)", point="0", order=4)
  {"series": "1 + x + x^2 + x^3 + O(x^4)", "point": "0", "order": 4}

Algebra & Simplification Tools

solve_equation

Solve a single equation or a system of simultaneous equations. Calls Sage's solve() function. Equations are parsed by splitting on =: the string "x^2 - 1 = 0" becomes the Sage equation x^2 - 1 == 0.

ParameterTypeDefaultDescription
equationstring or list[string]requiredA single equation string (e.g. "x^2 - 1 = 0") or a list of equations for systems (e.g. ["x + y = 3", "x - y = 1"]). If no = is present, the expression is solved as expr = 0.
variablestring or list[string]"x"Variable(s) to solve for. Use a list for systems (e.g. ["x", "y"]).

Returns: {"solutions": [...]}

> solve_equation(equation="x^2 - 5*x + 6 = 0")
  {"solutions": ["x == 2", "x == 3"]}

> solve_equation(equation=["x + y = 10", "x - y = 2"], variable=["x", "y"])
  {"solutions": [[x == 6, y == 4]]}

> solve_equation(equation="sin(x) = 1/2", variable="x")
  {"solutions": ["x == 1/6*pi"]}

simplify_expression

Apply Sage's simplify() function to reduce a symbolic expression to a simpler form.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to simplify.

Returns: {"simplified": "..."}

> simplify_expression(expression="(x^2 - 1)/(x - 1)")
  {"simplified": "x + 1"}

> simplify_expression(expression="sin(x)^2 + cos(x)^2")
  {"simplified": "1"}

expand_expression

Expand products, powers, and trigonometric/logarithmic identities using Sage's expand().

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to expand.

Returns: {"expanded": "..."}

> expand_expression(expression="(x + 1)^3")
  {"expanded": "x^3 + 3*x^2 + 3*x + 1"}

> expand_expression(expression="(a + b)*(a - b)")
  {"expanded": "a^2 - b^2"}

factor_expression

Factor a symbolic expression or integer using Sage's factor().

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to factor. Can be a polynomial (e.g. "x^2 - 1") or an integer (e.g. "60").

Returns: {"factored": "..."}

> factor_expression(expression="x^3 - 1")
  {"factored": "(x - 1)*(x^2 + x + 1)"}

> factor_expression(expression="60")
  {"factored": "2^2 * 3 * 5"}

calculate_expression

Evaluate a symbolic expression and return both its string representation and numeric value (when possible). Uses Sage's sage_eval() internally with pre-declared variables x, y, z, t.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to evaluate.

Returns: {"string": "...", "numeric": float} --- the numeric field is omitted when the expression cannot be converted to a float.

> calculate_expression(expression="factorial(10)")
  {"string": "3628800", "numeric": 3628800.0}

> calculate_expression(expression="sqrt(2)")
  {"string": "sqrt(2)", "numeric": 1.4142135623730951}

> calculate_expression(expression="pi")
  {"string": "pi", "numeric": 3.141592653589793}

Linear Algebra Tools

matrix_multiply

Multiply two matrices over the Symbolic Ring (SR). Input matrices are nested lists of numbers.

ParameterTypeDefaultDescription
matrix_alist[list[float]]requiredLeft matrix (rows of numbers).
matrix_blist[list[float]]requiredRight matrix (rows of numbers).

Returns: {"product": [[...], ...]} --- entries are floats when real, strings otherwise.

> matrix_multiply(matrix_a=[[1, 2], [3, 4]], matrix_b=[[5, 6], [7, 8]])
  {"product": [[19.0, 22.0], [43.0, 50.0]]}

matrix_operation

Perform a single matrix operation. Supports six operations on matrices over the Symbolic Ring.

ParameterTypeDefaultDescription
matrixlist[list[float]]requiredInput matrix as nested list of numbers.
operationstringrequiredOne of: "determinant", "inverse", "eigenvalues", "rank", "rref", "transpose".

Returns: {"operation": "...", "result": ...} --- result type varies by operation:

OperationResult typeDescription
determinantfloat or stringScalar determinant value.
inverselist[list[float]]The inverse matrix (error if singular).
eigenvalueslist[float]List of eigenvalues (with multiplicity).
rankintMatrix rank.
rreflist[list[float]]Reduced row echelon form.
transposelist[list[float]]Transposed matrix.
> matrix_operation(matrix=[[1, 2], [3, 4]], operation="determinant")
  {"operation": "determinant", "result": -2.0}

> matrix_operation(matrix=[[2, 1], [1, 2]], operation="eigenvalues")
  {"operation": "eigenvalues", "result": [3.0, 1.0]}

> matrix_operation(matrix=[[1, 2, 3], [0, 1, 4], [5, 6, 0]], operation="inverse")
  {"operation": "inverse", "result": [[-24.0, 18.0, 5.0], [20.0, -15.0, -4.0], [-5.0, 4.0, 1.0]]}

> matrix_operation(matrix=[[1, 2], [3, 6]], operation="rank")
  {"operation": "rank", "result": 1}

Differential Equations

solve_ode

Solve an ordinary differential equation using Sage's desolve(). The equation is specified as a string using Sage's diff() notation. The solver returns a general solution with arbitrary constants (_C, _K1, _K2, etc.).

ParameterTypeDefaultDescription
equationstringrequiredThe ODE as a string. Use diff(y(x),x) for y', diff(y(x),x,x) for y'', etc. Include = 0 or = rhs to specify the equation.
functionstring"y"Name of the dependent function being solved for.
variablestring"x"Name of the independent variable.

The dependent function may be written either applied (diff(y(x), x) + y(x)) or bare (diff(y, x) + y). Both describe the same equation and return identical solutions.

Returns: {"solution": "..."}

> solve_ode(equation="diff(y(x),x) + y(x) = 0")
  {"solution": "_C*e^(-x)"}

> solve_ode(equation="diff(y,x) + y = 0")     # bare form, same result
  {"solution": "_C*e^(-x)"}

> solve_ode(equation="diff(y(x),x,x) - y(x) = 0")
  {"solution": "_K1*e^(-x) + _K2*e^x"}

> solve_ode(equation="diff(y(x),x) = x*y(x)")
  {"solution": "_C*e^(1/2*x^2)"}

> solve_ode(equation="diff(y(t),t) + 2*y(t) = sin(t)", function="y", variable="t")
  {"solution": "..."}

Number Theory

number_theory_operation

Perform common number-theoretic operations using Sage's built-in functions.

ParameterTypeDefaultDescription
operationstringrequiredOne of: "is_prime", "factor_integer", "next_prime", "gcd", "lcm".
aintrequiredPrimary integer argument.
bint or nullnullSecond integer. Required for gcd and lcm; ignored otherwise.

Returns: {"operation": "...", "result": ...} --- result type varies:

OperationResult typeSage function calledDescription
is_primeboolis_prime(a)Whether a is a prime number.
factor_integerstringfactor(a)Prime factorization as a human-readable string (e.g. "2^3 * 3 * 5").
next_primeintnext_prime(a)The smallest prime greater than a.
gcdintgcd(a, b)Greatest common divisor of a and b.
lcmintlcm(a, b)Least common multiple of a and b.
> number_theory_operation(operation="is_prime", a=997)
  {"operation": "is_prime", "result": true}

> number_theory_operation(operation="factor_integer", a=2520)
  {"operation": "factor_integer", "result": "2^3 * 3^2 * 5 * 7"}

> number_theory_operation(operation="next_prime", a=100)
  {"operation": "next_prime", "result": 101}

> number_theory_operation(operation="gcd", a=48, b=180)
  {"operation": "gcd", "result": 12}

> number_theory_operation(operation="lcm", a=12, b=18)
  {"operation": "lcm", "result": 36}

Statistics

statistics_summary

Compute descriptive statistics for a numeric dataset using Sage's mean() and sqrt() functions.

ParameterTypeDefaultDescription
datalist[float]requiredList of numeric values. Must contain at least 2 elements for variance/std dev.

Returns: a dictionary with all of:

FieldDescription
meanArithmetic mean.
medianMedian value.
population_variancePopulation variance (divides by N).
sample_varianceSample variance (divides by N-1).
population_std_devPopulation standard deviation.
sample_std_devSample standard deviation.
minMinimum value.
maxMaximum value.
> statistics_summary(data=[2, 4, 4, 4, 5, 5, 7, 9])
  {"mean": 5.0, "median": 4.5, "population_variance": 4.0, "sample_variance": 4.571..., ...}

Visualization

plot_expression

Render a 2D plot of an expression and return it as a base64-encoded PNG image. Calls Sage's plot() function and serializes the result to an in-memory PNG buffer.

ParameterTypeDefaultDescription
expressionstringrequiredThe expression to plot.
variablestring"x"The plot variable.
range_minfloat-10.0Lower bound of the plot range.
range_maxfloat10.0Upper bound of the plot range.

Returns: {"image_base64": "...", "format": "png"}

The returned base64 string can be rendered directly in any client that supports inline images (e.g., via an <img> tag or Markdown ![](data:image/png;base64,...)).

> plot_expression(expression="sin(x)*e^(-x/5)", range_min=-5, range_max=20)
  {"image_base64": "iVBORw0KGgo...", "format": "png"}

> plot_expression(expression="x^3 - 3*x", range_min=-3, range_max=3)
  {"image_base64": "...", "format": "png"}

Session Management & Observability

reset_sage_session

Clear all variables, functions, and definitions in the current session. The underlying worker process continues running (fast). Equivalent to restarting a fresh Sage shell.

Returns: {"message": "Session cleared"}

Sage semantics

evaluate_sage runs your code through Sage's preparser, exactly as the Sage REPL does. 2^3 is 8, not 1; integer literals are Sage Integers; generator syntax such as K.<a> = NumberField(x^3 - 2) parses; and x, y, z and t are predefined. Use ^^ for XOR, as in Sage.

Sage's own REPL predefines x alone. This server predefines four, because the specialised tools have always declared x, y, z, t in their prelude: with only x, differentiate_expression("x^2*y^3") worked while the identical mathematics through evaluate_sage failed.

In evaluate_sage, any other symbol needs var('w'), and the error message says so. That is exactly SageMath's own rule: w + 1 typed as code is a NameError there too.

The specialised tools declare a symbol on sight, because they take an expression as a string and that is SageMath's other rule — SR("a*b + a") creates a and b. So simplify_expression("w^2 + w^2") answers 2*w^2, and expand_expression("(θ + φ)^2") answers in the letters you wrote.

Narrower than SR in the way that matters: SR invents any identifier, so SR("sinn(x)") returns sinn(x) and a typo becomes a silent wrong answer. Only symbol-shaped names are declared — a letter with an optional index (a, w, x_2), a spelled-out Greek name (alpha), or a Greek letter (α, Ω) — so sinn, foobar and pi2 are still errors. Names SageMath already defines are never shadowed: e stays Euler's number, I the imaginary unit, and gamma, zeta, π, σ, Γ and ψ stay the functions they are.

Only caller code is preparsed. The specialised tools build plain Python around sage_eval, and preparsing those templates would change what they mean.

Large integers

Mathematics produces integers that JSON numbers cannot carry. Above 2^53 a JSON number stops being exact, and JavaScript-based MCP clients parse every number as an IEEE double --- so bell(30) arrived in one CLI as 846749014511809388871680 instead of 846749014511809332450147. Nothing errored; the number was simply wrong, which is the worst way for it to fail.

Both directions therefore speak decimal strings past that boundary:

  • In: integer parameters from 2^53 upward must be passed as decimal strings; a numeric literal that large is rejected rather than silently rounded. The boundary is JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1), not 2^53: 2^53 + 1 rounds to exactly 2^53, so those two arrive indistinguishable and neither can be trusted.
  • Out: integer results beyond that boundary come back as decimal strings. Smaller integers keep their numeric type, so ordinary results are unchanged.
  • Matrices: entries may be integers, decimal strings or floats. Integer entries stay exact rather than being rounded through a double, and an integral result past the boundary comes back exact. Float matrices behave exactly as before.
{"operation": "bell", "result": "846749014511809332450147"}
{"operation": "binomial", "result": 120}

interrupt_sage_session

Stop a running computation while keeping every variable defined so far. The worker is signalled, abandons the current statement, and stays alive with its namespace intact. The interrupted call returns an Interrupted error.

Prefer this over cancel_sage_session — cancelling discards state that may have been expensive to build.

ParameterTypeDefaultDescription
sessionstring"default"Named workspace to interrupt.

Returns: {"message": "Interrupted session 'default'; state preserved"}

Interrupting when nothing is running is reported, not an error, and no signal is sent: {"message": "No running computation in session 'default'"}. That matters beyond tidiness — an idle worker is blocked reading its input, where a SIGINT has no computation to abort, and signalling it anyway left real Sage workers unable to answer the next request. POSIX only.

cancel_sage_session

Abort any in-flight computation by killing the worker process and starting a new one. All session state is lost — reach for this only when the worker is wedged badly enough that interrupting does not help.

Returns: {"message": "Session cancelled and restarted"}

start_sage_session, list_sage_sessions, stop_sage_session

One client can hold several independent workspaces. Variables defined in one are invisible to the others, so a long-running exploration and a quick scratch calculation need not collide.

> start_sage_session(name="curves")
> evaluate_sage(code="E = EllipticCurve([0,-1])", session="curves")
> evaluate_sage(code="G = graphs.PetersenGraph()", session="graphs")

> evaluate_sage(code="E.rank()", session="curves")
  0
> evaluate_sage(code="E.rank()", session="graphs")     # not defined here
  NameError

> list_sage_sessions()
  {"sessions": [{"name": "curves", "alive": true, "statements": 2}, ...], "count": 2}

> stop_sage_session(name="curves")

Every tool that runs on a worker accepts the same optional session argument. Omitting it uses the default workspace, which is the behaviour of every earlier version.

What session does, precisely. It selects which worker process runs the call, so a long computation in one workspace can be interrupted or cancelled without disturbing another. It does not give the specialised tools access to variables you defined with evaluate_sage: those tools evaluate their input in a fresh Sage namespace, so calculate_expression("myvar") will not see a myvar assigned earlier. Use evaluate_sage for anything that has to build on previous state.

MCP Resources

Resource URIScope valuesDescription
resource://sagemath/session/{scope}all, or a specific session IDReturns JSON with: session_id, live (bool), started_at, last_used_at, idle_seconds.
resource://sagemath/monitoring/{scope}metrics, allReturns JSON with the process-wide aggregates only: attempts, successes, failures, security_failures, avg_elapsed_ms, max_elapsed_ms, last_run_at. Per-failure error text and stdout are not exposed here (they are shared process-global state); see the server logs instead.
resource://sagemath/docs/{scope}all, reference, tutorialReturns documentation link objects with URLs to SageMath documentation.

Security Sandbox

All code --- whether from evaluate_sage or generated internally by helper tools --- passes through an AST-based security validator before execution.

What this is, and is not. The validator is defence in depth against accidents and casual misuse. It is not a boundary against determined adversarial code, and it should not be the only thing standing between an untrusted caller and your host. The container is the security boundary --- run the server in one, and see Container hardening.

These are removed from the worker namespace as well as rejected by the validator, and by where they come from rather than by name: a list of names cannot keep up with a namespace thousands deep, and cython(get_remote_file(url)) was download, compile and execute in one expression. gp('system("id")') ran a shell command. Neither involved a name any rule mentioned.

This section was previously inaccurate: it claimed subprocess.*, pathlib.* and socket.* were blocked when none of them were, because a rule required a module and a specific attribute name to match. Seven further bypasses were found and closed at the same time. It was inaccurate a second time, more subtly: the forbidden names were rejected only where they were called, so f = open followed by f("/etc/passwd") passed --- through the specialised tools as well as evaluate_sage. Forbidden names are now rejected wherever they are read, and the worker's namespace no longer contains them at all. The table below is covered by a test that fails if the code stops enforcing it, and that test now checks aliases, not just call sites.

The rule that comes first: an allowlist.

Caller code may read a name only if it is one this server offers --- the ~1900 mathematical names SageMath preloads, the safe builtins, and whatever the caller defines itself (assignments, loop variables, function arguments, var('t'), and anything created earlier in the same session). Everything else is refused.

That inversion is the point. Seven sandbox bypasses in two days had one shape between them: a name nobody had thought to forbid --- cython, sh, gp, get_remote_file, unpickle_global. A denylist over a namespace that size is always one name behind. It does not retroactively catch something dangerous still sitting in the namespace, but a helper added by a future SageMath release is denied until someone looks at it, rather than reachable the day it lands. A test run weekly, and on every push, fails when the two disagree.

The rules below still apply, and now serve as defence in depth behind it.

What is blocked:

Names in the first three rows are rejected anywhere they are read --- called, assigned, aliased, defaulted into a lambda, placed in a list, or reached through an attribute chain --- not only in call position. The last of those matters more than it sounds: sage is an allowed import root, so sage.misc.sage_eval.sage_eval("...") reached the same function that a bare sage_eval could not.

CategoryDetails
Dangerous builtinseval(), exec(), compile(), __import__(), open(), input(), globals(), locals(), vars()
Attribute indirectiongetattr(), setattr(), delattr() --- these defeat every attribute rule by naming the attribute at runtime
Runtime string evaluationsage_eval(), preparse(), sage_input() --- these evaluate a string after the AST has been approved
Dunder accessAny __dunder__ name or attribute, which blocks ().__class__.__bases__[0].__subclasses__() and __builtins__
Sage helpers that execute or fetchcython(), cython_lambda(), fortran() (compile and run code), sh() (runs a shell), get_remote_file() (downloads), loads/dumps/save/db_save (pickle is code execution)
External CAS interfacesgp, maxima, gap, singular, octave, magma, sage0 and everything else sage.interfaces.all exports --- each spawns the real program, and those have shell escapes of their own
Names that write, fetch or displayoeis (queries oeis.org), install_doc, show, view, animate, html, latex, search_src, search_doc, reference, Profiler --- each demonstrated a file written, a network request or the installation read. Plot tools are unaffected: they render through .savefig(BytesIO), and LaTeX output imports latex from sage.all directly rather than from the caller namespace
Sage loadersload() and attach() execute whatever path they are given, and load() accepts a URL
String-path attribute accessattrgetter, methodcaller, itemgetter, and the operator module that carries them. Every attribute rule here is enforced on the AST, and these take the path as a runtime string the AST never sees: operator.attrgetter("misc.persist.unpickle_global")(sage) returned the real function, which is arbitrary code execution. getattr, setattr and vars were already refused, which left operator as the only way in
Forbidden modulesEvery attribute of os, sys, subprocess, shutil, socket, pathlib, builtins, operator, warnings, pari, oeis --- at any depth, so sage.misc.temporary_file.os is caught too. pari is the PARI library interface, which the external-CAS scrub missed because it comes from sage.libs.pari; pari('system("id")') ran a shell command
Sage sub-packages that executecython, persist, remote_file, interfaces, inline_fortran, repl, package, temporary_file, attached_files, explain_pickle, edit_module, dev_tools, trace, sh --- at any depth. Blocked as a path, so sage.misc.trace.trace(...) is refused and A.trace() is not
ImportsRefused by default. An import is how you get back a helper the worker removed, and the namespace already has Sage loaded. Two narrow exceptions change nothing reachable: an import of names already offered, and from <module> import * for a curated set of internal SageMath modules whose public names are all ordinary mathematics --- screened clean as a whole and generated into star_exports.py, with any re-exported module object dropped so it cannot become a pivot. Nothing is added to the allowlist
Scope manipulationglobal and nonlocal statements (configurable)
Namespace removalThe worker's __builtins__ omits open, eval, exec, compile, input, breakpoint, globals, locals, vars, memoryview, help, exit and quit outright --- a backstop for spellings the AST pass misses. __import__ deliberately stays, because Sage imports lazily during ordinary mathematics; it is unreachable from caller code, which cannot name any dunder.

Caller-supplied expressions passed to the specialised tools are validated as expressions in their own right before they are embedded in generated code. Without that, calculate_expression("__import__('os').getuid()") reached the operating system, because the validator saw only a string constant.

What is allowed:

Everything Sage preloads --- which is the whole library. factorial(5), integrate(sin(x), x), matrix(...), EllipticCurve(...) and the rest need no import, because the worker starts with from sage.all import * already done.

The import allowlist below applies only to the snippets this server generates. Caller code gets a much narrower door --- the imports that would change nothing (a name already offered, or from <curated module> import * expanded to its screened names), and nothing else --- see the Imports row above:

ImportUsed by
sage, sage.allThe generated prelude
base64, ioThe plot templates, for in-memory PNG encoding
math, cmath, statisticsHelper templates

Network exposure

The server has no authentication. Anyone who can reach the HTTP endpoint can evaluate code, which is why every default here is loopback: --host defaults to 127.0.0.1, the default transport is stdio, the bundled compose file publishes to 127.0.0.1:8314, and the Helm service is ClusterIP. Putting it on a network means putting something that authenticates in front of it.

Container hardening

The validator narrows what caller code can express. The container is what actually contains it, so docker-compose.yml sets:

SettingWhy
read_only: trueThe root filesystem is immutable. Sage needs only a writable temp dir and its own dot-directory, supplied as the two tmpfs mounts below; without them it fails outright, which is how they were sized.
tmpfs: /tmp, /home/sage/.sageThe only writable paths, in memory, capped at 512 MB and 256 MB.
./:/workspace:roThe server runs from the package installed in the image; an escaped process should not be able to edit the checkout it reads.
cap_drop: [ALL]No Linux capabilities are needed to do mathematics.
security_opt: [no-new-privileges:true]Blocks privilege escalation via setuid binaries.
pids_limit: 256A fork bomb cannot exhaust the host.
mem_limit: 4gNeither can a runaway computation.

An escape was measured reading all environment variables, reading the mounted checkout and opening outbound sockets. If the server is exposed to untrusted callers, also consider network_mode: none where the workload allows it, and avoid passing secrets in the environment of this container.

The Helm chart applies runAsNonRoot, allowPrivilegeEscalation: false, capabilities.drop: [ALL] and readOnlyRootFilesystem: true, with emptyDir volumes for the same two writable paths and default CPU/memory requests and limits. It is close but not identical: compose's pids_limit has no direct chart equivalent (pod PID limits are a kubelet setting), so set one on the node if you need it.

Enforced limits:

LimitDefaultEnv var
Max source code length131,072 charsSAGEMATH_MCP_SECURITY_MAX_SOURCE
Max AST node count50,000SAGEMATH_MCP_SECURITY_MAX_AST_NODES
Max AST nesting depth75SAGEMATH_MCP_SECURITY_MAX_AST_DEPTH

Error handling: When code violates the security policy, the server returns a clear error message identifying the violation (e.g., "Call to forbidden function 'eval' is blocked") and logs a warning. The session remains alive --- subsequent calls can succeed.


LLM Client Configuration

Clients connecting through MCP receive the following guidance automatically:

  • Stateful sessions --- every conversation owns a dedicated Sage worker. Define symbols once (e.g., var('x'), f = ...) and reuse them across subsequent tool calls.
  • Use the right tool --- reach for specialized helpers (solve_equation, differentiate_expression, etc.) for structured JSON output. Fall back to evaluate_sage for anything else.
  • Chain computations --- assign results in one call and reference them in the next. All state persists within the session.
  • Timeouts --- long computations emit heartbeat progress events. Adjust per-call timeouts via the timeout parameter.
  • Security --- the AST validator blocks arbitrary imports, eval/exec, and filesystem/process calls. Prefer Sage primitives; if a violation occurs, rewrite the workflow using supported APIs.

Client-Specific Setup

Claude Desktop --- add to claude_desktop_config.json:

{
  "mcpServers": {
    "sagemath": {
      "command": "uv",
      "args": ["run", "sagemath-mcp"]
    }
  }
}

Claude Code --- add to .mcp.json in the project root:

{
  "mcpServers": {
    "sagemath": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "sagemath-mcp"]
    }
  }
}

Codex CLI:

codex mcp add sagemath --command uv --args "run" "sagemath-mcp"

Gemini CLI:

gemini mcp add sagemath --transport stdio --command uv --arg run --arg sagemath-mcp

For HTTP transport, expose the endpoint first (sagemath-mcp --transport streamable-http --host 0.0.0.0 --port 8314) and point the client at http://HOST:8314/mcp.


Deployment

stdio (default)

uv run sagemath-mcp

Best for local LLM clients (Claude Desktop, Claude Code, Codex CLI). The client spawns the server as a subprocess and communicates over stdin/stdout.

HTTP / Streamable HTTP

uv run sagemath-mcp --transport streamable-http --host 127.0.0.1 --port 8314

Best for remote clients, browser-based tools, or shared environments. Supports streaming responses and cancellation.

Docker Compose

docker compose up --build

Exposes http://127.0.0.1:8314/mcp. Runs as non-root sage user (UID/GID 1001). The compose file mounts the repository at /workspace and accepts environment variable overrides for all SAGEMATH_MCP_* settings.

Kubernetes (Helm)

helm install sagemath charts/sagemath-mcp \
  --set image.repository=ghcr.io/xbp-europe/sagemath-mcp \
  --set image.tag=latest

Key values: service.port, env (map of environment overrides), args (CLI arguments), ingress.*. The chart enforces non-root execution (runAsUser/runAsGroup 1000). Review values.yaml for the full set of configurable knobs. The release workflow validates the chart with helm lint and helm template before publishing.


Configuration Reference

All configuration is done via environment variables. No config files are needed.

Runtime Settings

VariableDescriptionDefault
SAGEMATH_MCP_SAGE_BINARYPath to the sage executable.sage
SAGEMATH_MCP_STARTUPSage code executed during session bootstrap.from sage.all import *
SAGEMATH_MCP_IDLE_TTLSeconds of inactivity before a session is culled.900
SAGEMATH_MCP_EVAL_TIMEOUTPer-evaluation timeout in seconds.30
SAGEMATH_MCP_MAX_STDOUTMaximum characters of stdout returned per call.100000
SAGEMATH_MCP_SHUTDOWN_GRACEGrace period before a stuck worker is terminated.2
SAGEMATH_MCP_FORCE_PYTHON_WORKERUse the pure-Python worker (helpful for tests/CI).false
SAGEMATH_MCP_PURE_PYTHONWhen set to 1, load math stdlib instead of Sage modules.unset

Security Settings

VariableDescriptionDefault
SAGEMATH_MCP_SECURITY_ENABLEDEnable/disable AST-based code validation.true
SAGEMATH_MCP_SECURITY_MAX_SOURCEMaximum source length in characters, measured after preparsing.131072
SAGEMATH_MCP_SECURITY_MAX_AST_NODESMaximum AST node count allowed.50000
SAGEMATH_MCP_SECURITY_MAX_AST_DEPTHMaximum AST depth allowed.75
SAGEMATH_MCP_SECURITY_ALLOW_IMPORTSPermit import statements when set to true.false
SAGEMATH_MCP_SECURITY_FORBID_GLOBALBlock global statements when true.true
SAGEMATH_MCP_SECURITY_FORBID_NONLOCALBlock nonlocal statements when true.true
SAGEMATH_MCP_SECURITY_LOG_VIOLATIONSEmit warnings when code is blocked.true
SAGEMATH_MCP_SECURITY_ALLOWED_IMPORTSComma-separated allowlist of importable modules.math,cmath,statistics,base64,io,sage,sage.all
SAGEMATH_MCP_SECURITY_ALLOWED_IMPORT_PREFIXESComma-separated prefixes treated as safe namespaces.sage.

CLI Reference

usage: sagemath-mcp [--transport {stdio,http,streamable-http,sse}]
                    [--host HOST] [--port PORT] [--path PATH]
                    [--log-level LOG_LEVEL]
ArgumentDescriptionDefault
--transportTransport protocol: stdio, http, streamable-http, or sse.stdio
--hostBind address for HTTP transports.127.0.0.1
--portListen port for HTTP transports.8314
--pathCustom HTTP path (e.g., /mcp) for streamable-http or sse transports.auto
--log-levelPython logging level (DEBUG, INFO, WARNING, ERROR).INFO
# Default: stdio transport for Claude Desktop / Codex CLI
sagemath-mcp

# HTTP transport for browser-based or remote clients
sagemath-mcp --transport streamable-http --host 0.0.0.0 --port 8314

# Debug logging
sagemath-mcp --log-level DEBUG

# With uv
uv run sagemath-mcp --transport streamable-http --host 127.0.0.1 --port 8314

Development

Prerequisites

  • Python 3.12+ with uv installed
  • Docker (optional, for integration tests and Sage container)
  • SageMath (optional, for local development without Docker)

Commands

uv pip install -e .[dev]       # Install with dev extras
make lint                       # ruff check (ruff 0.15+)
make test                       # pytest (pure Python, no Sage needed)
make integration-test           # pytest inside Sage Docker container
make build                      # sdist + wheel via scripts/build_release.py
make cli-integration            # Run CLI integration tests (Claude + Gemini)
make sage-container             # Bootstrap the Sage Docker container
make allowlist                  # Regenerate the caller allowlist from that Sage

make allowlist is needed after a SageMath upgrade or any change to what the worker namespace contains; an integration test and a weekly job fail when the committed allowlist and the installed Sage disagree. It writes through a temporary file, because the generator imports the module it replaces. Read the diff: every added name is a name every caller can then use, and anything that compiles, spawns, writes or fetches belongs in _DANGEROUS_BARE_NAMES instead.

Running Tests

Without a local SageMath installation you can still run the whole unit suite --- it replaces the Sage worker with a lightweight Python interpreter to validate session plumbing. Coverage is at 100% of statements and branches, enforced in CI by --cov-fail-under=100; that number is checked on every run, unlike a test count written into prose.

# Run all unit tests
uv run pytest

# Run a single test
uv run pytest tests/test_server.py -k "test_solve_equation"

# Run with coverage
uv run pytest --cov=sagemath_mcp --cov-report=term-missing

Linting

Ruff with line-length 100, target Python 3.12. Rules: E, F, W, B, UP, ASYNC, RUF, I (import sorting). Run make lint before committing.

Git Hooks

Configure Git hooks after cloning:

git config core.hooksPath .githooks

The pre-push hook runs ruff automatically.


CLI Integration Testing

The project includes a comprehensive end-to-end test suite that validates the MCP server through real LLM CLI invocations. Located in tests/cli_integration/.

Overview

  • 43 test cases across 9 mathematical domains
  • Tests both Claude Code (claude --print) and Gemini CLI (gemini -p)
  • Live progress reporting during execution
  • Multi-tier validation: substring matching, numeric extraction, soft-fail for non-deterministic output
  • JSON result export for historical tracking

Running

# Run against both CLIs
make cli-integration

# Or use the standalone runner with options
python -m tests.cli_integration.run_cli_tests --cli claude --domain calculus
python -m tests.cli_integration.run_cli_tests --cli both --parallel
python -m tests.cli_integration.run_cli_tests --cli gemini --domain algebra,number_theory

Domain Coverage

DomainCasesTools tested
Calculus10differentiate_expression, integrate_expression, limit_expression, series_expansion
Algebra11solve_equation, simplify_expression, expand_expression, factor_expression, calculate_expression
Linear algebra5matrix_multiply, matrix_operation
ODEs2solve_ode
Number theory6number_theory_operation
Statistics2statistics_summary
Plotting2plot_expression
General3evaluate_sage
Session2reset_sage_session, cancel_sage_session

Project Layout

sagemath-mcp/
├── pyproject.toml                  # Project metadata, dependencies, tool config
├── README.md                       # This file
├── USAGE.md                        # Detailed usage guide
├── CLAUDE.md                       # Claude Code project instructions
├── Dockerfile                      # Production container (SageMath + MCP server)
├── docker-compose.yml              # Local development stack
├── Makefile                        # Common commands (test, lint, build, etc.)
├── src/sagemath_mcp/
│   ├── server.py                   # Entry point: /health route, main(), and the imports that register everything
│   ├── app.py                      # The FastMCP object, instructions, lifespan, middleware
│   ├── runtime.py                  # Settings and the session manager
│   ├── codegen.py                  # Prelude, literal encoding, validation gates, numeric guards
│   ├── text.py                     # Client-facing strings shared by app and tools
│   ├── tools/                      # The 37 tools and 3 resources, by domain
│   │   ├── session.py              #   6 session tools + the 3 resources
│   │   ├── core.py                 #   evaluate_sage, streaming, calculate, simplify/expand/factor, find_root
│   │   ├── calculus.py             #   differentiate, integrate, limit, series, ODEs, sums, vector calculus
│   │   ├── algebra.py              #   solve, matrices, polynomial rings, boolean algebra
│   │   ├── discrete.py             #   number theory, combinatorics, graphs, groups, curves, codes
│   │   ├── stats.py                #   statistics_summary, distribution_operation
│   │   └── plotting.py             #   2D/3D plots and geometry
│   ├── session.py                  # Sage worker lifecycle, session management, idle culling
│   ├── _sage_worker.py             # Subprocess worker: code execution, AST validation, LaTeX
│   ├── security.py                 # AST validator, SecurityPolicy, configurable allowlists
│   ├── config.py                   # SageSettings from environment variables
│   ├── models.py                   # Pydantic models (EvaluateResult, SessionSnapshot, etc.)
│   ├── monitoring.py               # Thread-safe evaluation metrics (EvaluationMetrics)
│   └── py.typed                    # PEP 561 type hint marker
├── tests/
│   ├── conftest.py                 # Shared FakeContext fixture
│   ├── test_server.py              # Tool & resource unit tests
│   ├── test_session.py             # Session lifecycle, timeout, reset, cancel
│   ├── test_security.py            # AST validation, policy configuration
│   ├── test_config.py              # Environment variable parsing
│   ├── test_sage_worker.py         # Worker protocol, LaTeX, startup errors
│   ├── test_integration.py         # Real Sage: monitoring, timeout, cancellation
│   ├── test_use_cases.py           # End-to-end Sage workflows
│   └── cli_integration/            # LLM CLI end-to-end tests (43 cases)
│       ├── run_cli_tests.py        # Standalone runner with rich reporting
│       ├── test_cases.py           # All test case definitions
│       ├── validate.py             # Multi-tier output validation
│       ├── runner.py               # Claude/Gemini CLI invocation
│       ├── cli_config.py           # MCP server setup/teardown for CLIs
│       ├── test_claude.py          # Pytest wrapper for Claude
│       └── test_gemini.py          # Pytest wrapper for Gemini
├── charts/sagemath-mcp/            # Helm chart for Kubernetes
├── scripts/                        # Build, release, CI scripts
├── docs/mcp_quickstart.md          # Client quickstart and prompt cookbook
└── .github/workflows/
    ├── ci.yml                      # 6 parallel jobs: lint, test (3.12+3.13), security
    │                               #   (pip-audit), helm, integration, smoke
    ├── release.yml                 # Multi-Python test, build, GHCR push, PyPI publish
    └── version-bump.yml            # Manual version bump + tag workflow

Technology Stack

ComponentVersionPurpose
FastMCP3.2+MCP server framework (tools, resources, middleware)
MCP SDK1.27+Model Context Protocol implementation
Pydantic2.12+Data validation and serialization for all models
anyio4.13+Async runtime abstraction
SageMath10.xMathematics engine (subprocess worker)
Ruff0.15+Linting and import sorting
pytest9.0+Test framework
pytest-asyncio1.3+Async test support
pytest-cov7.0+Coverage reporting (100% statement and branch coverage, gated in CI)
pip-audit2.9+Dependency vulnerability scanning
Hatchling1.29+Build backend
Docker---Containerization and CI integration testing
Helm3.15+Kubernetes deployment
GitHub Actions---CI/CD (Node.js 24 compatible)
Cosign---Container image signing

Changelog

Every released version, newest first. CHANGELOG.md carries the full detail; this is the shape of each release.

v0.6.1 (2026-08-16)

A security patch on 0.6.0.

Security

  • A critical sandbox escape in the curated import * feature. The screen vetted each export by its __module__, but a module object has none, so a re-exported module (sage.modular.dimsdirichlet) passed and became a pivot into the whole sage.* tree; dirichlet.free_module_element.sage.env.os.system('id') ran a shell. The screen now drops module-object exports and the validator refuses a terminal module name under any root.
  • The monitoring resource leaked another client's inputs and outputs — the free-text error, rejected-code and stdout fields are dropped from the process-global snapshot.

Added

  • Caller code may from <module> import * for a curated set of clean internal SageMath modules (generated into star_exports.py; nothing added to the allowlist).
  • evaluate_sage auto-declares symbol-shaped free names (w, x_2, alpha) the way SageMath's SR does, instead of refusing them — narrow and typo-guarded, and a session variable is never turned back into a symbol.

Changed

  • set_verbose is offered as a no-op (it only sets a global verbosity level, which has no surface over MCP); inject_shorthands is simulated so its names are readable; a literal attrcall('method') is accepted after its name is screened. Doctest-corpus acceptance rose 98.69% → 98.95%.

Fixed

  • The guarded attrcall wrapper is reinstalled after every namespace reseal; it had silently stopped working after the first specialised-tool call in a session.

v0.6.0 (2026-08-15)

A security and correctness release, and the largest so far. Output changes, so it is a minor bump rather than a patch: 2^3 is 8, x/y/z/t are predefined, and callers lose imports, the external CAS interfaces and show/view/latex/html.

Security

  • Caller code moved to a deny-by-default allowlist. A name is refused unless SageMath preloads it, it is a safe builtin, or the caller's own code bound it. This replaced a denylist over a namespace thousands of names deep, after a run of bypasses that were each a name nobody had forbidden.
  • A series of sandbox escapes closed, each with a regression test and each verified against real SageMath 10.9: string-path attribute access (operator.attrgetter, and Sage's own attrcall/raw_getattr/getattr_debug); pari and latex.has_file running shell commands; unpickle_global reachable after a tool call re-imported sage.all; and bindings authorizing names that already existed. See CHANGELOG.md and REVIEW_ACTIONS.md for the full list with reproductions.
  • Tool parameters refuse the names the namespace scrub removes, and cannot walk the sage module tree — the fragment path is validated independently of where sage_eval resolves.

Changed

  • evaluate_sage runs SageMath, not Python — the preparser is applied, so 2^3 is 8 and generator syntax parses.
  • x, y, z and t are predefined, matching the specialised tools' prelude; any other symbol needs var('w'), and the error says so.

Fixed

  • f(x) = x^2 + 1 (the tutorial's first line), find_root taking an equation, match statements and function('f') binding names, uniformly-indented code, and refusal messages that now name an actionable fix.

Added

  • Suites for the mathematics that must work (test_math_coverage.py), the research and physics sessions a user actually runs, and SageMath's own doctests executed through the server.

v0.5.0 (2026-08-14)

A correctness and hardening release. Output changes for large integers, so it is a minor bump rather than a patch.

Security

  • Caller strings reaching sage_eval-enabled generated code are now validated. Four tool parameters (graph_operation.graph, group_operation.group, coding_theory_operation.code_type, polynomial_ring_operation.base_ring) were interpolated raw, which was demonstrated reading files, running shell commands and opening outbound connections.
  • Forbidden names are rejected wherever they are read, not only where called: f = open, a lambda default, a list literal, and the same for modules (m = os, from sage.all import os as m).
  • The worker namespace no longer contains open, eval, exec, compile and the rest of that family.

Changed

  • Integer results at or above 2^53 are returned as decimal strings, and the same parameters accept them on the way in. JavaScript-based clients parse JSON numbers as doubles, so bell(30) was reaching one CLI as 846749014511809388871680 instead of 846749014511809332450147.
  • Matrix entries accept integers and decimal strings and stay exact; float matrices behave exactly as before.
  • interrupt_sage_session reports No running computation when nothing is running instead of claiming state was preserved. Signalling an idle worker could leave it unable to answer, costing the namespace it was protecting.
  • The container runs with a read-only root filesystem; the Helm chart gained readOnlyRootFilesystem and resource defaults.

Fixed

  • /health was never registered under FastMCP 3.x and returned 404 in every HTTP deployment.
  • is_convex returned true for concave and self-intersecting polygons.
  • Persisted sessions that had used a specialised tool could not be restored.
  • Streaming progress is bounded by both event count and characters, and a slow callback can no longer time out a finished evaluation.

Internal

  • server.py split from 2327 lines into app.py, runtime.py, codegen.py and a tools/ package; tool names, schemas and descriptions held identical by a snapshot test.
  • 100% statement and branch coverage, enforced in CI. CLI suites run nightly.

v0.4.0 (2026-08-13)

A correctness release: several tools returned wrong values or did not work at all, so output differs from 0.3.1. Named workspaces (start_sage_session/list_sage_sessions/stop_sage_session plus a session argument), interrupt_sage_session, and the first exact-integer guards on number_theory_operation.

v0.3.1 (2026-04-03)

Release-pipeline fixes only, no code change: Cosign lowercases the GHCR reference, the build installs build first, PyPI trusted publishing via a pypi environment.

v0.3.0 (2026-04-03)

18 tools to 33, all Sage-backed: symbolic_sum, combinatorics_operation, plot3d_expression, distribution_operation, find_root, plot_multi_expression and vector_calculus_operation.

v0.2.0 (2026-04-03)

The first substantial release: 18 MCP tools across calculus, algebra, linear algebra, ODEs, number theory, statistics and plotting; CLI integration suite; FastMCP 3.x migration; Docker pinned to SageMath 10.9; Helm health probes; Python 3.12 minimum.

v0.1.2 (2025-11-02)

Default HTTP port aligned to 8314 across code, docs and deployment artifacts; package published to GitHub Packages during release.

Roadmap

See ROADMAP.md for the full prioritized plan. Highlights:

Phase 1 — High-value tools:

  • symbolic_sum / symbolic_product --- symbolic summation and products
  • combinatorics_operation --- binomial, permutations, combinations, partitions, Catalan, Fibonacci
  • plot3d_expression --- 3D surface plots for two-variable functions

Phase 2 — Medium-value tools:

  • distribution_operation --- probability distributions (PDF, CDF, sampling, quantiles)
  • find_root --- numeric root-finding (complements symbolic solve_equation)
  • Multi-expression plotting --- overlay multiple functions in one plot
  • vector_calculus_operation --- gradient, divergence, curl, Laplacian

Phase 3 — Enrichment:

  • Richer evaluate_sage examples (Fourier/Laplace transforms, modular arithmetic, recurrences)
  • HTTP /health endpoint for Helm probes
  • Streaming partial output for long computations
  • Disk-backed session persistence

Requirements

  • Python 3.12+
  • A local SageMath installation available on the PATH (tested with Sage 10.x), or Docker.
  • FastMCP-compatible MCP client (e.g. Claude Desktop, Claude Code, Codex CLI, Gemini CLI).

Contributing

We welcome issues and pull requests! Review the Code of Conduct and Contributing Guide before opening a PR. For vulnerability disclosures, follow the steps in SECURITY.md. Ownership defaults are defined in .github/CODEOWNERS.

License

MIT

Rendered live from XBP-Europe/sagemath-mcp's GitHub README — not stored, always reflects the source repo.

1 Install Method

NameDescriptionCategorySource
pypi packageInstall via pypi (stdio transport)mcp-serversagemath-mcp

0 Comments

Login required
Log in to post a comment or update on this repo.

No comments yet — be the first to share an update.