Offer courses and rooms as MCP resources, with two German prompts

A course or room can now be attached to a message rather than fetched:
schulcloud://courses/<id> and schulcloud://rooms/<id> carry exactly what
get_course and get_room return. Deliberately coarse — a picker lists every
resource at once, which suits some twenty courses and not a thousand files.

Two prompts, in German because the school is: zusammenfassung summarises a
course or room, and pruefungsvorbereitung prepares for an exam with practice
questions and a study plan. Each embeds the overview and says where material
hides and what cannot be read.

Claude Code shaped the details, read from its bundle rather than its docs.
It splits prompt arguments on whitespace and drops extra words, so words
arrive joined with "_", and courses match by fragments, whole words first,
so LF1 is not ambiguous with LF10. Its @ autocomplete shows a resource's
description, so the description carries the name. Errors are ProtocolError,
because McpError's message prefix is doubled by the client.

Verified in interactive Claude Code: @-mention, autocomplete and the prompt
commands. 157 tests. Smoke 67/67 live; 69/69 and 67/67 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 3e44e66dde
commit 9d0272c622
13 changed files with 809 additions and 53 deletions

View File

@@ -39,8 +39,8 @@ index.
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
Run smoke **both ways**: with `DATABASE_URL` set (57 checks, index-backed) and
without (55 checks, live-only). The degradation path is a supported mode, not a
Run smoke **both ways**: with `DATABASE_URL` set (69 checks, index-backed) and
without (67 checks, live-only). The degradation path is a supported mode, not a
fallback nobody exercises.
Store tests need a database and skip without one:
@@ -53,7 +53,7 @@ once put fixtures into real data.
## Architecture
```
bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*
bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,prompts}.ts
└─ http/{server,api,auth}.ts /mcp and /api
bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts
@@ -86,6 +86,10 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks
a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say
when *not* to use the tool.
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
`get_course`/`get_room`; and two prompts. What people read in a picker (labels,
prompt texts) is German; what the model reads stays English.
- **`context.ts`** — per-session state. Only `/me` is cached, because the school
id is on every files-storage path and cannot change for a token.
@@ -290,6 +294,26 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
saying what is unavailable and what still works.
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
## Resources and prompts
Claude Code is the client these are tested in, and it shapes them. Read from its
bundle (2.1.272), not its docs:
- **A prompt command's arguments are split on whitespace, and extra words are
dropped** (`zipObject(argNames, input.split(/\s+/))`). A value of several words
can only arrive joined, so `argumentText` turns `_` back into spaces and reads
`-` as "skipped"; course names match by fragments (`resolveTarget`). Any new
free-text argument needs the same treatment.
- **The @ autocomplete fuzzy-matches `name` but displays `description`**, falling
back to the name only when there is none — a description must carry the name.
- **An @-mention resolves only URIs from `resources/list`**; a template alone
cannot be mentioned. `McpServer` returns every template's listing in one reply
and ignores cursors, which suits a few dozen entries and not the file manager.
- **Throw `ProtocolError`, not `McpError`**: McpError prefixes its message with
"MCP error <code>:", the client prefixes it again, and people read both.
- A resource listing that fails yields no entries rather than an error: one
refused kind would otherwise cost the whole reply.
## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`