simple-sage-mcp
<p align="center">
<img src="https://raw.githubusercontent.com/JGCoelho/simple-sagemath-mcp/main/images/simple-sagemath-banner.png"
alt="simple-sagemath-mcp" width="850">
</p>
# simple-sage-mcp
An MCP server that gives an agent a **persistent SageMath kernel** over stdio: one
long-lived Sage process that code is run in, interrupted, inspected and stopped.
Work outlives the call that started it, and the kernel outlives the server that
spawned it.
## Quickstart
SageMath must be installed, and its own interpreter must be able to `import
sage.all`. From a checkout:
```bash
./install.sh
```
That finds a working interpreter, writes the `sage` entry into the `mcp.json` next
to the install target, and **proves the registration works** by spawning the server
and running `2+2` through it. Another client needs the block it prints:
```json
{
"mcpServers": {
"sage": {
"command": "/path/to/python-with-mcp/bin/python",
"args": ["/path/to/sage-mcp/sage_mcp.py"],
"env": {"SAGE_PYTHON": "/path/to/sage-env/bin/python", "SAGE_CWD": "/tmp"},
"timeout": 600
}
}
}
```
`pip install git+https://github.com/JGCoelho/simple-sagemath-mcp` works too. Check a
registration at any time with `./install.sh --check` or
`simple-sage-mcp-check /path/to/python /path/to/sage_mcp.py`. `sage_mcp.py --help`
lists the options and the environment variables.
## Scope and safety
This is a **general kernel, not a curated math API, and not a sandbox**. Code sent
to `exec`, `run_file` or `notebook` runs as *you*, on your machine, with your
files and your network: the kernel is an ordinary Sage process, and
`SAGE_MCP_LIMITS` bounds its resources, not its privileges. There is no allow-list,
no seccomp, no filesystem or network isolation, and nothing stops a job from
reading `~/.ssh` or writing anywhere you can. So: run work you would run yourself,
do not drive it from untrusted input, and there is no HTTP transport to expose --
it is a stdio server over a private Unix socket, and that is the whole surface.
Linux only: it relies on `AF_UNIX`, `/proc` and `rlimits`.
## Persistent state and sessions
### One kernel per state directory
The kernel is a separate process that the server spawns on demand and then talks to
over a private socket. Its identity is the **state directory**,
`${SAGE_MCP_STATE:-/tmp/sage-mcp-$UID}`:
```
kernel.sock the listener (0700, AF_UNIX -- nothing on loopback to dial)
kernel.key 32-byte HMAC key the clients must present (0600)
kernel.json pid, start time, log path -- what `status` reads when the kernel is busy
running.json the job in flight, rewritten per job, so `status`/`interrupt` still work
kernel.log the kernel's own log (rotated at `SAGE_MCP_LOG_MAX`, 8 MiB by default)
```
Everything that points at that directory shares one kernel: several Pi sessions,
subagents, a restarted server. Jobs queue on it and run one at a time, on the
kernel's main thread. The first `exec` starts it (`status` says `not running` until
then); when the **last** server exits and the kernel is idle it stops itself. Point
two configurations at the same `SAGE_MCP_STATE` and they share the kernel; point
them at different ones and they cannot see each other.
Nothing lives in the package directory, and nothing is stored per call: `run_file`
and `notebook` work on the files you name, in place.
### Sessions are namespaces inside that kernel
A session is **a name for a namespace**, not a process: `session="default"` unless
you say otherwise, any other name is created the first time it is used. Each
namespace starts as a copy of `from sage.all import *`, and from then on holds only
what that session's own code defines. So `a = 1` in session `work` is invisible in
`default`, and both persist between calls -- the whole point of the kernel.
`reset` empties one namespace (it does not wait for the session to go idle, so do
not reset a session whose job you still want). `drop` frees the name, refusing a
session with a job in flight. Neither restarts the kernel, and neither touches other
sessions.
### What survives what
| event | variables | job in flight | counters |
| --- | --- | --- | --- |
| `exec` hits its `timeout_s` | kept | **keeps running**; `poll` collects it | live |
| the agent's turn ends | kept | keeps running | live |
| the MCP server exits or is restarted | kept -- the next server adopts the kernel | keeps running; the new server's `poll` collects it | live in the kernel |
| `interrupt` | kept | fails with `KeyboardInterrupt` | `interrupted` + 1 |
| `notebook` / `run_file` | unaffected | unaffected (different process) | -- |
| `interrupt(force=true)` | **lost** | stopped mid-flight | reset with the kernel |
| the kernel exits on its own, or `/tmp` is cleaned | lost | lost | lost |
The kernel is the authority on all of it; the server keeps no state of its own
except its identity and its view of the socket. That is why a `poll` works after a
server restart, and why counts (`jobs: N run, ...`) reset exactly when the kernel
does -- `counters_since` says since when.
## Tools
| tool | params (required → `*`) | returns |
| --- | --- | --- |
| `exec` | `code`*, `session`="default", `timeout_s`=120 (0 = return at once), `preparse`=true | stdout, then the repr of the last expression; on timeout the partial output plus a job id to `poll` |
| `poll` | `job_id`*, `wait_s`=0, `since`=0 | new output since the offset, ending in `[job finished in 6.0s]` or `[still running 12s; ...]` |
| `status` | -- | kernel/session/job/client state, last error, limits, log path |
| `interrupt` | `force`=false | `SIGINT` the running job (it fails with `KeyboardInterrupt`, variables kept); `force=true` stops the kernel and loses them |
| `reset` / `drop` | `session`; `sessions`=[], `all`=false | empty a namespace; free idle sessions |
| `run_file` | `path`*, `lang`="auto", `args`=[], `timeout_s`=120, `stdin`, `cwd` | exit code and duration, then stdout/stderr inline; a non-zero exit is an error carrying both |
| `notebook` | `action`*, `path`*, `out`, `in_place`, `cell_timeout_s`=120, `timeout_s` | `check` / `extract` / `clear` / `run`; all four run in Sage's interpreter, and the tool is advertised only when *that* interpreter has `nbformat`+`nbconvert`+`nbclient` |
Five short transcripts with the real output -- a job outliving its `exec`, an
interrupt, `run_file`, sessions, notebooks -- are in [`examples/`](https://github.com/JGCoelho/simple-sagemath-mcp/tree/main/examples).
## Jobs, and why `poll` exists
`exec`'s timeout **cancels nothing**: a Gröbner basis or a graph enumeration that
takes twenty minutes is normal work for Sage, and a timeout that threw it away would
be a bug. So `exec` answers with a job id (or the finished output, if it got there
within `timeout_s`), and `poll(job_id, since=N)` returns the output produced since
offset `N` -- incremental, never a re-run. To cancel, do it explicitly: `interrupt`,
then `poll` to collect the `KeyboardInterrupt`. Silence is never read as "cancel".
A wait can be longer than your client's request timeout, so `exec`, `poll`,
`run_file` and `notebook` send an MCP progress notification every 30 s while they
wait; a progress notification resets that timeout (pi's default is 60 s, and its
`mcp.json` entry for `sage` sets `"timeout": 600`). A client with a tighter
timeout lowers the interval with `SAGE_MCP_PROGRESS_SLICE`. A client that asks for no
progress keeps the old behaviour exactly, which is why `timeout_s=0` plus `poll`
is still the shape for a job that may run for hours.
`interrupt` is owner-checked: only the client whose job is running may send it.
`interrupt(force=true)` is not, on purpose -- it is the escape hatch for a kernel
wedged past answering its owner, so it is **machine-level**: any client sharing the
state directory can stop the kernel and destroy another's work. `DESIGN.md`, the
tool description and a test say so, so it cannot change quietly.
## Why Sage, not Python
The kernel preparses by default, so this is **Sage's REPL**, not Python with
`sage.all` imported: `2^10` is 1024, `1/2` stays the rational `1/2`, `type(2)` is
`Integer`, and `R.<x> = QQ[]` works. An ipykernel with `from sage.all import *`
silently gets all of these wrong.
## Architecture
`sage_mcp.py` is the stdio front end, built on the official MCP Python SDK
(`MCPServer`), so schemas, validation and the protocol come from the SDK.
`sage_client.py` spawns and talks to the kernel: it holds a lock, presents the
authkey, and distinguishes *busy* (alive, not answering -- a running computation
holds the GIL) from *gone*, because respawning a busy kernel is how twenty minutes
of work disappears. `sage_kernel.py` is the long-lived process: it runs jobs on the
**main thread** so `cysignals`-based Sage (Singular, PARI, GAP) behaves, publishes
the in-flight job to `running.json`, and refuses to let a job's failure or an idle
`SIGINT` take the kernel with it. `subproc.py` is the child-process core behind
`run_file` and `notebook`, `limits.py` the `SAGE_MCP_LIMITS` knob. See
[DESIGN.md](https://github.com/JGCoelho/simple-sagemath-mcp/blob/main/DESIGN.md) for the reasoning and `tests/conformance.py` for the
protocol contract.
`sage://kernel/status` (`application/json`) is the machine-readable form of
`status`: `pid`, `state`, uptime, `restarts`, the `jobs` counters, `last_error` with
the job and session that produced it, `sessions`, `limits` (with `source: kernel`
when the kernel answered, `configured` when it did not), and the log tail -- read
from the file, so it answers while a job runs.
## Requirements
- Linux.
- **SageMath installed, separately from this server.** `pip`/`uvx` install the
server only: the kernel is Sage's own interpreter. If it cannot be found, both
`status` (`sage interpreter: NOT FOUND -- …`) and `exec` say so, and
`exec`'s error names the three discovery routes and the one-line fix.
- **Two interpreters, on purpose.** The *server* is any Python 3.10+ with the `mcp`
package, and nothing else; the *kernel* is Sage's own interpreter, found at
runtime by `SAGE_PYTHON`, then `sage -c 'import sys;print(sys.executable)'`, then
the python beside `sage` on `PATH`. A Sage crash cannot take the transport down,
and `mcp` never has to be installed into the Sage environment.
- `notebook` needs `nbformat`+`nbconvert`+`nbclient` in the *kernel's* interpreter
(they ship with SageMath).
## Configuration
| variable | default | meaning |
| --- | --- | --- |
| `SAGE_PYTHON` | discovered | interpreter that must `import sage.all` -- the kernel |
| `SAGE_MCP_STATE` | `/tmp/sage-mcp-$UID` | the state directory above; the kernel's identity |
| `SAGE_CWD` | server's cwd | working directory for `exec` and `run_file` |
| `SAGE_MCP_LIMITS` | `cpu=off,as=8G,fsize=1G,nproc=off` | per-process limits, below |
| `SAGE_MCP_GAP_LIMIT` | `3` (`0` disables) | GAP crash-loop guard, below |
| `SAGE_MCP_LOG_MAX` | `8` MiB (`0` disables) | rotate `kernel.log` to `kernel.log.1`, one generation |
| `SAGE_MCP_PROGRESS_SLICE` | `30` | seconds between progress reports while `exec`/`poll`/`run_file`/`notebook` wait; lower it for a client whose request timeout is tighter than that |
`SAGE_MCP_LIMITS` bounds what the kernel and the children `run_file`/`notebook` spawn
may consume, so a runaway job fails instead of taking the machine down:
| limit | default | scope | on trip |
| --- | --- | --- | --- |
| `cpu` | off | kernel, from just after Sage's import | `SIGXCPU` caught: the job fails with `ResourceLimitError` |
| `as` | 8 GiB | kernel + children | `MemoryError` → job error |
| `fsize` | 1 GiB | kernel + children | `SIGXFSZ`/`EFBIG` → job error |
| `nproc` | off | **per UID, machine-wide** | `fork`/`Popen` gets `EAGAIN` → that job or child spawn fails |
| core | always `0` | kernel + children | no core dumps |
Sizes take `K`/`M`/`G`; `off`/`0` leaves the inherited limit alone; unmentioned keys
keep their default, so `SAGE_MCP_LIMITS="cpu=60"` only adds a CPU budget. A typo is
an error naming the knob. Only the *soft* limit is lowered, so a job that needs more
room gets it by restarting the kernel (`interrupt(force=true)`) under a different
`SAGE_MCP_LIMITS`. `nproc` is a margin over the tasks your UID already runs, armed
before each job -- not a count of the kernel's children. A job that trips a limit
fails with `ResourceLimitError: RLIMIT_...`, its text is prefixed with `[limits]`,
the kernel survives, and `status` says which limit applies.
`SAGE_MCP_GAP_LIMIT` is the **GAP crash-loop guard**. `structure_description()` on
hostile input makes GAP crash-restart in a loop, forking a process per crash, and no
timeout catches it because the cell never finishes. The kernel counts the
`Gap crashed` / `Restarting Gap` markers as they are written and, at the limit,
`SIGINT`s that job once -- the same signal as `interrupt`, so it ends as a
`KeyboardInterrupt`, the session keeps its variables, and `poll` collects it. The
result is prefixed with `[gap-guard] interrupted after N 'Gap crashed' markers ...`,
the trip is logged, and `status` counts it. The evidence is the **writer**, not the
text: only a write from `sage/interfaces/gap.py` (stdout or stderr) -- or a stderr
write from a job that has already written through it -- counts, so echoing the words
cannot trip it, and importing the module (which `from sage.all import *` does for
every session) is not evidence. If the job catches the interrupt and runs on, the
result says `SIGINT was sent but the job continued` instead of claiming an
interruption that did not happen.
## Tests
```bash
# 24 conformance cases, one row each, ~70 s
/path/to/sage-env/bin/python tests/conformance.py # or --only C13,C15
# 154 checks over MCP stdio through the official client, ~220 s
/path/to/sage-env/bin/python tests/acceptance.py
# one suite instead of all of them (~7 s): the names come from --list-suites
/path/to/sage-env/bin/python tests/acceptance.py --list-suites
/path/to/sage-env/bin/python tests/acceptance.py --suite discovery_visibility_suite
# 10 checks as a Sage script, ~10 s (`singular` must be on PATH)
PATH=/path/to/sage-env/bin:$PATH sage tests/interrupt_ownership.sage
# the frozen tools/list contract (8 tools, 0 mismatches)
/path/to/sage-env/bin/python tests/snapshot_tools.py --compare
```
`acceptance.py` guards what is easy to regress: busy-vs-dead, spawn serialisation,
stale state files, dead launchers, an honest stop, output caps, `since` offsets, log
isolation, shared-kernel ownership, socket privacy and the authkey, adoption across a
server restart, the three discovery routes, a cancelled `exec` still being
recoverable, the `run_file` contract, both halves of the two-interpreter rule for
`notebook`, the GAP guard (writer-based, and honest about a swallowed signal), the
limits (each fails its job and leaves the kernel alive), the status resource and its
counters, and `--check` driving a registration in the client's own environment.
`sage_mcp.py --check` (behind `install.sh --check` and `simple-sage-mcp-check`) is
the 8-check smoke test; its last check runs the C01/C04/C08/C17/C20 subset of the
matrix **against the registration** -- its `command`, `args` and `env` exactly as
written -- so a registration that only works because *your* shell exports
`SAGE_PYTHON` is reported as broken, the way the client would find it. Without
`tests/` (pip/uvx ship none) that subset is `SKIP` and left out of the totals; a
skipped check is never a pass.
## Not in this release
Honest gaps, in the order they would be closed:
- **No CI.** The suites run by hand (they are in `Tests` above). A realistic
split, written down in `PLAN.md` §11: a fast job on GitHub-hosted runners with
no SageMath (byte-compile every module, `--help`/`--version`, `--suite
cli_suite`, the no-Sage diagnosis, and `server.json` against the registry
schema -- ~1 minute), and a full job inside the `sagemath/sagemath` image for
the kernel suites (~6 minutes plus the image pull). The fast job cannot run the
conformance matrix: C01 asserts the eight tools, and without Sage `notebook` is
correctly not advertised.
- **Not on PyPI yet.** The wheel and sdist build clean (`python -m build`,
`twine check`) and `server.json` is ready, but the release is deliberately
unpublished for now; install from git. A published version is permanent, and
the security posture ("no sandbox") has to be right before strangers run it.
- **No Docker image.** The only way to get `pip`/`uvx` users without SageMath
working, and the honest way to make "reproducible" true.
- **`tests/` is not shipped**, so `--check`'s conformance subset reports `SKIP`
for an installed package (never counted as a pass).
- **One maintainer, Linux only** (`AF_UNIX`, `/proc`, `rlimits`).
- Two `nproc` checks depend on the machine-wide task count of your UID, so a
heavily loaded machine can still flake them; the test margin is 16 with 56 fork
attempts to make the drift irrelevant.
## License
MIT -- see `LICENSE`. What changed in each release: `CHANGELOG.md`.
TDQS
Scored across 7 tools
Most tools map to distinct lifecycle operations: interrupt vs. drop, reset vs. drop, and poll vs. status are well separated. The main overlap is that exec and run_file both execute code, so an agent must rely on the descriptions to choose the right context.
All names are lowercase and terse, with no casing inconsistency. However, the set mixes imperative verbs, one compound name (run_file), an abbreviation (exec), and a noun (status), so it is not a fully predictable verb_noun pattern.
Seven tools is well within the healthy range for a kernel/session management server. Each tool earns its place by covering a distinct role: execute, run file, collect output, interrupt, inspect status, reset namespace, and drop sessions.
Core lifecycle coverage is strong: execution, async polling, interruption/kill, status, namespace reset, session drop, and file execution are all present. Minor gaps exist, such as no explicit session-creation or queued-job-cancel operation, but agents can work around them with exec, interrupt, and status.