# stem-mcp deployment guide

How to run `stem-mcp` as an MCP stdio server, as an optional HTTP/WebSocket
facade, how its data is laid out and backed up, and how to give it a working
SymPy bridge. For the tool contract itself, see [API.md](./API.md).

## As an MCP stdio server

The default and primary mode: a stdio MCP server, one `--role` per process.

```json
{
  "mcpServers": {
    "stem": {
      "command": "npx",
      "args": ["stem-mcp", "--role", "instructor"]
    }
  }
}
```

Any MCP client works (Claude Code, Claude Desktop, …). A public-facing or shared
instance should run at the lowest role that does the job — `guest` for a
read-only formula/asset browser, `student` for a class that posts and draws:

```json
{
  "mcpServers": {
    "stem": {
      "command": "npx",
      "args": ["stem-mcp", "--role", "student", "--engine", "mathjax"],
      "env": {
        "STEM_MCP_DATA_DIR": "/srv/stem/data",
        "STEM_MCP_PYTHON": "/srv/stem/.venv/bin/python"
      }
    }
  }
}
```

Every flag has an environment-variable twin (`--role`/`STEM_MCP_ROLE`,
`--data-dir`/`STEM_MCP_DATA_DIR`, `--engine`/`STEM_MCP_ENGINE`,
`--serve`/`STEM_MCP_SERVE`, `--serve-host`/`STEM_MCP_SERVE_HOST`). A flag wins
over its env var. An invalid `--role` or `--engine` is a hard startup error, so a
typo never silently falls back to a more-privileged default.

## The `--serve` HTTP + WebSocket facade

Off unless asked. With no `--serve`, no port is bound, `lib/http-facade.mjs` is
never imported and `ws` is never loaded — the process is the stdio server and
nothing else.

```bash
npx stem-mcp --role student --serve 8737
```

