stem-mcp
v0.2.0
Published
MCP server for STEM teaching: rendered math (KaTeX/MathJax + mhchem), SymPy checks, formula library, slide decks, channels, RBAC.
Maintainers
Readme
stem-mcp
An MCP stdio server for teaching STEM. It renders LaTeX (KaTeX or MathJax, with
mhchem and physics), checks algebra against SymPy, keeps versioned libraries
of formulas, code snippets and assets (figures, animations, HTML fragments) with
one full-text search each, builds self-contained slide decks, lesson pages and a
standalone equation editor, and runs simple problem/question channels with a
shared whiteboard — all behind a role matrix
(instructor / TA / student / guest).
Everything is offline. No network call is made at render time and no rendered artifact references an external URL, so a deck opens the same on a projector with the Wi-Fi off.
Run it
npx stem-mcp --role instructorClient config (Claude Code, Claude Desktop, or any MCP client):
{
"mcpServers": {
"stem": { "command": "npx", "args": ["stem-mcp", "--role", "student"] }
}
}Documentation
- docs/API.md — every one of the 30 tools: args, result shape,
the role that may call it, and error shapes, grouped by area, plus the
REST/WebSocket facade contract. A drift-guard test
(
test/api-doc.test.mjs) keeps it in lockstep with the registry. - docs/DEPLOY.md — MCP client config, the
--servefacade behind a reverse proxy, data-dir layout and backup, SymPy/.venvsetup, and the three test modes. - templates/ — two ready-to-edit lesson-deck skeletons (one
math, one physics) with placeholder
{{ slots }}, valid against the deck schema and lint-clean.
Tools (30)
| Tool | What it does |
| --- | --- |
| server_status | Role, engine, data dir, SymPy availability, record counts |
| render_math | LaTeX → HTML+MathML (KaTeX) or SVG (MathJax); mhchem/physics supported |
| render_fragment | Markdown with $…$ / $$…$$ → export-friendly HTML fragment |
| notation_lint | ISO 80000-2 typography lint for LaTeX |
| eq_check | Parse + lint one expression; structured errors with positions |
| eq_transform | SymPy: simplify / expand / factor / solve / diff / integrate |
| eq_verify | SymPy: is LHS ≡ RHS? |
| formula_add | Add a formula to the library (instructor) |
| formula_update | Patch a formula; the previous revision is kept (instructor) |
| formula_get | Fetch a formula, optionally at a past version |
| formula_search | Full-text formula search, filtered by subject and tags |
| snippet_add | Add a code snippet (TA+) |
| snippet_get | Fetch a snippet, optionally at a past version |
| snippet_search | Full-text snippet search, filtered by subject |
| asset_add | Add a figure, animation or HTML fragment, with its provenance (TA+) |
| asset_update | Patch an asset; the previous revision is kept (instructor) |
| asset_get | Fetch an asset with its content or path, optionally at a past version |
| asset_search | Full-text asset search, filtered by subject, tags and kind |
| deck_create | Create a slide deck (TA+) |
| deck_update | Patch a deck; the previous revision is kept (TA+) |
| deck_render | Render a deck to one standalone HTML file (optional out_path) |
| lesson_page_render | Render the same deck as one scrolling lesson page (optional out_path) |
| deck_search | Full-text deck search over title, session and concepts |
| editor_export | Export a standalone equation editor: LaTeX in, live preview, MathML out (optional out_path) |
| channel_create | Create a collaboration channel (instructor) |
| channel_list | List channels with post counts |
| post_create | Post a problem / question / lecture-session to a channel |
| post_reply | Reply to a post; the reply's math renders through the pipeline |
| whiteboard_append | Append a stroke or a clear to a channel's shared whiteboard |
| whiteboard_get | Read a channel's whiteboard: every op, in the order it was drawn |
Roles
The matrix is additive — each role is the one below it plus a little more:
| Role | Adds |
| --- | --- |
| guest | The read-only floor: server_status, render_math, render_fragment, notation_lint, eq_check, eq_transform, eq_verify, formula_get, formula_search, snippet_get, snippet_search, asset_get, asset_search, deck_search, deck_render, lesson_page_render, editor_export, channel_list, whiteboard_get |
| student | post_create, post_reply, whiteboard_append — taking part in channels and drawing on the shared board |
| ta | deck_create, deck_update, snippet_add, asset_add — deck, snippet and asset authoring |
| instructor | formula_add, formula_update, asset_update, channel_create — formula-library writes, edits to published assets, and channel creation |
Anything absent from the matrix is denied, so an unmatrixed tool fails closed.
guest writes nothing at all: it is a strict subset of student, meant for an
unauthenticated reader (a public formula browser, a shared render endpoint).
A student or guest deck_render always produces the student deck, whatever
audience the call asks for. out_path on the three export tools
(deck_render, lesson_page_render, editor_export) is instructor/TA only — a
student or guest export never chooses its own destination, so a read-only role
never gains a file-write primitive — and, for every role, a path naming one of
the data-dir store files
(formulas.json, snippets.json, decks.json, channels.json, assets.json) is refused
rather than allowed to overwrite the library. Omit out_path and a deck or
lesson page lands in <data-dir>/renders/; editor_export hands the page back
inline instead, because it is the same page every time and has no default worth
writing to.
Configuration
| Flag | Env | Default | Values |
| --- | --- | --- | --- |
| --data-dir | STEM_MCP_DATA_DIR | ~/.stem-mcp | any writable directory |
| --role | STEM_MCP_ROLE | instructor | instructor, ta, student, guest |
| --engine | STEM_MCP_ENGINE | katex | katex, mathjax |
| --serve [port] | STEM_MCP_SERVE | off | a port; bare --serve means 8737, 0 means any free port |
| --serve-host | STEM_MCP_SERVE_HOST | 127.0.0.1 | the interface the facade binds |
| — | STEM_MCP_PYTHON | python3 | interpreter used for the SymPy bridge |
The data dir holds formulas.json, snippets.json, assets.json, decks.json,
channels.json and a renders/ folder. The formula, snippet and asset stores are
seeded on first use only; a readable existing file is loaded as-is, and an
unparseable one is quarantined beside itself rather than silently discarded.
Assets and provenance
An asset is a figure, an animation or an HTML fragment. All three share one record shape and one store:
{
"kind": "figure", // figure | animation | fragment
"name": "Right triangle with labelled legs and hypotenuse",
"subject": "math", // math | physics | chemistry | statistics
"format": "svg", // svg | manim-py | html | mp4-ref
"content": "<svg …>", // inline source — OR —
"path": "…/scene.py", // where it lives, for anything not inlined
"caption": "…",
"source": "…",
"provenance": { "origin": "…", "interpretation": ["…"] },
"tags": ["geometry"],
"description": "…"
}Exactly one of content and path. Both at once would leave a reader
guessing which is current; neither leaves a record naming an asset nobody can
fetch. Inline content is capped at 256 KB — the store is one JSON file
rewritten on every flush, not a blob container — so anything bigger goes on disk
and is referenced by path. The rule is checked against the record an
asset_update would produce, so switching an asset from a reference to inline
source means sending the new field and nulling the old one
({content: "…", path: null}).
Provenance is the point. origin says where the thing came from — drawn from
a stated theorem, recreated from a figure transcript, referenced from a lesson
repository — and interpretation lists the judgement calls made along the way,
so a figure can be re-derived or argued with later instead of being trusted
blindly. The convention is lifted from the lesson figures' .meta.json files in
pipeline/stem-content/iemh101/assets/figures/, and the two seeded Manim scenes
are path references into pipeline/stem-content/iemh101/animations/ — the
canonical example of an asset the library points at rather than copies, because
the lesson repository stays its single source of truth.
asset_search and deck_search return metadata, never the payload: a hit list
carrying twenty inline SVGs (or twenty decks' slides) is unusable and, for an LLM
client, expensive. An asset hit reports bytes — the length of the inline
content, or null for a path reference — and asset_get fetches the real thing.
The seeded assets are original work and self-contained: the only http: string
in them is the SVG namespace declaration, which is an identifier and is never
fetched.
The equation editor
editor_export builds one HTML file that is an equation editor: type LaTeX on
the left, read it typeset on the right, then Copy LaTeX or Copy MathML
(the bare <math> element, ready to paste into a document or a page). A palette
inserts seven templates at the cursor — fraction, square root, sum, integral,
matrix, cases, and \ce chemistry — and a display style toggle switches
between inline and display layout. An expression that does not parse is marked
in red and reported, not silently dropped.
Give it to a class, put it on a USB stick, attach it to an email. It fetches
nothing. KaTeX's engine, its stylesheet, the mhchem extension and all twenty
WOFF2 math faces are embedded, so it works from file:// with the network off,
and the build refuses to emit a page that references anything else.
What it cannot claim is the deck's literal zero-http: output: a deck strips
the xmlns declarations out of maths it has already rendered, whereas the editor
ships a live engine that still needs those namespace constants for every
expression not yet typed. So the seven http:// strings in the page are all one
of two W3C namespace URIs — the same static exception the seeded assets carry,
and identifiers no browser resolves — and the guarantee here is an allow-list
rather than a strip: lib/editor.mjs refuses at build time to emit a page
containing any URL that is not one of those two.
Size, measured rather than guessed: ~0.68 MB. The worry was that fonts would
push a fully-inlined page past a megabyte and a half and force a subset —
dropping \mathfrak, \mathscr, \textsf and \texttt to fallback
system faces. They do not, because only WOFF2 is inlined and the stylesheet's
WOFF and TTF fallbacks go with the url() refs they lived in:
| Part | Bytes |
| --- | --- |
| katex.min.js | 272,537 |
| 20 WOFF2 faces | 259,792 on disk → 346,412 base64 |
| contrib/mhchem.min.js | 33,706 |
| katex.min.css | 23,827 |
| assembled page | 682,322 |
So the whole face set ships and there is no rendering degradation — every
KaTeX font command looks exactly as it does online. The subset branch would have
saved about 170 KB of an already-small file in exchange for output that is
quietly uglier in ways an author only discovers in front of a class. A 2.5 MB
ceiling is enforced in lib/editor.mjs, so a future KaTeX that ships far more
fonts fails loudly at build time instead.
Nothing KaTeX ships is copied into this package: the page is assembled from
node_modules/katex when you export it, so the version you get is the version
you installed.
Channels and the whiteboard
A channel is a place to put a problem, a question or a lecture session; posts get
replies whose maths renders through the same pipeline as everything else. Each
channel also has a whiteboard: an append-only log of ops — a stroke (a
polyline of [x, y] points, optional color) or a clear — with the author's
role and a timestamp stamped by the server, not by the caller. A clear is itself
an op, so the history stays intact and whiteboard_get always replays the whole
board in draw order.
It lives in the store, not in a socket, which is what makes it work at all for an
MCP client: whiteboard_append / whiteboard_get are ordinary tools, and a
client with no HTTP facade running has the same board as a live one, minus the
liveness. It is bounded the same way inline asset content is — 1000 points per
stroke, 20000 points and 2000 ops per board — because channels.json is one file
rewritten on every flush.
Serving HTTP and WebSocket (--serve)
Off unless you ask. With no --serve, no port is bound, no socket is opened,
lib/http-facade.mjs is never imported and ws is never loaded — the process is
the stdio MCP server and nothing else.
npx stem-mcp --role student --serve 8737The endpoints are thin wrappers over the same handlers the MCP tools use, so the role check, the input caps, the store locks and the error text are identical by construction — one implementation, two doorways.
| Method | Path | Tool |
| --- | --- | --- |
| GET | /status | server_status |
| POST | /render | render_math |
| POST | /eq/verify | eq_verify |
| POST | /eq/transform | eq_transform |
| POST | /formulas/search | formula_search |
| POST | /assets/search | asset_search |
| POST | /decks/:id/render | deck_render |
| POST | /whiteboard/:channelId/append | whiteboard_append |
| GET | /whiteboard/:channelId | whiteboard_get |
| WS | /ws | subscribe to a channel's live events |
The request body is the tool's arguments as JSON; a path parameter always wins
over a body field of the same name. Status codes carry transport failures
only — 404 for a path that is no route, 400 for a body that is not a JSON
object, 413 for one over 1 MB. A tool-level refusal (an unknown id, a denied
role, an oversized LaTeX argument) is a 200 carrying the tool's own body plus
isError: true, because it is an answer, not a broken request.
Live updates. Open a socket to /ws, send {"type":"subscribe","channelId":"…"},
and every post_create, post_reply and whiteboard_append on that channel
arrives as a JSON event ({"type":"post"|"reply"|"whiteboard", …}).
{"type":"unsubscribe","channelId":"…"} stops it. The store is the source of
truth and the socket is a relay: every event is written and flushed before it is
broadcast, and the broadcast is fire-and-forget, so a subscriber that is absent,
slow or disconnected misses notifications and never data. Reconnect and replay
the truth with whiteboard_get / channel_list.
The socket is a read path, so it goes through the same role matrix the tools do — per event, not once at the door, because the feed is not uniform:
| Event | Carries | Needs |
| --- | --- | --- |
| whiteboard | exactly what whiteboard_get returns | whiteboard_get — the guest floor |
| post, reply | the post body and the rendered reply HTML | post_create — the student floor |
Subscribing at all needs channel_list. The second row is the honest part: no
tool returns post or reply bodies (channel_list gives counts), so the live
feed is a wider read surface than the tool registry, and it is gated a tier
higher for that reason — a --role guest instance's sockets watch the
whiteboard and are told nothing about the conversation. An event type the matrix
does not name is relayed to nobody. When a post-read tool exists, that gate moves
to it.
Roles are per instance, and there is no authentication. The process was
started with one --role, and every HTTP and WebSocket caller has exactly that
role — there is no header, token or body field that changes it. A deployment that
needs several roles runs several instances, one per role, on separate ports.
Real per-caller auth is a v2 item.
That is also why the facade binds 127.0.0.1 by default. Anything reachable from
the network is reachable as that role, by anyone — including, for an
instructor instance, out_path on the render tools, which is a file-write
primitive. If you must expose it, put a reverse proxy in front that does the
authentication and authorization, run the instance at the lowest role that works
(guest for a public formula browser), and set --serve-host deliberately
rather than by accident.
Degradation and gotchas
SymPy is optional. If python3 or sympy is missing, server_status
reports sympy.available: false with a reason, and eq_transform / eq_verify
return {ok: false, error: {available: false, reason, fallback_hint}} — never a
throw, never a hang. Rendering, linting, the library, decks and channels all work
without it. To enable the equation tools:
pip install sympy latex2sympy2_extendedStrict parse. Malformed LaTeX is rejected rather than silently truncated —
x - is an error, not x. Wrong answers are worse than no answer when the
output is going in front of a class.
A patch is judged by the record it would produce. formula_update,
deck_update and asset_update take a free-form patch — they must, since a
patch names any subset of the fields — so the merged body is checked against the
record's own shape before anything is written: an update can never leave a record
that the matching formula_add / deck_create / asset_add would have refused.
A key the shape does not name is refused, not quietly stored: each store is a
single JSON file rewritten in full on every flush, so a mistyped field is dead
weight in every later read of that store and a deliberate one is somewhere to
park a couple of megabytes. Refusal rather than silent stripping, so a caller who
meant something by the key finds out. The rule applies at every depth — a junk
key inside a slide, a block, a variable or a provenance object is refused exactly
as a top-level one is. A record written before this check existed and already
carrying such a key is not stuck: the stored record is pruned of unknown keys, at
every depth, before the merged body is checked, so the junk is dropped by the next
update rather than blocking it forever — and the revision that carried it keeps
it, because history is history.
deck_create prunes inbound instead of refusing. The one exception to the
refuse-unknown-keys rule above: a submitted deck is a renderer's input, and a
content pipeline stamps non-rendered authoring metadata onto its decks — per-slide
concepts/eqRefs, per-block figure/probe, a top-level source block, none
of which stem-mcp renders. So deck_create prunes every key the deck schema does
not name, at every depth, before validation, and stores the canonical (pruned)
deck; the rendered output is byte-identical to the schema-reduced deck. The
pipeline's own deck.json stays its source of truth for the dropped metadata.
This is scoped to deck_create on purpose — the portable deck schema gains no
content-specific fields — and is the inbound mirror of deck_update, whose patch
still refuses an unknown key.
Escape currency. In render_fragment and any deck or post markdown, a
literal dollar sign must be written \$; an unescaped $ may open a math span
and swallow the text after it. Math inside fenced code blocks and inline code
spans is left as code and never rendered.
Input limits. LaTeX arguments (render_math, eq_check, eq_transform,
eq_verify) are capped at 4000 characters and render_fragment's markdown at
64000; the per-equation LaTeX pulled out of a fragment is bounded by that in
turn. Over the limit is a structured tool error naming the argument and the
limit, not a wedged renderer or a recursion blow-up in SymPy. Control characters
(U+0000–U+0008, U+000B, U+000C, U+000E–U+001F) are refused the same way, with
the offending code point and its offset; tab, newline and carriage return are
ordinary text and pass through.
Fragments are not sandboxed. URLs you put in markdown (links, images,
\href) reach the output as written. Rendered decks, lesson pages and the
equation editor are verified self-contained; keeping a hand-authored fragment
offline is the author's job. KaTeX runs at its default trust: false, so \href{…}{x} never becomes a
link there — it renders as flagged error text. MathJax does pass \href
through, as an href attribute on the rendered node, and the external-url
lint on deck_render / lesson_page_render is what surfaces it: the URL is
reported, not stripped. That is the v1 decision, restated and unchanged —
authored content stays trusted, the lint tells you what a render actually
contains, and the author decides.
Trust model
The SymPy bridge spawns a caller-local python interpreter (python3, or
STEM_MCP_PYTHON) with a minimal environment — PATH only — and cwd set to
the system temp dir. That keeps the server's own environment out of the child
and stops a relative path from resolving against whatever directory the MCP
client happened to launch us in. It is process hygiene, not a sandbox or a
jail: the worker runs with your user's privileges and can read and write
anything you can. Point STEM_MCP_PYTHON only at an interpreter you trust, and
treat LaTeX arriving from untrusted users as input to your machine.
A venv interpreter still works under that stripped environment — it finds its
own prefix from its own location, so VIRTUAL_ENV and PYTHONPATH are not
needed:
STEM_MCP_PYTHON=/path/to/.venv/bin/python npx stem-mcp --role instructorDevelopment
npm test # full suite
npm run test:nopython # same suite with the SymPy bridge forced unavailable
npm run test:sympy # same suite, but FAILS if SymPy is missing instead of skippingBoth modes must be green: the second proves the degradation path, with the
python-dependent tests skipping rather than failing. test:sympy is the
opposite gate — it checks STEM_MCP_PYTHON (default python3) can
import sympy before running, so a green run really means the SymPy path ran.
v2
Adapters (stem-mcp-adapter-manim, stem-mcp-adapter-mathscene), granular
slide CRUD, and fragment markers that compile to both HTML step-reveals and
PDF subslides are deferred to v2 — see
docs/superpowers/specs/2026-07-27-stem-mcp-design.md §11.
