# stem-mcp API reference

Every tool the server registers, derived from `createHandlers()` in `server.mjs`
(the authoritative handler table) and `ROLE_MATRIX` in `lib/rbac.mjs`. The list
is kept honest by `test/api-doc.test.mjs`, which fails if a registered tool is
missing here or a tool documented here is not registered — so this file cannot
drift from the code.

**30 tools**, grouped by area: [render](#render), [equation](#equation-checking-sympy),
[formula library](#formula-library), [snippet library](#snippet-library),
[asset library](#asset-library), [decks & pages](#decks--pages),
[editor](#editor), [channels & whiteboard](#channels--whiteboard),
[status](#status). A [REST / WebSocket](#rest--websocket-facade) section maps the
optional HTTP facade onto these same handlers.

## Conventions

**Roles.** The matrix is additive — `guest` ⊂ `student` ⊂ `ta` ⊂ `instructor`.
Each tool below names the lowest role that may call it:

- **guest+** — every role, including an unauthenticated reader. The read-only floor.
- **student+** — `student`, `ta`, `instructor`.
- **TA+** — `ta`, `instructor`.
- **instructor** — `instructor` only.

**Result envelope.** Every handler returns the MCP tool-result shape
`{ content: [{ type: "text", text: "<JSON>" }] }`; the tables below describe the
JSON inside `text`. The HTTP facade unwraps it to the bare JSON (see the REST
section).

**Two error shapes.**

1. **Structured tool error** — a thrown failure (RBAC denial, an input over a
   cap, a control character, a record that fails validation) is caught at the one
   choke point and returned as
   `{ isError: true, content: [{ text: "{\"error\":\"…\"}" }] }`. Over REST this
   is a `200` carrying `{ isError: true, error: "…" }`. The message is the same
   byte-for-byte over stdio and HTTP.
2. **In-band failure** — a *normal* (non-`isError`) result that reports a
   miss or a degraded dependency:
   - `{ error: "<thing> not found" }` — an id that names no record. Not an
     `isError`: a lookup that found nothing is an answer.
   - `{ ok: false, error: { available: false, reason, fallback_hint } }` — the
     SymPy tools when the bridge is unavailable (see [degradation](#degradation)).

**Records.** Every stored record carries `id` (8 hex chars), `version`,
`createdAt`, `updatedAt`, and `revisions[]` (prior bodies) on top of its
caller-supplied fields. `id`, `version`, `revisions`, `createdAt`, `updatedAt`
are reserved: the store owns them, and a body or patch naming one is stripped at
the boundary. `_get` tools take an optional `version` to fetch a past revision.

**Update tools take a free-form `patch`** (`z.record(z.any())`) because a patch
names any subset of a record's fields. The merged body is then validated against
the record's *full* strict shape, so an update can never leave a record the
matching `_add` / `_create` would have refused — and a key the shape does not
name is **refused**, not stored, at every depth.

**Unrecognized keys — two directions, two behaviours.** (1) A *patch* that
introduces a key the current schema does not name is **refused**: the update
fails and nothing is written. (2) A key that is *already present* in the stored
record but is no longer recognized by the current schema — written before a
schema tightened, or by an older build — is **silently dropped** on the next
successful update of that record, **even when the patch never mentions it**,
because the stored record is pruned of unknown keys (at every depth) before the
merged body is validated. The dropped value is retained only in that record's
`revisions[]` history; it is never again returned by `_get` / `_search`. Both
halves apply to `formula_update`, `asset_update` and `deck_update`.

**`deck_create` is the one *inbound* exception.** The `_add` / `_create` tools
otherwise **refuse** an unknown key on the way in, exactly as an update *patch*
does. `deck_create` instead **prunes** unknown keys (at every depth) from a full
submitted deck before storing it, so a content pipeline's deck — which stamps
non-rendered authoring metadata (per-slide `concepts`/`eqRefs`, per-block
`figure`/`probe`, a top-level `source` block) onto every deck — validates,
stores, and renders **unmodified**. The stored deck is the canonical (pruned)
form; the rendered HTML is byte-identical to the schema-reduced deck. This is
deliberately scoped to `deck_create` (a renderer accepting an authoring
superset), not a general loosening of the record schemas. See
[`deck_create`](#deck_create).

---

## Render

### `render_math`

Render one LaTeX expression. **guest+**

- **args:** `latex` (string, ≤4000 chars, required); `display` (bool, block vs
  inline); `engine` (`"katex"`|`"mathjax"`, default = server's `--engine`);
  `format` (`"html"`|`"mathml"`|`"svg"`). `svg` forces MathJax; KaTeX cannot emit SVG.
- **result:** `{ ok: true, engine, format, output, mathml }` — `output` is the
  rendered markup, `mathml` the MathML twin. On a parse failure:
  `{ ok: false, errors: [{ message, position? }] }`.
- **errors:** control char / over-cap `latex` → structured tool error.

### `render_fragment`

Markdown with `$…$` / `$$…$$` math → a self-contained HTML fragment. **guest+**

- **args:** `markdown` (string, ≤64000 chars, required); `engine` (optional override).
- **result:** `{ ok: true, html, errors: [{ latex, message }] }`. A per-equation
  parse failure is reported in `errors` and rendered in place as an error span —
  the fragment still comes back.
- **notes:** a literal currency `$` must be escaped `\$` or it may open a math
  span. Math inside code fences/spans is left as code. **URLs in the markdown
  (links, images, `\href`) reach the output as written** — keeping a fragment
  offline is the author's job; only decks/pages/editor are verified self-contained.
- **errors:** control char / over-cap `markdown` → structured tool error.

### `notation_lint`

ISO 80000-2 typography lint for LaTeX (advisory; never blocks). **guest+**

- **args:** `latex` (string, required).
- **result:** `{ findings: [{ rule, message, match, fix }] }` — empty means clean.
  Rules: `upright-differential`, `named-function-backslash`, `upright-constant-e`,
  `upright-constant-i`.

## Equation checking (SymPy)

### `eq_check`

Parse + lint one expression against the server's configured engine. **guest+**

- **args:** `latex` (string, ≤4000 chars, required).
- **result:** `{ valid, errors, lint }` — `valid` is the render check, `errors`
  the parse errors (empty when valid), `lint` the `notation_lint` findings. What
  `eq_check` accepts is exactly what `render_math`/`render_fragment` will render.
- **errors:** control char / over-cap → structured tool error. Does **not** need
  SymPy — validation is the render engine, not the bridge.

### `eq_transform`

SymPy algebra. **guest+**

- **args:** `latex` (string, ≤4000 chars, required); `action`
  (`simplify`|`expand`|`factor`|`solve`|`diff`|`integrate`, required); `symbol`
  (string, default `"x"`, used by solve/diff/integrate).
- **result:** `{ ok: true, result: { latex, plain } }` — the transformed
  expression as LaTeX and as SymPy's string form.
- **errors:** bad input (unparseable LaTeX) → `{ ok: false, error: { message } }`;
  bridge unavailable → degraded `{ ok: false, error: { available: false, reason, fallback_hint } }`;
  control char / over-cap → structured tool error.

### `eq_verify`

SymPy equivalence: is LHS ≡ RHS? **guest+**

- **args:** `lhs` (string, ≤4000), `rhs` (string, ≤4000), both required.
- **result:** `{ ok: true, result: { equivalent: bool } }`.
- **errors:** as `eq_transform` (bad input `{message}`, degraded `{available:false}`,
  structured tool error on control char / over-cap).

## Formula library

### `formula_add`

Add a formula to the library. **instructor**

- **args (record body):** `name`, `subject` (`math`|`physics`|`chemistry`|`statistics`),
  `topic`, `concepts` (string[]), `latex`, `variables` (`{symbol, meaning, si_unit|null}[]`),
  `tags` (string[]), `description`, `source`. All required; reserved keys stripped.
- **result:** the stored record (body + `id`/`version`/timestamps/`revisions`).
- **errors:** a body failing the strict `FORMULA_RECORD` shape → structured tool error.

### `formula_update`

Patch a formula; the prior revision is kept. **instructor**

- **args:** `id` (string, required); `patch` (object — any subset of the formula fields).
- **result:** the updated record, or `{ error: "formula not found" }`.
- **errors:** a merged body failing the shape, or a patch naming an unknown key
  (at any depth) → structured tool error.

### `formula_get`

Fetch a formula, optionally at a past version. **guest+**

- **args:** `id` (string, required); `version` (number, optional).
- **result:** the record, or `{ error: "formula not found" }` (also when the
  version does not exist).

### `formula_search`

Full-text formula search. **guest+**

- **args:** `query` (string, required); `subject` (optional filter); `tags`
  (string[] optional filter).
- **result:** up to 20 `{ score, ...record }`, best first. Searches
  name/topic/concepts/description/tags/latex. **Returns whole records**, formula
  bodies being small.

## Snippet library

### `snippet_add`

Add a code snippet. **TA+**

- **args (record body):** `title`, `language`, `subject`, `code`, `tags`
  (string[]), `description`. Reserved keys stripped.
- **result:** the stored record.

### `snippet_get`

Fetch a snippet, optionally at a past version. **guest+**

- **args:** `id` (string, required); `version` (number, optional).
- **result:** the record, or `{ error: "snippet not found" }`.

### `snippet_search`

Full-text snippet search. **guest+**

- **args:** `query` (string, required); `subject` (optional filter).
- **result:** up to 20 `{ score, ...record }`. Searches title/description/tags/language.

## Asset library

An asset is a figure, animation or HTML fragment: one record shape, one store,
discriminated by `kind`. It carries **exactly one** of `content` (inline source,
≤256 KB) or `path` (a reference to where it lives). `provenance` (`{origin,
interpretation[]}`) is required and is the record of where the asset came from.

> **256 KB cap is measured in `String.length` — UTF-16 code units, i.e.
> "characters", not bytes.** A multi-byte or astral-plane character counts as 1
> or 2 units, not by its UTF-8 byte length. Size for the store, which is one JSON
> file rewritten in full on every flush.

### `asset_add`

Add a figure, animation or HTML fragment. **TA+**

- **args (record body):** `kind` (`figure`|`animation`|`fragment`), `name`,
  `subject`, `format` (`svg`|`manim-py`|`html`|`mp4-ref`), **one of** `content`
  or `path`, `provenance` (`{origin, interpretation[]}`, strict), `tags`
  (string[]), `description`; optional `caption`, `source`.
- **result:** `{ id, version, kind, name }` — **deliberately not the whole
  record**, so a quarter-megabyte of SVG is not echoed back.
- **errors:** both/neither of `content`/`path`, `content` over cap, a control
  char, or a body failing the strict shape → structured tool error.

### `asset_update`

Patch an asset; the prior revision is kept. **instructor**

- **args:** `id` (string, required); `patch` (object). To switch inline↔reference,
  send the new field and null the old one (`{content:"…", path:null}`). `content`
  and `path` accept `null`.
- **result:** `{ id, version, kind, name }`, or `{ error: "asset not found" }`.
- **errors:** the exactly-one-of rule, the cap, and the strict shape all apply to
  what the patch would *produce* → structured tool error.

### `asset_get`

Fetch an asset **with** its `content` or `path`, optionally at a past version. **guest+**

- **args:** `id` (string, required); `version` (number, optional).
- **result:** the full record, or `{ error: "asset not found" }`. This is how you
  fetch content that `asset_search` withholds.

### `asset_search`

Full-text asset search. **guest+**

- **args:** `query` (string, required); `subject`, `tags` (string[]), `kind`
  (`figure`|`animation`|`fragment`) — all optional filters.
- **result:** up to 20 hits. **Asymmetric with `_get`:** a hit carries the
  metadata plus a `bytes` count (`content.length`, or `null` for a path
  reference) — **not** the `content` itself and **not** `revisions`. Fetch the
  payload with `asset_get`.

> A hit *does* carry the full `provenance` object (~1.2 KB of origin +
> interpretation notes each). Twenty hits therefore return ~24 KB of provenance
> even though the SVG/HTML bodies are withheld — cheap next to the content, but
> not free for an LLM client counting tokens.

## Decks & pages

A deck is `{ title, session, concepts[], slides[] }`; each slide is
`{ heading, blocks: [{ md, fragment?, solution? }], speaker_notes? }`. See
[`templates/`](../templates/) for two ready-to-edit examples.

### `deck_create`

Create a slide deck. **TA+**

- **args (record body):** `title`, `session`, `concepts` (string[]), `slides`
  (strict shape above). Reserved keys stripped.
- **result:** `{ id, version }`.
- **errors:** a slide with no heading, or a block with no `md` → structured tool
  error. A malformed *known* field (e.g. `slides` not an array) is still refused —
  pruning removes only keys the schema does not name; it never masks a real error.
- **Unknown keys are pruned, not refused** (inbound tolerance). A submitted deck
  may carry non-rendered authoring metadata — a content pipeline stamps per-slide
  `concepts`/`eqRefs`, per-block `figure`/`probe`, and a top-level `source` block
  onto its decks — none of which this renderer reads. Rather than import those
  conventions into the portable deck schema, `deck_create` **prunes** every key
  the schema does not name, at every depth, before validation, and stores the
  canonical (pruned) deck. Rendered output is byte-identical to the schema-reduced
  deck (the pruned keys are non-rendered), and the pipeline's own `deck.json`
  remains the source of truth for the metadata that was dropped. This is the
  **inbound mirror of `deck_update`**: a full submitted deck is *pruned* of unknown
  keys, whereas an update *patch* that introduces one is *refused* — a patch names
  fields deliberately (see [Unrecognized keys](#conventions) and `deck_update`).

### `deck_update`

Patch a deck; the prior revision is kept. **TA+**

- **args:** `id` (string, required); `patch` (object).
- **result:** `{ id, version }`, or `{ error: "deck not found" }`.
- **errors:** merged body failing the strict deck shape → structured tool error.

### `deck_render`

Render a deck to one standalone HTML file (step-through slides). **guest+**

- **args:** `id` (string, required); `audience` (`instructor`|`student`, default
  `student`); `out_path` (string, optional).
- **result:** `{ file, audience, bytes, lint }` — `bytes` is `html.length`
  (UTF-16 code units), `lint` the per-slide findings (`math-error`, `external-url`,
  notation). A **`student` or `guest` render is always the student build**,
  whatever `audience` asks — speaker notes and solution blocks are never emitted.
- **errors:** `out_path` is **instructor/TA only** (a read-only role choosing a
  path would gain a file-write primitive → structured tool error); `out_path`
  naming a data-store file is refused for every role; unknown id →
  `{ error: "deck not found" }`.

### `lesson_page_render`

Render the same deck as one scrolling lesson page (no step-through, no solutions,
no speaker notes). **guest+**

- **args:** `id` (string, required); `out_path` (string, optional).
- **result:** `{ file, bytes, lint }`.
- **errors:** same `out_path` rules as `deck_render`; unknown id →
  `{ error: "deck not found" }`.

### `deck_search`

Full-text deck search. **guest+**

- **args:** `query` (string, required).
- **result:** up to 20 `{ score, id, title, session, concepts, slides, version }`
  — `slides` is the **count**, not the slides. Render a deck to read it.

> `deck_search` lets **any** role, guest included, enumerate every deck's id,
> title, session and concept list. It is discovery, not content — but a title is
> not nothing. A `--role guest` instance that should not reveal even the names of
> unpublished decks should not be sharing that store.

## Editor

### `editor_export`

Export a standalone offline equation editor: one HTML file with a LaTeX box, live
KaTeX preview, Copy LaTeX / Copy MathML, and a template palette. **guest+**

- **args:** `out_path` (string, optional); `title` (string, ≤200 chars, optional).
- **result:** with **no** `out_path`, `{ bytes, inlined, html }` — the whole
  ~0.68 MB page inline. With `out_path`, `{ file, bytes, inlined }`.
- **notes:** the editor is a documented **allow-list** self-containment guarantee
  — `lib/editor.mjs` refuses to emit any URL that is not one of two W3C namespace
  constants. This is **not** the decks' strip-based *zero*-URL output: the editor
  ships a live KaTeX engine that still needs those namespace URIs for every
  expression the reader types.
- **errors:** `out_path` instructor/TA only, never a data-store file; over-cap
  `title` → structured tool error.

## Channels & whiteboard

A channel holds posts (problem / question / lecture-session); posts get replies
whose math renders through the same pipeline. Each channel also has one
append-only whiteboard. Channels, posts and whiteboards share one store,
discriminated by `kind`.

### `channel_create`

Create a collaboration channel. **instructor**

- **args:** `name` (string), `description` (string), both required.
- **result:** the channel record (`kind: "channel"`).

### `channel_list`

List channels with post counts. **guest+**

- **args:** none.
- **result:** `[{ ...channel, posts }]` — `posts` is the count of posts in each
  channel. **No post or reply bodies are returned by any tool** (this is what
  makes the WS post/reply feed a wider read surface — see below).

### `post_create`

Post a problem/question/lecture-session to a channel. **student+**

- **args:** `channelId` (string), `postKind` (`problem`|`question`|`lecture-session`),
  `title` (string), `body_md` (string). All required.
- **result:** the post record (`author_role` stamped by the server, `replies: []`),
  or `{ error: "channel not found" }`.
- **relay:** emits a `post` WS event to that channel's subscribers.

### `post_reply`

Reply to a post; the reply's math renders through the pipeline. **student+**

- **args:** `postId` (string), `body_md` (string), both required.
- **result:** `{ postId, replies, math_errors }` — `replies` is the new count,
  `math_errors` the per-equation parse failures (the reply is posted regardless).
  Unknown post → `{ error: "post not found" }`.
- **relay:** emits a `reply` WS event.

> Replies are stored by rewriting the post's whole `replies[]` array through
> `Store.update`, which snapshots the prior body into `revisions[]` — so a post's
> on-disk size grows with the **square** of its reply count. Fine for a class
> discussion; a caveat for a long-lived, heavily-replied post. See DEPLOY.md.

### `whiteboard_append`

Append one op to a channel's shared whiteboard. **student+**

- **args:** `channelId` (string, required); `op` (strict): `{ type: "stroke",
  points: [[x,y],…], color? }` or `{ type: "clear" }`. Coordinates must be
  finite.
- **result:** `{ channelId, ops, op }` — `ops` is the new op count, `op` the
  stored entry (`author_role`, `at` stamped by the server). Unknown channel →
  `{ error: "channel not found" }`.
- **errors:** a stroke with 0 or >1000 points, a board over 20000 points or 2000
  ops, a clear carrying points, a non-finite coordinate → structured tool error.
- **relay:** emits a `whiteboard` WS event.

### `whiteboard_get`

Read a channel's whiteboard: every op in draw order. **guest+**

- **args:** `channelId` (string, required).
- **result:** `{ channelId, ops }` — an undrawn channel has an empty log, not a
  missing one. Unknown channel → `{ error: "channel not found" }`.

## Status

### `server_status`

Engine/bridge availability, role, data dir, record counts. **guest+**

- **args:** none.
- **result:** `{ role, engine, dataDir, sympy, counts }` where `sympy` is the
  bridge probe (`{ available: true, python, sympy, parser }` or
  `{ available: false, reason }`) and `counts` is
  `{ formulas, snippets, decks, assets, channels }`.

---

## REST / WebSocket facade

Off unless `--serve` is given. The REST endpoints are thin wrappers over the
**same handler table** the MCP tools use — the role check, input caps, store
locks and error text are identical by construction. Only **nine** of the 30 tools
are routed:

| 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` | live channel events (subscribe/relay) |

The request body is the tool's arguments as JSON; a path parameter (`:id`,
`:channelId`) always wins over a body field of the same name.

### Error-status contract

Status codes carry **transport** failures only:

- `404` — the path is no route.
- `400` — the body is not a JSON object, or is malformed JSON.
- `413` — the body is over the 1 MB facade limit (measured in **bytes**, unlike
  the tools' character caps).
- `500` — the facade itself broke (not a tool failure — a handler already turns a
  tool throw into a structured error).

A **tool-level** failure — an unknown id, a denied role, an over-cap argument —
is a **`200`** carrying the tool's own body plus `isError: true`
(`{ isError: true, error: "…" }`), because it is an answer, not a broken request,
and it is byte-for-byte what an MCP client would receive. An in-band miss
(`{ error: "… not found" }`, `{ ok: false, error: {available:false} }`) is a
`200` too.

### WebSocket subscribe / relay protocol

Open a socket to `/ws`. Send `{"type":"subscribe","channelId":"…"}` to start
receiving that channel's events, `{"type":"unsubscribe","channelId":"…"}` to
stop. The server replies `{"type":"subscribed"|"unsubscribed","channelId":"…"}`,
or `{"type":"error","error":"…"}` for malformed JSON, a missing `channelId`, an
unknown message type, or a role that may not subscribe.

Events pushed to subscribers:

| Event | `type` | Carries | RBAC — role needs |
| --- | --- | --- | --- |
| whiteboard op | `whiteboard` | exactly what `whiteboard_get` returns | `whiteboard_get` — the guest floor |
| new post | `post` | the post body | `post_create` — the student floor |
| new reply | `reply` | the post body + rendered reply HTML | `post_create` — the student floor |

**The relay is a read path, gated per event** (`mayReceive` in `lib/rbac.mjs`),
not once at the door. Subscribing at all needs `channel_list` (`maySubscribe`).

> **The WS read surface is wider than the tool registry.** *No* tool returns post
> or reply bodies — `channel_list` gives only counts — yet the `post`/`reply`
> events push the full body and rendered reply HTML. That is why those events are
> gated at the `post_create` (student) tier rather than the guest floor: a
> `--role guest` instance's sockets watch the whiteboard but are told nothing
> about the conversation. An event type the matrix does not name is relayed to
> nobody (fails closed). When a post-read tool is added, that gate moves to it.

**The store is the source of truth; the socket is a fire-and-forget relay.**
Every event is written and flushed *before* it is broadcast, so a subscriber that
is absent, slow or disconnected misses notifications, never data. Reconnect and
replay the truth with `whiteboard_get` / `channel_list`.

There is **no authentication** on the facade — roles are per instance. See
DEPLOY.md for the reverse-proxy and loopback story.

## Degradation

SymPy is optional. If `python3`/`sympy` is missing (or `STEM_MCP_PYTHON` points
at an interpreter without it), `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. `eq_check`, all rendering, the libraries, decks, editor and
channels work without it. The `ws` dependency degrades the same way: absent, the
REST half still serves and the startup line says the websocket is unavailable.
