3 Commits

Author SHA1 Message Date
c79f1b120d Index-backed search, /api surfaces, image-only PDF detection
search now queries the Postgres index and states its freshness in every
result, with fresh=true bypassing it for a live crawl — the agent can
always get current data rather than being quietly misled by a stale
index. Adds refresh_index (per-course by default; a full crawl is ~270
requests), what_changed (generation diff — the API has no changed-since
filter of any kind), and index_status.

/api gives the CLI its backend behind the same bearer token as /mcp:
GET /manifest (cursor + per-file status), GET /files/:id (served from
the mirror with Range support, falling back to a live proxy for files
too large to mirror), GET /status, POST /refresh. Bytes go over plain
HTTP rather than MCP because base64 in JSON-RPC costs a third more and
buffers whole files. An unresolvable manifest cursor returns 409 rather
than silently meaning "everything is new", so a client cannot be tricked
into a full re-download.

Verified end to end against the live instance and a real Postgres:
crawl -> index -> German FTS -> manifest -> ranged download, with 401
on missing token, 400 on a malformed id, and 429 on a too-soon refresh.

Two findings worth recording. The build silently omitted the .sql
migrations from dist, which the store's graceful degradation turned into
"running without the index" rather than a crash — now copied by a build
step. And 3 of 4 sampled course PDFs have no embedded fonts at all: they
are scans, so extraction legitimately yields nothing. That is now
detected and reported as image-only with OCR named as the missing piece,
instead of an indistinguishable "0 characters". It revises the roadmap's
"OCR not needed" note, which held for reading images but not for
indexing them.

49 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:16:38 +02:00
81dd633863 Extract core/, lift the crawler out of the search tool
Moves the reusable half into src/core/ (client, types, board, extract,
text, keepalive) and the MCP half into src/mcp/. The layering was
already clean — nothing in core imported app code or read process.env —
so this is a move, not a redesign, and the smoke suite stayed the oracle
throughout.

The substantive part is core/crawl.ts. The course->board->card->element
->file traversal previously existed only inside tools/search.ts, and the
indexer, what's-new diff and file mirror all need it. It now returns a
typed Snapshot with breadcrumbs, sorted so two crawls of unchanged
content compare equal. Metadata only: downloading and extracting bytes
is an order of magnitude more expensive and only the indexer wants it.

core/match.ts holds the keyword matching, which makes it testable
without a network, and core/text.ts gains the fold/tokenize/snippet
helpers (accent folding is not optional for German).

search now finds strictly more than before — 5 hits vs 3 for
"Datenschutz" — because the snapshot surfaces file-name matches the old
streaming walk skipped. 34 unit tests and 30/30 smoke checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:04:52 +02:00
35125b7683 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>
2026-09-11 23:52:12 +02:00