Bare `--serve` uses port 8737; `--serve 0` binds any free port. The facade routes
only nine tools (see [API.md](./API.md#rest--websocket-facade)) and shares the
exact handler table, RBAC and error text of the stdio server.

### It binds loopback, and it has no auth

**The facade binds `127.0.0.1` by default and there is no authentication, TLS,
CORS or rate-limiting in v1.1.** Roles are per instance: 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.

Consequences to design around:

- **Anything reachable on the network is reachable *as that role, by anyone*.**
  For an `instructor` instance that includes `out_path` on the render tools,
  which is a file-write primitive.
- **No transport security.** Plain HTTP and plain WS. Do not put a password, a
  token, or private student data across it in the clear.
- **No rate limiting or CORS.** The 1 MB body cap and the tools' own input caps
  are the only backpressure; a caller can hammer the port.
- **The WS read surface is wider than the tool registry.** The `post`/`reply`
  relay pushes full post bodies and rendered reply HTML that *no* tool returns.
  It is gated per event at the student tier (see API.md), but on a network-exposed
  instance that means anyone who can reach the port and clears that tier reads the
  conversation live.

**To expose it, put a reverse proxy in front** that does the authentication and
authorization (and TLS, and rate limiting), run the instance at the lowest role
that works, and set `--serve-host` deliberately:

```bash
npx stem-mcp --role guest --serve 8737 --serve-host 127.0.0.1   # proxy terminates TLS + auth
```

A deployment that needs several roles runs several instances, one role per port,
each behind the proxy. Real per-caller auth is a v2 item.

## Data directory

One directory (`--data-dir`, `STEM_MCP_DATA_DIR`, default `~/.stem-mcp`) holds
everything:

```
<data-dir>/
  formulas.json      # formula library  (seeded on first use)
  snippets.json      # snippet library  (seeded on first use)
  assets.json        # asset library    (seeded on first use)
  decks.json         # slide decks      (created empty)
  channels.json      # channels + posts + replies + whiteboards (created empty)
  renders/           # deck / lesson-page HTML output lands here by default
  *.corrupt-<ts>     # a store file that failed to parse, moved aside not discarded
```

Each store is a **single JSON file rewritten in full on every flush** (atomic
temp-file + rename). This shapes the operational caveats:

- **Seeding is first-use only.** `formulas`/`snippets`/`assets` are seeded from
  the package's `seeds/` the first time each file is absent; an existing readable
  file is loaded as-is. `decks`/`channels` start empty.
- **Corruption is quarantined, never discarded.** An unparseable store file is
  renamed `*.corrupt-<timestamp>` before a fresh one is seeded; if it cannot even
  be moved aside, startup fails rather than overwrite it. Sweep up old
  `*.corrupt-*` files periodically.
- **Revisions grow the files.** Every `*_update` snapshots the prior body into
  `revisions[]`. Records are versioned append-only — good for audit, but a
  heavily-edited library grows.
- **Legacy fields are silently dropped on the next update.** A key in a stored
  record that the current schema no longer recognizes (e.g. after a schema
  tightened between versions) is pruned the next time that record is successfully
  updated via `formula_update` / `asset_update` / `deck_update` — even if the
  patch never touches it. The old value survives only in that record's
  `revisions[]`, so an in-place migration that needs to *keep* such a field must
  read it out before the first update that would drop it.
- **`post_reply` grows quadratically.** A reply rewrites the post's whole
  `replies[]` array through the revisioned update path, so each revision carries
  the entire reply list as it then stood: a post with *n* replies has stored
  ~*n²*/2 reply copies. Whiteboards avoid this (they use an append that cuts no
  revision, and are hard-capped at 20000 points / 2000 ops per board), but a
  long-lived, heavily-replied post is the one thing that can bloat
  `channels.json`. Archive or rotate busy channels.

### Backup

The whole state is those five JSON files. Back up the data dir (or just the
`*.json` in it — `renders/` is regenerable output). They are plain JSON, so a
copy while the server is idle is a complete, restorable snapshot; the atomic
rename means even a copy taken mid-flush catches either the old or the new whole
file, never a half-written one. Restore by putting the files back and restarting.

## SymPy setup

The equation tools (`eq_transform`, `eq_verify`) shell out to a Python worker.
Everything else — rendering, linting, libraries, decks, editor, channels — works
with no Python at all; the equation tools degrade to a structured
`{available:false}` result rather than failing (see
[API.md](./API.md#degradation)).

### Recommended: a venv with `latex2sympy2_extended`

```bash
python3 -m venv /srv/stem/.venv
/srv/stem/.venv/bin/pip install sympy latex2sympy2_extended
```

Then point the server at that interpreter:

```bash
STEM_MCP_PYTHON=/srv/stem/.venv/bin/python npx stem-mcp --role instructor
```

`STEM_MCP_PYTHON` overrides the interpreter (default `python3`). A venv
interpreter works under the bridge's stripped environment without any extra
variables — it finds its own prefix from its own location, so `VIRTUAL_ENV` and
`PYTHONPATH` are **not** needed.

### Why `latex2sympy2_extended` is the recommendation

The worker prefers `latex2sympy2_extended` and falls back to SymPy's own
`parse_latex(..., strict=True)`. `import sympy` succeeding is **not enough**:
SymPy's built-in LaTeX parser needs the **antlr4** runtime, and without a parser
backend every live parse fails behind an otherwise-healthy SymPy. The **strict**
flag matters just as much — non-strict ANTLR silently mis-parses (`x -` becomes
`x`) instead of raising, and a wrong answer in front of a class is worse than no
answer. `latex2sympy2_extended` sidesteps the antlr4 dependency and parses more
LaTeX, which is why it is preferred. The `test:sympy` gate below (via
`scripts/check-sympy.mjs`) actually exercises this policy on a trivial
expression, so a green gate proves a *working* parser, not merely an importable
SymPy.

### Windows note

The bridge spawns the worker with a **POSIX-shaped** minimal environment —
`{ PATH: process.env.PATH }` and nothing else — and `cwd` set to the system temp
dir. On Windows a child process typically needs more than `PATH` (e.g.
`SystemRoot`/`windir`, and often `PATHEXT`) to start at all, so the equation
tools may not spawn cleanly there. v1.1 is developed and tested on POSIX; a
`.venv` with an absolute `STEM_MCP_PYTHON` is the supported path. On Windows,
expect to point `STEM_MCP_PYTHON` at an absolute interpreter and to verify the
worker actually spawns (`server_status` → `sympy.available`).

## Test modes

Three modes, all of which must be green — the second and third are contracts,
not conveniences:

```bash
npm test              # full suite; SymPy-dependent tests SKIP if the bridge is absent
npm run test:nopython # STEM_MCP_PYTHON forced invalid → proves the degradation path
npm run test:sympy    # preflight FAILS if SymPy/parser is missing, then runs the suite
```

- **`npm test`** — the default. If Python/SymPy is absent, the equation tests skip
  (they are not the point of a plain run).
- **`npm run test:nopython`** — points `STEM_MCP_PYTHON` at a non-existent binary,
  so the whole suite runs through the degraded path. This is what proves
  `{available:false}` is returned rather than a throw or a hang.
- **`npm run test:sympy`** — the opposite gate. `scripts/check-sympy.mjs` runs
  first and **fails loudly** if the interpreter cannot actually parse LaTeX (no
  SymPy, or no parser backend), so a green run genuinely exercised the SymPy path
  instead of skipping it. Use `STEM_MCP_PYTHON` to select the interpreter this
  gate checks.

## Trust model (recap)

The SymPy worker runs a **caller-local** interpreter with your user's privileges
— it is process hygiene (stripped env, temp `cwd`), **not** a sandbox or jail. It
can read and write anything you can. Point `STEM_MCP_PYTHON` only at an
interpreter you trust, and treat LaTeX from untrusted users as input to *your*
machine. See the README's "Trust model" for the full statement.
