Initial schulcloud-mcp server
Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.
The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:
- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
listing files-storage with parentType=boardnodes and the element id.
Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.
Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.
Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
CLAUDE.md
Normal file
112
CLAUDE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
An MCP server exposing a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
|
||||
account to Claude, read-only: courses, column boards, lessons, tasks, and file
|
||||
downloads with text extraction. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
|
||||
|
||||
Two entry points, one server definition:
|
||||
- `src/bin/http.ts` — Streamable HTTP, the deployed form, behind Caddy on a Pi.
|
||||
- `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run build # tsc → dist/
|
||||
npm run dev # watch mode, runs src/ directly via type stripping
|
||||
npm test # unit tests (node:test), no network
|
||||
npm run typecheck
|
||||
npm run probe # verify token + API assumptions against the LIVE instance
|
||||
npm run smoke # full end-to-end: real server + real MCP client + real data
|
||||
```
|
||||
|
||||
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
|
||||
read-only. Run `smoke` after touching anything in `src/tools/` or
|
||||
`src/schulcloud/` — the unit tests cover only pure functions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bin/{http,stdio}.ts → server.ts (createServer)
|
||||
└─ tools/{overview,content,files,search,raw}.ts
|
||||
└─ context.ts (caches /me → school id)
|
||||
└─ schulcloud/client.ts (all GET, no writes)
|
||||
schulcloud/board.ts (assembles boards)
|
||||
extract.ts (documents → text)
|
||||
render.ts (→ Markdown)
|
||||
```
|
||||
|
||||
- **`schulcloud/client.ts`** — every upstream call. Methods are `GET`-only by
|
||||
design; see "Invariants" below.
|
||||
- **`schulcloud/board.ts`** — the non-obvious part. A column board needs three
|
||||
kinds of call to reconstruct; this hides that.
|
||||
- **`tools/*.ts`** — each registers a group of tools and formats results as
|
||||
Markdown. Tool descriptions are prompts: they are how Claude decides which
|
||||
tool to reach for, so they carry the German domain terms (Kurse, Themen,
|
||||
Aufgaben) and say when *not* to use the tool.
|
||||
- **`context.ts`** — per-session state. Only `/me` is cached, because the school
|
||||
id is required on every files-storage path and cannot change for a token.
|
||||
|
||||
## Invariants
|
||||
|
||||
**Everything is read-only.** Every client method is a `GET`, and `api_get`
|
||||
rejects non-`/api/` paths and anything carrying a scheme or host. The endpoint
|
||||
is internet-facing by necessity, so "a leaked token cannot act as the user" is
|
||||
the property that makes that acceptable. Do not add a write tool without the
|
||||
user explicitly asking for one and understanding this.
|
||||
|
||||
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
||||
account for 30 days; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
|
||||
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
||||
|
||||
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
||||
and may be ahead of what is deployed. When they disagree with the instance, the
|
||||
instance is right. `docs/API.md` records which is which.
|
||||
|
||||
## API gotchas
|
||||
|
||||
These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
|
||||
- Course contents are at `GET /api/v3/course-rooms/{courseId}/board`. There is
|
||||
no `GET /api/v3/courses/{id}`, and `:roomId` there is the *course* id.
|
||||
- `/api/v3/rooms` is an unrelated newer feature, not courses. Empty is normal.
|
||||
- `limit` is rejected above 100 though the spec says 99. Page at 99; the client
|
||||
clamps and `listAllCourses` pages for you.
|
||||
- There is no `GET /tasks/{id}`, and the task lists omit `description` — it
|
||||
only exists on the course page's task element. `get_task` does that join.
|
||||
- **Board file elements carry no file id.** Files are found by listing
|
||||
files-storage with `parentType: 'boardnodes'` and the *element* id as
|
||||
`parentId`. Same for `fileFolder` and `drawing`.
|
||||
- Files live in a separate service (`/api/v3/file/*`, repo `file-storage`) with
|
||||
its own OpenAPI document. It is not in the main `docs-json`.
|
||||
- Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use
|
||||
`normalizeObjectId`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
|
||||
emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly.
|
||||
- Tabs for indentation, single quotes, trailing commas.
|
||||
- Comments explain *why* — an API quirk, a security property, a trade-off — not
|
||||
what the line does. Several such comments encode findings that are expensive
|
||||
to rediscover; do not strip them.
|
||||
- Tool failures return `isError: true` with an actionable message via
|
||||
`tools/result.ts`. `toToolError` separates 401 (token expired — the user must
|
||||
act) from 403 (no access) from 404 (bad id) deliberately; keep that split.
|
||||
|
||||
## Adding a tool
|
||||
|
||||
1. Add the client method in `schulcloud/client.ts` (`GET` only).
|
||||
2. Register the tool in the relevant `tools/*.ts`, with a description that says
|
||||
when to use it *and when not to*.
|
||||
3. Format output as Markdown, keeping ids visible for follow-up calls.
|
||||
4. Add a check to `scripts/smoke.mjs` and run `npm run smoke`.
|
||||
|
||||
## Environment
|
||||
|
||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
|
||||
for the full set and `docs/AUTH.md` for refreshing the JWT — it expires every 30
|
||||
days, and `npm run probe` reports the days remaining.
|
||||
Reference in New Issue
Block a user