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:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
vendor
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
test
|
||||||
|
scripts
|
||||||
|
docs
|
||||||
|
files.zip
|
||||||
38
.env.example
Normal file
38
.env.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schulcloud instance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Base URL of the instance, no trailing slash.
|
||||||
|
TSC_URL=https://schulcloud-thueringen.de
|
||||||
|
|
||||||
|
# The value of the `jwt` cookie from a logged-in browser session.
|
||||||
|
# Valid for 30 days from issue; see docs/AUTH.md for how to copy a fresh one.
|
||||||
|
TSC_JWT_COOKIE=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# This MCP server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Shared secret callers must present as `Authorization: Bearer <token>`.
|
||||||
|
# REQUIRED for the public deployment — without it the endpoint is open to
|
||||||
|
# anyone who finds the hostname. Generate one with:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
MCP_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Listen address inside the container. Leave as-is when running behind Caddy.
|
||||||
|
PORT=8080
|
||||||
|
BIND_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Limits (optional — sensible defaults are built in)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Largest file download_file will pull, in bytes. Default 25 MiB.
|
||||||
|
# Videos in Schulcloud routinely exceed this; they are not extractable anyway.
|
||||||
|
# MAX_DOWNLOAD_BYTES=26214400
|
||||||
|
|
||||||
|
# Characters of extracted text returned before truncation. Default 120000.
|
||||||
|
# MAX_EXTRACTED_CHARS=120000
|
||||||
|
|
||||||
|
# Per-request timeout against the Schulcloud API, in ms. Default 30000.
|
||||||
|
# REQUEST_TIMEOUT_MS=30000
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Upstream clones kept for reference/searching only — not part of this project.
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Local scratch
|
||||||
|
tmp/
|
||||||
|
files.zip
|
||||||
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.
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Build stage: full dependency tree, compile TypeScript to dist/.
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Prune to runtime dependencies only, in its own stage so the build tree
|
||||||
|
# (typescript, @types) never reaches the final image.
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Every extractor is pure JavaScript, so the runtime image needs no build
|
||||||
|
# toolchain — just a signal-forwarding init so SIGTERM reaches node.
|
||||||
|
RUN apk add --no-cache tini
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# node:alpine ships an unprivileged `node` user; the process never writes to disk.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV PORT=8080 BIND_HOST=0.0.0.0
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node", "dist/bin/http.js"]
|
||||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# schulcloud-mcp
|
||||||
|
|
||||||
|
An MCP server that gives Claude read-only access to a
|
||||||
|
[Schulcloud](https://github.com/hpi-schul-cloud) account — courses, boards,
|
||||||
|
lessons, tasks — and reads the attached files, so you can ask about your
|
||||||
|
coursework instead of downloading PDFs and uploading them by hand.
|
||||||
|
|
||||||
|
Built and verified against `schulcloud-thueringen.de` with a live student
|
||||||
|
account. Everything in `docs/API.md` was confirmed against the running
|
||||||
|
instance, not inferred from the upstream source.
|
||||||
|
|
||||||
|
## What Claude can do with it
|
||||||
|
|
||||||
|
> *"What do I have due this week?"*
|
||||||
|
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
|
||||||
|
> *"Summarise the routing lesson from the LF10 course."*
|
||||||
|
|
||||||
|
Thirteen tools, all read-only:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `whoami` | account, school, roles — also a connectivity check |
|
||||||
|
| `list_courses` | all courses, with ids |
|
||||||
|
| `get_dashboard` | the tiles as pinned on the web dashboard |
|
||||||
|
| `get_course` | one course's boards, topics and tasks |
|
||||||
|
| `get_board` | a column board in full: columns, cards, text, links, files |
|
||||||
|
| `get_lesson` | a topic's text sections, materials, files and tasks |
|
||||||
|
| `list_tasks` | homework across all courses, by due date |
|
||||||
|
| `get_task` | one task: description, due date, status, attachments |
|
||||||
|
| `list_files` | files attached to any entity |
|
||||||
|
| `download_file` | fetch a file and extract its text, or view an image |
|
||||||
|
| `search` | keyword search across courses, boards, files and tasks |
|
||||||
|
| `list_news` | school and course announcements |
|
||||||
|
| `api_get` | GET-only escape hatch for uncovered API surface |
|
||||||
|
|
||||||
|
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
|
||||||
|
files and returns **images inline** for Claude to look at. Verified against
|
||||||
|
real files in the account: a 93-file PDF corpus, DOCX, ODT and PPTX all
|
||||||
|
extract correctly.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill in TSC_URL and TSC_JWT_COOKIE
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run probe # verifies the token and API against the live instance
|
||||||
|
```
|
||||||
|
|
||||||
|
Then either deploy it as a remote connector, or point Claude Code at
|
||||||
|
`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
|
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and lasts 30 days —
|
||||||
|
see [docs/AUTH.md](docs/AUTH.md).
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
**Bearer token, not a cookie jar.** The instance's `jwt` cookie works verbatim
|
||||||
|
as `Authorization: Bearer`, and is valid for 30 days. There is no session to
|
||||||
|
keep alive and no `refresh-session` timer — a simplification that only became
|
||||||
|
apparent by testing against the live instance.
|
||||||
|
|
||||||
|
**Read-only by construction.** Every method on the API client is a `GET`,
|
||||||
|
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
||||||
|
connectors call it from Anthropic's cloud), so the fact that a leaked token
|
||||||
|
cannot be used to *act* as the user is the main safety property. Adding one
|
||||||
|
write tool would forfeit it.
|
||||||
|
|
||||||
|
**Stateless.** No database, despite one being available on the host. 26 courses
|
||||||
|
is not a caching problem, and a cache would introduce staleness questions that
|
||||||
|
live calls simply do not have.
|
||||||
|
|
||||||
|
**Assembled, not raw.** `get_board` makes three kinds of upstream call and
|
||||||
|
stitches the results — board skeleton, card bodies, and a files-storage lookup
|
||||||
|
per file element — because a model asking "what's on this board" wants the
|
||||||
|
answer, not a traversal plan. Output is Markdown with ids preserved for
|
||||||
|
follow-up calls, not raw JSON.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
bin/ stdio and http entry points
|
||||||
|
schulcloud/ API client, response types, board assembly
|
||||||
|
tools/ one module per group of MCP tools
|
||||||
|
http/ express app, bearer auth
|
||||||
|
extract.ts document → text
|
||||||
|
render.ts formatting helpers
|
||||||
|
docs/ API findings, auth, deployment
|
||||||
|
deploy/ Caddyfile snippet
|
||||||
|
scripts/ probe (verify against live) and smoke (end-to-end)
|
||||||
|
vendor/ upstream clones, git-ignored, for reference only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # watch mode, runs src/ directly
|
||||||
|
npm test # unit tests, no network
|
||||||
|
npm run probe # check assumptions against the live instance
|
||||||
|
npm run smoke # full end-to-end: real server, real client, real data
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
||||||
|
Streamable HTTP and exercises every tool against the live account — 30 checks
|
||||||
|
covering the auth gate, the protocol handshake, every content chain, file
|
||||||
|
extraction, `api_get`'s guard rails and error handling.
|
||||||
|
|
||||||
|
## Upstream
|
||||||
|
|
||||||
|
Reference clones live in `vendor/` (git-ignored):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/schulcloud-server.git vendor/schulcloud-server
|
||||||
|
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/file-storage.git vendor/file-storage
|
||||||
|
git clone --depth 1 --filter=blob:none https://github.com/hpi-schul-cloud/nuxt-client.git vendor/nuxt-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Most of that organisation's ~100 repositories are archived or superseded; those
|
||||||
|
three are the live ones that matter. The instance's own OpenAPI documents
|
||||||
|
(`/api/v3/docs-json`, `/api/v3/file/docs-json`) are more authoritative than any
|
||||||
|
of them — see [docs/API.md](docs/API.md).
|
||||||
44
deploy/Caddyfile.snippet
Normal file
44
deploy/Caddyfile.snippet
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Add this to the Pi's existing Caddyfile.
|
||||||
|
#
|
||||||
|
# Caddy obtains and renews the certificate automatically, provided the VPS
|
||||||
|
# forwards ports 80 and 443 through to this Caddy and the DNS name resolves to
|
||||||
|
# the VPS's public address.
|
||||||
|
#
|
||||||
|
# The bearer-token check lives in the application, not here: Caddy would have
|
||||||
|
# to be reloaded to rotate the token, whereas the app reads it from the
|
||||||
|
# environment. Caddy's job is TLS, timeouts and keeping the container off the
|
||||||
|
# public interface.
|
||||||
|
|
||||||
|
mcp.example.org {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# `schulcloud-mcp` is the Compose service name; Docker's embedded DNS
|
||||||
|
# resolves it on the shared network. No host port is published.
|
||||||
|
reverse_proxy schulcloud-mcp:8080 {
|
||||||
|
# MCP's Streamable HTTP transport keeps a server-sent-events channel
|
||||||
|
# open for server-initiated messages. Without flush_interval -1 Caddy
|
||||||
|
# buffers those, and the connector appears to hang.
|
||||||
|
flush_interval -1
|
||||||
|
|
||||||
|
# Long enough for a `search` call, which walks every course.
|
||||||
|
transport http {
|
||||||
|
read_timeout 300s
|
||||||
|
write_timeout 300s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/schulcloud-mcp.log
|
||||||
|
format json
|
||||||
|
# Request URLs are not secrets here (the token is in a header, not the
|
||||||
|
# path), but the Authorization header must never be written to disk.
|
||||||
|
# Caddy does not log headers by default; do not add them.
|
||||||
|
}
|
||||||
|
}
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Standalone Compose file for the Pi.
|
||||||
|
#
|
||||||
|
# If you already run Caddy and PostgreSQL from another Compose project, either
|
||||||
|
# merge the `schulcloud-mcp` service below into that project's file, or keep
|
||||||
|
# this file separate and attach it to the existing Caddy network — see the
|
||||||
|
# `networks` block at the bottom and deploy/Caddyfile.snippet.
|
||||||
|
|
||||||
|
services:
|
||||||
|
schulcloud-mcp:
|
||||||
|
build: .
|
||||||
|
image: schulcloud-mcp:latest
|
||||||
|
container_name: schulcloud-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
PORT: 8080
|
||||||
|
BIND_HOST: 0.0.0.0
|
||||||
|
# No ports are published to the host: Caddy reaches the container over the
|
||||||
|
# shared Docker network, so the only way in from the internet is through
|
||||||
|
# Caddy's TLS and this server's bearer check.
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
# Set to true once this joins the network your existing Caddy already uses,
|
||||||
|
# and change the name to match (`docker network ls` to find it).
|
||||||
|
external: false
|
||||||
|
name: caddy
|
||||||
135
docs/API.md
Normal file
135
docs/API.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The Schulcloud API, as verified against this instance
|
||||||
|
|
||||||
|
Everything here was confirmed against `https://schulcloud-thueringen.de` with a
|
||||||
|
real student account on 2026-09-11, not inferred from source. Where upstream
|
||||||
|
source and live behaviour disagreed, live behaviour won.
|
||||||
|
|
||||||
|
## Two services, one origin
|
||||||
|
|
||||||
|
| Service | Source repo | Base path | Self-documenting at |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Main server (NestJS) | [`schulcloud-server`](https://github.com/hpi-schul-cloud/schulcloud-server) | `/api/v3/` | `/api/v3/docs`, `/api/v3/docs-json` |
|
||||||
|
| Files storage | [`file-storage`](https://github.com/hpi-schul-cloud/file-storage) | `/api/v3/file/` | `/api/v3/file/docs`, `/api/v3/file/docs-json` |
|
||||||
|
|
||||||
|
Both accept the same bearer token. The files service was split out of
|
||||||
|
`schulcloud-server` into its own repository, which is why no `file` paths
|
||||||
|
appear in the main `docs-json` — a detail that will send you in circles if you
|
||||||
|
only read the main spec. Fetch both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
|
||||||
|
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
|
||||||
|
```
|
||||||
|
|
||||||
|
These are the authoritative reference for *this* instance's deployed version.
|
||||||
|
Prefer them over the GitHub sources, which track `main` and may be ahead.
|
||||||
|
|
||||||
|
## Which repositories matter
|
||||||
|
|
||||||
|
The `hpi-schul-cloud` org has ~100 repos, most archived or superseded. The live
|
||||||
|
ones relevant here:
|
||||||
|
|
||||||
|
- **`schulcloud-server`** — the API. Read `apps/server/src/modules/<module>/api/`
|
||||||
|
for controllers and DTOs.
|
||||||
|
- **`file-storage`** — the files service, extracted from the above.
|
||||||
|
`src/modules/files-storage/api/controller/files-storage.controller.ts` is the
|
||||||
|
whole surface.
|
||||||
|
- **`nuxt-client`** — the current web front end. Useful for seeing which API
|
||||||
|
calls the real UI makes in which order.
|
||||||
|
- **`schulcloud-client`** — the *legacy* Handlebars front end. Still receives
|
||||||
|
commits, but it is not where new features land.
|
||||||
|
|
||||||
|
Superseded/archived and worth ignoring: `authorization-service`,
|
||||||
|
`schulcloud-editor`, `nexboard-api-js`, `end-to-end-tests`, `docker-compose`,
|
||||||
|
`H5P-Nodejs-library`, `shd-client`.
|
||||||
|
|
||||||
|
Note the naming: `/api/v1` is the old Feathers surface. On this instance
|
||||||
|
`/api/v1/docs` 404s, and the v3 NestJS API covers everything this server needs.
|
||||||
|
|
||||||
|
## Content model
|
||||||
|
|
||||||
|
```
|
||||||
|
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
|
||||||
|
│ ├─ file ──── fileRecord(s)
|
||||||
|
│ ├─ link
|
||||||
|
│ └─ …
|
||||||
|
├─ lesson (Thema) ─── contents[] + materials[]
|
||||||
|
└─ task (Aufgabe) ─── description + fileRecord(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
On the account this was built against: 26 courses holding 30 column boards, 18
|
||||||
|
lessons, 42 tasks and 175 files. **Column boards hold the great majority of
|
||||||
|
current material**; lessons are the older format.
|
||||||
|
|
||||||
|
## Endpoints this server uses
|
||||||
|
|
||||||
|
| Purpose | Call |
|
||||||
|
|---|---|
|
||||||
|
| Identity, school id, permissions | `GET /api/v3/me` |
|
||||||
|
| Courses | `GET /api/v3/courses?skip&limit` |
|
||||||
|
| One course's contents | `GET /api/v3/course-rooms/{courseId}/board` |
|
||||||
|
| Dashboard tiles | `GET /api/v3/dashboard` |
|
||||||
|
| Tasks | `GET /api/v3/tasks`, `GET /api/v3/tasks/finished` |
|
||||||
|
| Lesson body | `GET /api/v3/lessons/{lessonId}` |
|
||||||
|
| Lesson's tasks | `GET /api/v3/lessons/{lessonId}/tasks` |
|
||||||
|
| Board structure | `GET /api/v3/boards/{boardId}` |
|
||||||
|
| What a board belongs to | `GET /api/v3/boards/{boardId}/context` |
|
||||||
|
| Card bodies | `GET /api/v3/cards?ids=<id>&ids=<id>` |
|
||||||
|
| Files of an entity | `GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId}` |
|
||||||
|
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
|
||||||
|
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
|
||||||
|
| News | `GET /api/v3/news` |
|
||||||
|
|
||||||
|
### Gotchas that cost real time
|
||||||
|
|
||||||
|
**`course-rooms`, not `courses`, for course contents.** `GET /api/v3/courses/{id}`
|
||||||
|
does not exist. The route that returns a course's lessons/tasks/boards is
|
||||||
|
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
|
||||||
|
Nothing in the naming suggests this.
|
||||||
|
|
||||||
|
**`/api/v3/rooms` is a different feature.** "Rooms" are the newer standalone
|
||||||
|
collaboration spaces, unrelated to courses. On this instance the account has
|
||||||
|
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken
|
||||||
|
endpoint but is simply an empty feature.
|
||||||
|
|
||||||
|
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
|
||||||
|
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
|
||||||
|
satisfy both. Asking for 200 returns a `400 API_VALIDATION_ERROR`, not a
|
||||||
|
truncated list.
|
||||||
|
|
||||||
|
**There is no `GET /tasks/{id}`.** Single-task detail has to be assembled: the
|
||||||
|
list endpoints give metadata but *omit `description`*, which appears only on the
|
||||||
|
course page's task element. `get_task` does this join.
|
||||||
|
|
||||||
|
**Board files need three calls.** A `file` element's `content` carries only
|
||||||
|
`{caption, alternativeText}` — no file id. The bytes are found by listing
|
||||||
|
files-storage with `parentType: 'boardnodes'` and the **element** id as
|
||||||
|
`parentId`. This is the single least discoverable part of the API, and applies
|
||||||
|
equally to `fileFolder` and `drawing` elements.
|
||||||
|
|
||||||
|
**`storageLocationId` is the school id** (from `/me`), with
|
||||||
|
`storageLocation: 'school'`, for every parent type in normal use.
|
||||||
|
|
||||||
|
**Lesson ids come back as buffers.** `GET /api/v3/lessons/{id}` returns nested
|
||||||
|
ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
|
||||||
|
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
|
||||||
|
converts them.
|
||||||
|
|
||||||
|
**`Content-Disposition` on downloads is malformed.** It comes back as
|
||||||
|
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
||||||
|
percent-encoded inside the quotes. Parse defensively.
|
||||||
|
|
||||||
|
### Content element types
|
||||||
|
|
||||||
|
From `ContentElementType` in `schulcloud-server`, all seen live except where
|
||||||
|
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
|
||||||
|
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
|
||||||
|
|
||||||
|
Collaborative text editor contents are **not** retrievable through the API —
|
||||||
|
`GET /api/v3/collaborative-text-editor/{parentType}/{parentId}` returns a URL to
|
||||||
|
the Etherpad-style editor, not the document text.
|
||||||
|
|
||||||
|
## Re-verifying after an upstream release
|
||||||
|
|
||||||
|
`npm run probe` re-checks every assumption above against the live instance and
|
||||||
|
prints what it finds, including days left on the token.
|
||||||
93
docs/AUTH.md
Normal file
93
docs/AUTH.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
|
## What this server uses
|
||||||
|
|
||||||
|
The `jwt` cookie from a logged-in browser session, sent verbatim as
|
||||||
|
`Authorization: Bearer <token>`. That is the whole mechanism.
|
||||||
|
|
||||||
|
This was worth confirming rather than assuming, because the obvious reading of
|
||||||
|
"it's a cookie" leads somewhere much more complicated. Verified live:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <jwt> → 200 # what this server does
|
||||||
|
Cookie: jwt=<jwt> → 200 # also works
|
||||||
|
(no auth) → 401
|
||||||
|
```
|
||||||
|
|
||||||
|
`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed. There is no
|
||||||
|
cookie jar, no session to keep alive, and no `refresh-session` call on a timer.
|
||||||
|
|
||||||
|
## Token lifetime: 30 days
|
||||||
|
|
||||||
|
The token is a standard JWT. Decoded from the live instance:
|
||||||
|
|
||||||
|
```
|
||||||
|
iss / aud : schulcloud-thueringen.de
|
||||||
|
iat → exp : 720 hours (exactly 30 days)
|
||||||
|
claims : accountId, userId, schoolId, roles, systemId, jti,
|
||||||
|
isExternalUser, isServiceAccount, support
|
||||||
|
```
|
||||||
|
|
||||||
|
So a token copied today works for a month, and refreshing it is a calendar
|
||||||
|
chore rather than an engineering problem. `npm run probe` prints the days
|
||||||
|
remaining.
|
||||||
|
|
||||||
|
## Getting a fresh token
|
||||||
|
|
||||||
|
1. Log in to the instance in a normal browser.
|
||||||
|
2. DevTools → **Application** → **Cookies** → the instance's origin.
|
||||||
|
3. Copy the value of the **`jwt`** cookie.
|
||||||
|
4. Put it in `TSC_JWT_COOKIE` in `.env` and restart the server
|
||||||
|
(`docker compose restart schulcloud-mcp`).
|
||||||
|
|
||||||
|
There is no need to log out afterwards; the token stays valid independently of
|
||||||
|
the browser session.
|
||||||
|
|
||||||
|
## How you will know it expired
|
||||||
|
|
||||||
|
Every tool returns a specific message on `401` rather than a generic failure:
|
||||||
|
|
||||||
|
> Schulcloud rejected the token … The JWT in TSC_JWT_COOKIE has expired or been
|
||||||
|
> revoked.
|
||||||
|
|
||||||
|
That message is the signal to redo the four steps above. A `403` means the
|
||||||
|
account genuinely lacks access to that resource and is *not* a token problem.
|
||||||
|
|
||||||
|
## Why not username + password
|
||||||
|
|
||||||
|
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
|
||||||
|
pointing back at Schulcloud's own server, so the authorization-code exchange
|
||||||
|
happens server-side with a client secret only Schulcloud holds. A third party
|
||||||
|
cannot replicate that flow. `POST /api/v3/authentication/local` exists but is
|
||||||
|
for accounts with local credentials, which federated school accounts do not
|
||||||
|
have.
|
||||||
|
|
||||||
|
Given a 30-day token, the pasted-JWT approach is the right trade: one manual
|
||||||
|
step a month against re-implementing an OAuth client we cannot hold the secret
|
||||||
|
for. If this ever needs to be unattended, the honest options are a service
|
||||||
|
account issued by the school's IDM, or a headless browser login — not a
|
||||||
|
reimplementation of the Keycloak exchange.
|
||||||
|
|
||||||
|
## Protecting this server's own endpoint
|
||||||
|
|
||||||
|
Distinct from the above, and just as important. The MCP endpoint is reachable
|
||||||
|
from the public internet by construction: Claude's connectors call it from
|
||||||
|
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
|
||||||
|
a shared secret checked in constant time on every `/mcp` request
|
||||||
|
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
|
||||||
|
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
|
||||||
|
|
||||||
|
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
|
||||||
|
warning and serves unauthenticated — only acceptable bound to localhost.
|
||||||
|
|
||||||
|
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update
|
||||||
|
the connector in Claude. Nothing else stores it.
|
||||||
|
|
||||||
|
## Blast radius
|
||||||
|
|
||||||
|
Every path in this server is a `GET`, including the `api_get` escape hatch,
|
||||||
|
which rejects anything not starting with `/api/` and anything carrying a scheme
|
||||||
|
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could
|
||||||
|
read this account's Schulcloud data; they could not post, submit, delete, or
|
||||||
|
otherwise act as the user. Keep it that way — adding a single write tool would
|
||||||
|
change that property entirely.
|
||||||
157
docs/DEPLOYMENT.md
Normal file
157
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
## The shape of it
|
||||||
|
|
||||||
|
```
|
||||||
|
claude.ai ──HTTPS──▶ VPS (public IP) ──tunnel──▶ Pi 5 (home network)
|
||||||
|
└─ Caddy ──▶ schulcloud-mcp:8080
|
||||||
|
│
|
||||||
|
└──▶ schulcloud-thueringen.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude's custom connectors call the endpoint from Anthropic's cloud, so it must
|
||||||
|
be publicly reachable over real TLS — a localhost tunnel or self-signed cert
|
||||||
|
will not do. The VPS provides the public address; Caddy on the Pi terminates
|
||||||
|
TLS and obtains the certificate.
|
||||||
|
|
||||||
|
The container publishes no host port. Caddy reaches it over the shared Docker
|
||||||
|
network, so the only way in from the internet is through Caddy and then through
|
||||||
|
this server's bearer check.
|
||||||
|
|
||||||
|
## First deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <this repo> /opt/schulcloud-mcp
|
||||||
|
cd /opt/schulcloud-mcp
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
# Fill in TSC_URL and TSC_JWT_COOKIE (see docs/AUTH.md), then:
|
||||||
|
openssl rand -hex 32 # → MCP_AUTH_TOKEN
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f schulcloud-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect:
|
||||||
|
|
||||||
|
```
|
||||||
|
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
|
||||||
|
the service.
|
||||||
|
|
||||||
|
## Joining the existing Caddy
|
||||||
|
|
||||||
|
The Pi already runs Caddy and PostgreSQL in a Compose project. This server needs
|
||||||
|
neither a database nor its own Caddy — only a network it shares with the
|
||||||
|
existing one.
|
||||||
|
|
||||||
|
Find the network Caddy is on:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' <caddy-container>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `docker-compose.yml`, set that name and mark it external:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
name: <the network name you just found>
|
||||||
|
```
|
||||||
|
|
||||||
|
Append `deploy/Caddyfile.snippet` to the Pi's Caddyfile, replacing
|
||||||
|
`mcp.example.org` with the real hostname, and reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Two settings in that snippet matter and are easy to miss:
|
||||||
|
|
||||||
|
- **`flush_interval -1`** — MCP's Streamable HTTP transport holds a
|
||||||
|
server-sent-events channel open. Without this, Caddy buffers it and the
|
||||||
|
connector hangs with no error.
|
||||||
|
- **`read_timeout`/`write_timeout` of 300s** — a `search` call walks every
|
||||||
|
course and can take tens of seconds. Caddy's defaults will cut it off.
|
||||||
|
|
||||||
|
## Ports and DNS
|
||||||
|
|
||||||
|
- DNS for the hostname points at the **VPS**, not the Pi.
|
||||||
|
- The VPS forwards 80 and 443 to the Pi's Caddy. Port 80 must work too, or
|
||||||
|
Caddy cannot complete the ACME HTTP challenge.
|
||||||
|
- Nothing else needs to be exposed.
|
||||||
|
|
||||||
|
## Verifying from outside
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://mcp.example.org/healthz
|
||||||
|
# {"status":"ok","sessions":0}
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
|
||||||
|
-H 'content-type: application/json' -d '{}'
|
||||||
|
# 401 ← the bearer check is live
|
||||||
|
```
|
||||||
|
|
||||||
|
If `/healthz` answers but `/mcp` returns 401 with a correct token, check that
|
||||||
|
the token in `.env` matches the one in the connector exactly — no trailing
|
||||||
|
newline from a copy-paste.
|
||||||
|
|
||||||
|
## Connecting Claude
|
||||||
|
|
||||||
|
1. claude.ai → **Settings → Connectors → Add custom connector**.
|
||||||
|
2. URL: `https://mcp.example.org/mcp`
|
||||||
|
3. Under **Advanced settings**, add the bearer token as an authorization
|
||||||
|
header. If your organisation has no header-auth field, the server also
|
||||||
|
accepts the token as `X-Api-Key`.
|
||||||
|
4. Enable the connector in a conversation via **+ → Add connectors**.
|
||||||
|
|
||||||
|
Ask *"which courses am I in?"* as a first check — that exercises auth, the
|
||||||
|
Schulcloud token and the API in one call.
|
||||||
|
|
||||||
|
## Running it locally instead
|
||||||
|
|
||||||
|
For Claude Code or Claude Desktop on your own machine, skip all of the above and
|
||||||
|
use stdio:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"schulcloud": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/schulcloud-mcp/dist/bin/stdio.js"],
|
||||||
|
"env": {
|
||||||
|
"TSC_URL": "https://schulcloud-thueringen.de",
|
||||||
|
"TSC_JWT_COOKIE": "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MCP_AUTH_TOKEN` is irrelevant in stdio mode — there is no network listener.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/schulcloud-mcp && git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec schulcloud-mcp node -e "1" # sanity
|
||||||
|
npm run probe # re-verify the API assumptions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Restart policy** is `unless-stopped`; the container comes back after a
|
||||||
|
reboot.
|
||||||
|
- **Sessions** are in-memory and dropped after 30 minutes idle. A restart
|
||||||
|
invalidates them; Claude re-initializes transparently.
|
||||||
|
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
|
||||||
|
- **The container is read-only** with `cap_drop: ALL` and
|
||||||
|
`no-new-privileges`, running as the unprivileged `node` user. It writes
|
||||||
|
nothing to disk — downloads are streamed through memory, capped at
|
||||||
|
`MAX_DOWNLOAD_BYTES` (25 MiB default).
|
||||||
|
- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how
|
||||||
|
many days are left.
|
||||||
2494
package-lock.json
generated
Normal file
2494
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "schulcloud-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "MCP server exposing the Schulcloud (HPI Schul-Cloud / SVS) API to Claude: courses, boards, lessons, tasks and file downloads.",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"schulcloud-mcp": "dist/bin/stdio.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "node --watch --experimental-strip-types src/bin/http.ts",
|
||||||
|
"start": "node dist/bin/http.js",
|
||||||
|
"stdio": "node dist/bin/stdio.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test test/*.test.ts",
|
||||||
|
"probe": "node --env-file=.env scripts/probe.mjs",
|
||||||
|
"smoke": "node --env-file=.env scripts/smoke.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.20.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
|
"unpdf": "^1.3.2",
|
||||||
|
"unzipper": "^0.12.3",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^22.15.0",
|
||||||
|
"@types/unzipper": "^0.10.11",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
114
scripts/probe.mjs
Normal file
114
scripts/probe.mjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Verifies this server's assumptions against the live instance and prints what
|
||||||
|
* it finds. Run it after a Schulcloud release, or when a tool starts failing,
|
||||||
|
* to tell "the token expired" apart from "the API moved".
|
||||||
|
*
|
||||||
|
* Read-only. Usage: `node --env-file=.env scripts/probe.mjs`
|
||||||
|
*/
|
||||||
|
import { loadConfig } from '../dist/config.js';
|
||||||
|
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = new SchulcloudClient(config);
|
||||||
|
|
||||||
|
console.log(`instance: ${config.baseUrl}\n`);
|
||||||
|
|
||||||
|
// --- token ---------------------------------------------------------------
|
||||||
|
const payload = decodeJwt(config.jwt);
|
||||||
|
if (payload) {
|
||||||
|
const expires = new Date(payload.exp * 1000);
|
||||||
|
const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000;
|
||||||
|
console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 10)}, ` +
|
||||||
|
`expires ${expires.toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`);
|
||||||
|
if (daysLeft < 0) console.log(' *** EXPIRED — copy a fresh jwt cookie, see docs/AUTH.md');
|
||||||
|
else if (daysLeft < 5) console.log(' *** expiring soon — plan to copy a fresh jwt cookie');
|
||||||
|
} else {
|
||||||
|
console.log('token: could not decode (not a JWT?)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- endpoints this server depends on ------------------------------------
|
||||||
|
const checks = [
|
||||||
|
['GET /api/v3/me', () => client.me()],
|
||||||
|
['GET /api/v3/courses', () => client.listCourses({ limit: 1 })],
|
||||||
|
['GET /api/v3/dashboard', () => client.getDashboard()],
|
||||||
|
['GET /api/v3/tasks', () => client.listTasks({ limit: 1 })],
|
||||||
|
['GET /api/v3/tasks/finished', () => client.listFinishedTasks({ limit: 1 })],
|
||||||
|
['GET /api/v3/news', () => client.listNews({ limit: 1 })],
|
||||||
|
['GET /api/v3/docs-json', () => client.getJson('/api/v3/docs-json')],
|
||||||
|
['GET /api/v3/file/docs-json', () => client.getJson('/api/v3/file/docs-json')],
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('\ncore endpoints:');
|
||||||
|
let me;
|
||||||
|
for (const [label, run] of checks) {
|
||||||
|
try {
|
||||||
|
const result = await run();
|
||||||
|
if (label.endsWith('/me')) me = result;
|
||||||
|
console.log(` ok ${label}${summarize(result)}`);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof SchulcloudApiError ? error.status : '—';
|
||||||
|
console.log(` FAIL ${label} → ${status} ${error.message.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the chains that make the content tools work -------------------------
|
||||||
|
if (me) {
|
||||||
|
console.log('\ncontent chain:');
|
||||||
|
const courses = await client.listAllCourses();
|
||||||
|
console.log(` ${courses.length} course(s) visible`);
|
||||||
|
|
||||||
|
let boardId, lessonId;
|
||||||
|
for (const course of courses) {
|
||||||
|
const page = await client.getCourseBoard(course.id).catch(() => null);
|
||||||
|
if (!page) continue;
|
||||||
|
boardId ??= page.elements.find((e) => e.type === 'column-board')?.content.id;
|
||||||
|
lessonId ??= page.elements.find((e) => e.type === 'lesson')?.content.id;
|
||||||
|
if (boardId && lessonId) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boardId) {
|
||||||
|
const skeleton = await client.getBoardSkeleton(boardId);
|
||||||
|
const cardIds = skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId));
|
||||||
|
const cards = await client.getCards(cardIds.slice(0, 5));
|
||||||
|
console.log(` ok board → columns → cards (board ${boardId}: ${skeleton.columns.length} col, ${cardIds.length} cards)`);
|
||||||
|
|
||||||
|
const fileElement = cards.flatMap((c) => c.elements).find((e) => e.type === 'file');
|
||||||
|
if (fileElement) {
|
||||||
|
const files = await client.listFiles({
|
||||||
|
storageLocationId: me.school.id,
|
||||||
|
parentType: 'boardnodes',
|
||||||
|
parentId: fileElement.id,
|
||||||
|
});
|
||||||
|
console.log(` ok file element → files-storage (${files.total} file(s) on element ${fileElement.id})`);
|
||||||
|
} else {
|
||||||
|
console.log(' — no file element among the sampled cards');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(' — no column board found to test with');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lessonId) {
|
||||||
|
const lesson = await client.getLesson(lessonId);
|
||||||
|
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(result) {
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
if ('total' in result) return ` (total ${result.total})`;
|
||||||
|
if ('paths' in result) return ` (${Object.keys(result.paths).length} paths)`;
|
||||||
|
if ('school' in result) return ` (${result.school.name})`;
|
||||||
|
if ('gridElements' in result) return ` (${result.gridElements.length} tiles)`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwt(token) {
|
||||||
|
try {
|
||||||
|
const part = token.split('.')[1];
|
||||||
|
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
170
scripts/smoke.mjs
Normal file
170
scripts/smoke.mjs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* End-to-end smoke test: starts the HTTP server, connects a real MCP client
|
||||||
|
* over Streamable HTTP, and exercises every tool against the live instance.
|
||||||
|
*
|
||||||
|
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
|
||||||
|
* Read-only — it never writes to Schulcloud.
|
||||||
|
*/
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { loadConfig } from '../dist/config.js';
|
||||||
|
import { createHttpApp } from '../dist/http/server.js';
|
||||||
|
|
||||||
|
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
|
||||||
|
process.env.MCP_AUTH_TOKEN = TOKEN;
|
||||||
|
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = createHttpApp(config);
|
||||||
|
const httpServer = await new Promise((resolve) => {
|
||||||
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||||
|
});
|
||||||
|
const { port } = httpServer.address();
|
||||||
|
const base = `http://127.0.0.1:${port}/mcp`;
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function check(name, ok, detail) {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
if (!ok) failures++;
|
||||||
|
console.log(`${ok ? ' PASS' : ' FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth gate ---------------------------------------------------------
|
||||||
|
console.log('\n== auth ==');
|
||||||
|
{
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
||||||
|
});
|
||||||
|
check('rejects request with no token', res.status === 401, `got ${res.status}`);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong-token' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
||||||
|
});
|
||||||
|
check('rejects wrong token', res.status === 401, `got ${res.status}`);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||||
|
check('healthz is open and ok', res.status === 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- connect -----------------------------------------------------------
|
||||||
|
console.log('\n== protocol ==');
|
||||||
|
const client = new Client({ name: 'smoke', version: '0' }, { capabilities: {} });
|
||||||
|
await client.connect(
|
||||||
|
new StreamableHTTPClientTransport(new URL(base), {
|
||||||
|
requestInit: { headers: { authorization: `Bearer ${TOKEN}` } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check('client connected with valid token', true);
|
||||||
|
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const names = tools.map((t) => t.name).sort();
|
||||||
|
check('tools listed', tools.length > 0, names.join(', '));
|
||||||
|
check(
|
||||||
|
'every tool has a description and schema',
|
||||||
|
tools.every((t) => t.description && t.inputSchema),
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = async (name, args = {}) => {
|
||||||
|
const res = await client.callTool({ name, arguments: args });
|
||||||
|
const text = res.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
||||||
|
return { res, text, isError: res.isError === true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- tools against the live instance -----------------------------------
|
||||||
|
console.log('\n== live tools ==');
|
||||||
|
const who = await call('whoami');
|
||||||
|
check('whoami', !who.isError && /School:/.test(who.text), who.text.split('\n')[0]);
|
||||||
|
|
||||||
|
const courses = await call('list_courses', { limit: 100 });
|
||||||
|
check('list_courses', !courses.isError && /Courses \(/.test(courses.text));
|
||||||
|
const courseIds = [...courses.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
|
||||||
|
check('list_courses returned usable ids', courseIds.length > 0, `${courseIds.length} courses`);
|
||||||
|
|
||||||
|
const active = await call('list_courses', { activeOnly: true });
|
||||||
|
check('list_courses activeOnly', !active.isError);
|
||||||
|
|
||||||
|
const dash = await call('get_dashboard');
|
||||||
|
check('get_dashboard', !dash.isError);
|
||||||
|
|
||||||
|
const tasks = await call('list_tasks', { scope: 'open' });
|
||||||
|
check('list_tasks open', !tasks.isError);
|
||||||
|
const taskId = tasks.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
|
||||||
|
|
||||||
|
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
|
||||||
|
check('list_news', !(await call('list_news')).isError);
|
||||||
|
|
||||||
|
// Walk courses until we find one with a board, to exercise the whole chain.
|
||||||
|
let boardId, fileId, lessonId, courseWithBoard;
|
||||||
|
for (const id of courseIds) {
|
||||||
|
const course = await call('get_course', { courseId: id });
|
||||||
|
if (course.isError) continue;
|
||||||
|
courseWithBoard ??= id;
|
||||||
|
const b = course.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||||
|
const l = course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||||
|
lessonId ??= l;
|
||||||
|
if (b && !boardId) boardId = b;
|
||||||
|
if (boardId && lessonId) break;
|
||||||
|
}
|
||||||
|
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
|
||||||
|
check('found a column board', Boolean(boardId), boardId);
|
||||||
|
|
||||||
|
if (boardId) {
|
||||||
|
const board = await call('get_board', { boardId });
|
||||||
|
check('get_board', !board.isError && /Board id:/.test(board.text));
|
||||||
|
fileId = board.text.match(/File: \*\*[^*]+\*\* \(`([0-9a-f]{24})`/)?.[1];
|
||||||
|
check('get_board resolved attachments', Boolean(fileId), fileId ?? 'no files on this board');
|
||||||
|
check('get_board includeFiles=false', !(await call('get_board', { boardId, includeFiles: false })).isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lessonId) check('get_lesson', !(await call('get_lesson', { lessonId })).isError, lessonId);
|
||||||
|
if (taskId) {
|
||||||
|
const task = await call('get_task', { taskId });
|
||||||
|
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the board had no file, fall back to hunting one on a task.
|
||||||
|
if (!fileId && taskId) {
|
||||||
|
const listed = await call('list_files', { parentType: 'tasks', parentId: taskId });
|
||||||
|
fileId = listed.text.match(/\(`([0-9a-f]{24})`/)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileId) {
|
||||||
|
const dl = await call('download_file', { fileId });
|
||||||
|
check('download_file extracts content', !dl.isError && /## /.test(dl.text), dl.text.split('\n').slice(0, 1).join(''));
|
||||||
|
const extracted = /extracted \d+ characters|returned inline|no text extractor/.test(dl.text);
|
||||||
|
check('download_file reported an extraction outcome', extracted);
|
||||||
|
const raw = await call('download_file', { fileId, raw: true });
|
||||||
|
check('download_file raw=true', !raw.isError && /Base64/.test(raw.text));
|
||||||
|
} else {
|
||||||
|
check('download_file', false, 'no file id found to test with');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n== search ==');
|
||||||
|
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
|
||||||
|
const search = await call('search', { query: searchTerm });
|
||||||
|
check(`search "${searchTerm}"`, !search.isError, search.text.split('\n')[0]);
|
||||||
|
check('search scoped to one course', !(await call('search', { query: 'a b', courseId: courseIds[0] })).isError);
|
||||||
|
|
||||||
|
console.log('\n== api_get guard rails ==');
|
||||||
|
check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError);
|
||||||
|
check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError);
|
||||||
|
check('api_get rejects absolute URL', (await call('api_get', { path: 'https://evil.test/api/x' })).isError);
|
||||||
|
|
||||||
|
console.log('\n== error handling ==');
|
||||||
|
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
|
||||||
|
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
httpServer.close();
|
||||||
|
|
||||||
|
console.log(`\n${results.length - failures}/${results.length} checks passed`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
32
src/bin/http.ts
Normal file
32
src/bin/http.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { loadConfig } from '../config.ts';
|
||||||
|
import { createHttpApp } from '../http/server.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP entry point — the deployed form of this server, sitting behind Caddy.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = createHttpApp(config);
|
||||||
|
|
||||||
|
const server = app.listen(config.port, config.bindHost, () => {
|
||||||
|
console.log(
|
||||||
|
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
||||||
|
`auth ${config.authToken ? 'enabled' : 'DISABLED'}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let Docker's SIGTERM drain in-flight requests instead of cutting them off.
|
||||||
|
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 10_000).unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error('[schulcloud-mcp] fatal:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
22
src/bin/stdio.ts
Normal file
22
src/bin/stdio.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { loadConfig } from '../config.ts';
|
||||||
|
import { createServer } from '../server.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stdio entry point — for running the server locally against Claude Code or
|
||||||
|
* Claude Desktop. The remote deployment uses bin/http.ts instead.
|
||||||
|
*
|
||||||
|
* Nothing may be written to stdout here except MCP protocol frames.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const { server } = createServer(config);
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error('[schulcloud-mcp] fatal:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
52
src/config.ts
Normal file
52
src/config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Runtime configuration, read once from the environment.
|
||||||
|
*
|
||||||
|
* The two Schulcloud values are named after the browser artefacts they come
|
||||||
|
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
|
||||||
|
* DevTools stays an obvious, mechanical step — see docs/AUTH.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
|
||||||
|
jwt: string;
|
||||||
|
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
||||||
|
authToken: string | undefined;
|
||||||
|
port: number;
|
||||||
|
bindHost: string;
|
||||||
|
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
|
||||||
|
maxDownloadBytes: number;
|
||||||
|
/** Characters of extracted text returned before truncation kicks in. */
|
||||||
|
maxExtractedChars: number;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
if (!value) throw new Error(`Missing required environment variable ${name}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function int(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name]?.trim();
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`Environment variable ${name} must be a positive integer, got ${raw}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
return {
|
||||||
|
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||||
|
jwt: required('TSC_JWT_COOKIE'),
|
||||||
|
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
||||||
|
port: int('PORT', 8080),
|
||||||
|
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
|
||||||
|
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
||||||
|
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
|
||||||
|
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/context.ts
Normal file
38
src/context.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Config } from './config.ts';
|
||||||
|
import { SchulcloudClient } from './schulcloud/client.ts';
|
||||||
|
import type { MeResponse } from './schulcloud/types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-process state shared by every tool.
|
||||||
|
*
|
||||||
|
* The only thing worth holding onto is the identity from `/api/v3/me`: the
|
||||||
|
* school id is a required path segment for every files-storage call, and it
|
||||||
|
* cannot change for a given JWT. Everything else is fetched live.
|
||||||
|
*/
|
||||||
|
export class ServerContext {
|
||||||
|
readonly client: SchulcloudClient;
|
||||||
|
private identity: Promise<MeResponse> | undefined;
|
||||||
|
|
||||||
|
constructor(readonly config: Config) {
|
||||||
|
this.client = new SchulcloudClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached `/me`. Shared promise, so concurrent first calls make one request. */
|
||||||
|
me(): Promise<MeResponse> {
|
||||||
|
this.identity ??= this.client.me().catch((error: unknown) => {
|
||||||
|
// Don't cache a failure — a replaced JWT should be able to recover.
|
||||||
|
this.identity = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return this.identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async schoolId(): Promise<string> {
|
||||||
|
return (await this.me()).school.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the cached identity so the next call re-reads it. */
|
||||||
|
reset(): void {
|
||||||
|
this.identity = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/extract.ts
Normal file
223
src/extract.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a downloaded file into something Claude can actually read.
|
||||||
|
*
|
||||||
|
* Schulcloud material is overwhelmingly PDF, DOCX and images, so those get
|
||||||
|
* real extractors; the long tail falls back to a plain-text read when the
|
||||||
|
* bytes look like text, and to a "binary, not extractable" note otherwise.
|
||||||
|
* Heavy parsers are imported lazily so that a server that only ever lists
|
||||||
|
* files never pays for loading them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExtractionKind = 'text' | 'image' | 'binary';
|
||||||
|
|
||||||
|
export interface Extraction {
|
||||||
|
kind: ExtractionKind;
|
||||||
|
/** Extracted text, for `kind: 'text'`. */
|
||||||
|
text?: string;
|
||||||
|
/** Base64 payload plus its media type, for `kind: 'image'`. */
|
||||||
|
image?: { base64: string; mimeType: string };
|
||||||
|
/** Human-readable note about what happened, always present. */
|
||||||
|
note: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
|
|
||||||
|
const PLAIN_TEXT_TYPES = new Set([
|
||||||
|
'text/plain',
|
||||||
|
'text/markdown',
|
||||||
|
'text/csv',
|
||||||
|
'text/html',
|
||||||
|
'text/xml',
|
||||||
|
'application/json',
|
||||||
|
'application/xml',
|
||||||
|
'application/x-yaml',
|
||||||
|
'text/yaml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function extractContent(
|
||||||
|
bytes: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
fileName: string,
|
||||||
|
maxChars: number,
|
||||||
|
): Promise<Extraction> {
|
||||||
|
const type = mimeType.toLowerCase();
|
||||||
|
const ext = fileName.toLowerCase().split('.').pop() ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (IMAGE_TYPES.has(type)) {
|
||||||
|
return {
|
||||||
|
kind: 'image',
|
||||||
|
image: { base64: bytes.toString('base64'), mimeType: type },
|
||||||
|
note: `Image (${type}, ${formatBytes(bytes.length)}) returned inline.`,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'application/pdf' || ext === 'pdf') {
|
||||||
|
return finishText(await extractPdf(bytes), maxChars, 'PDF');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.includes('wordprocessingml') || ext === 'docx') {
|
||||||
|
return finishText(await extractDocx(bytes), maxChars, 'Word document');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.includes('spreadsheetml') || ext === 'xlsx' || ext === 'xlsm') {
|
||||||
|
return finishText(await extractXlsx(bytes), maxChars, 'Excel workbook');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.includes('presentationml') || ext === 'pptx') {
|
||||||
|
return finishText(await extractOoxmlZipText(bytes, /^ppt\/slides\/slide\d+\.xml$/), maxChars, 'PowerPoint deck');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.startsWith('application/vnd.oasis.opendocument') || ['odt', 'odp', 'ods'].includes(ext)) {
|
||||||
|
return finishText(await extractOoxmlZipText(bytes, /^content\.xml$/), maxChars, 'OpenDocument file');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PLAIN_TEXT_TYPES.has(type) || type.startsWith('text/') || looksLikeUtf8Text(bytes)) {
|
||||||
|
return finishText(bytes.toString('utf8'), maxChars, 'Text file');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
kind: 'binary',
|
||||||
|
note:
|
||||||
|
`Could not extract text from ${fileName} (${type}): ${error instanceof Error ? error.message : String(error)}. ` +
|
||||||
|
`Use download_file with raw=true to get the bytes.`,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'binary',
|
||||||
|
note: `${fileName} is ${type} (${formatBytes(bytes.length)}) — no text extractor for this format. Use download_file with raw=true to get base64 bytes.`,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishText(raw: string, maxChars: number, label: string): Extraction {
|
||||||
|
const cleaned = normalizeWhitespace(raw);
|
||||||
|
const truncated = cleaned.length > maxChars;
|
||||||
|
const text = truncated ? cleaned.slice(0, maxChars) : cleaned;
|
||||||
|
return {
|
||||||
|
kind: 'text',
|
||||||
|
text,
|
||||||
|
note: truncated
|
||||||
|
? `${label}: extracted text truncated to ${maxChars} characters (of ${cleaned.length}).`
|
||||||
|
: `${label}: extracted ${cleaned.length} characters of text.`,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractPdf(bytes: Buffer): Promise<string> {
|
||||||
|
const { extractText, getDocumentProxy } = await import('unpdf');
|
||||||
|
const document = await getDocumentProxy(new Uint8Array(bytes));
|
||||||
|
const { text } = await extractText(document, { mergePages: true });
|
||||||
|
return Array.isArray(text) ? text.join('\n\n') : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractDocx(bytes: Buffer): Promise<string> {
|
||||||
|
const mammoth = (await import('mammoth')).default;
|
||||||
|
const { value } = await mammoth.extractRawText({ buffer: bytes });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractXlsx(bytes: Buffer): Promise<string> {
|
||||||
|
const ExcelJS = (await import('exceljs')).default;
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(bytes as unknown as ArrayBuffer);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
workbook.eachSheet((sheet) => {
|
||||||
|
parts.push(`## Sheet: ${sheet.name}`);
|
||||||
|
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||||||
|
const cells: string[] = [];
|
||||||
|
row.eachCell({ includeEmpty: true }, (cell) => cells.push(cellText(cell.value)));
|
||||||
|
// Trailing empties carry no information once the row is tabular.
|
||||||
|
while (cells.length && cells.at(-1) === '') cells.pop();
|
||||||
|
if (cells.length) parts.push(cells.join('\t'));
|
||||||
|
});
|
||||||
|
parts.push('');
|
||||||
|
});
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.text === 'string') return record.text;
|
||||||
|
if (typeof record.result === 'string' || typeof record.result === 'number') return String(record.result);
|
||||||
|
if (Array.isArray(record.richText)) {
|
||||||
|
return record.richText.map((run) => String((run as { text?: unknown }).text ?? '')).join('');
|
||||||
|
}
|
||||||
|
if (typeof record.hyperlink === 'string') return record.hyperlink;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls visible text out of an OOXML/ODF container by reading the XML parts
|
||||||
|
* matching `pattern` and stripping tags. Crude, but these formats put their
|
||||||
|
* prose in text nodes, which is all we need for "read me this slide deck".
|
||||||
|
*
|
||||||
|
* Uses unzipper's random-access API rather than its stream parser: the stream
|
||||||
|
* emits entries faster than their bodies can be buffered, so a streaming read
|
||||||
|
* finishes before the contents arrive.
|
||||||
|
*/
|
||||||
|
async function extractOoxmlZipText(bytes: Buffer, pattern: RegExp): Promise<string> {
|
||||||
|
const unzipper = await import('unzipper');
|
||||||
|
const directory = await unzipper.Open.buffer(bytes);
|
||||||
|
|
||||||
|
const wanted = directory.files.filter((file) => file.type === 'File' && pattern.test(file.path));
|
||||||
|
// slide2 must not sort before slide10's neighbours by string order.
|
||||||
|
wanted.sort((a, b) => numericSuffix(a.path) - numericSuffix(b.path));
|
||||||
|
|
||||||
|
const parts = await Promise.all(
|
||||||
|
wanted.map(async (file) => xmlToText((await file.buffer()).toString('utf8'))),
|
||||||
|
);
|
||||||
|
return parts.filter((part) => part.trim()).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericSuffix(path: string): number {
|
||||||
|
return Number.parseInt(/(\d+)\.xml$/.exec(path)?.[1] ?? '0', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlToText(xml: string): string {
|
||||||
|
return xml
|
||||||
|
// Paragraph and line-break tags are the only structure worth keeping.
|
||||||
|
.replace(/<\/(a:p|w:p|text:p|text:h)>/g, '\n')
|
||||||
|
.replace(/<(a:br|w:br|text:line-break)\b[^>]*\/?>/g, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWhitespace(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heuristic: decodable as UTF-8 and free of NUL bytes in the sampled prefix. */
|
||||||
|
function looksLikeUtf8Text(bytes: Buffer): boolean {
|
||||||
|
const sample = bytes.subarray(0, 4096);
|
||||||
|
if (sample.includes(0)) return false;
|
||||||
|
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(sample);
|
||||||
|
return !decoded.includes('<27>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
50
src/http/auth.ts
Normal file
50
src/http/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer-token gate for the public endpoint.
|
||||||
|
*
|
||||||
|
* This server is reachable from the internet by construction — Claude's
|
||||||
|
* connectors call it from Anthropic's cloud, not from the user's machine — so
|
||||||
|
* the token is the only thing between a stranger and the account's data.
|
||||||
|
* Comparison is constant-time, and a miss returns a bare 401 with a
|
||||||
|
* `WWW-Authenticate` challenge and no detail about why.
|
||||||
|
*/
|
||||||
|
export function bearerAuth(expected: string) {
|
||||||
|
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||||
|
|
||||||
|
return function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const presented = extractToken(req.get('authorization'), req.get('x-api-key'));
|
||||||
|
if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
|
||||||
|
res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"');
|
||||||
|
res.status(401).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32001, message: 'Unauthorized' },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
|
||||||
|
if (authorization) {
|
||||||
|
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
||||||
|
if (match?.[1]) return match[1].trim();
|
||||||
|
}
|
||||||
|
// Some connector UIs only offer a custom header rather than Authorization.
|
||||||
|
return apiKey?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constantTimeEquals(a: Buffer, b: Buffer): boolean {
|
||||||
|
// timingSafeEqual throws on length mismatch, which would itself leak length.
|
||||||
|
// Hash-free equalisation: compare against a padded copy of the same size.
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
const padded = Buffer.alloc(b.length);
|
||||||
|
a.copy(padded, 0, 0, Math.min(a.length, b.length));
|
||||||
|
timingSafeEqual(padded, b);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
144
src/http/server.ts
Normal file
144
src/http/server.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import express, { type Request, type Response } from 'express';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Config } from '../config.ts';
|
||||||
|
import { createServer } from '../server.ts';
|
||||||
|
import { bearerAuth } from './auth.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streamable-HTTP front end, for use as a remote MCP connector.
|
||||||
|
*
|
||||||
|
* Sessions are stateful: a client POSTs `initialize`, gets an
|
||||||
|
* `Mcp-Session-Id` back, and reuses it for subsequent POSTs, an optional GET
|
||||||
|
* (the SSE channel for server-initiated messages), and a DELETE to close.
|
||||||
|
* Each session owns one McpServer instance, which keeps per-session caches
|
||||||
|
* (the `/me` lookup) from leaking between callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MCP_PATH = '/mcp';
|
||||||
|
/** Sessions are dropped after this long without traffic, in case DELETE never arrives. */
|
||||||
|
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
transport: StreamableHTTPServerTransport;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
lastSeen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpApp(config: Config): express.Express {
|
||||||
|
const app = express();
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
// Caddy sits in front and terminates TLS; trust its forwarding headers so
|
||||||
|
// logged client IPs are real rather than the proxy's.
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
|
const sweep = setInterval(() => {
|
||||||
|
const cutoff = Date.now() - SESSION_IDLE_MS;
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (session.lastSeen < cutoff) {
|
||||||
|
sessions.delete(id);
|
||||||
|
void session.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
sweep.unref();
|
||||||
|
|
||||||
|
// Liveness probe for Docker/Caddy. Deliberately before auth and free of any
|
||||||
|
// detail about the instance or the account.
|
||||||
|
app.get('/healthz', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', sessions: sessions.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.authToken) {
|
||||||
|
app.use(MCP_PATH, bearerAuth(config.authToken));
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
||||||
|
'Only acceptable when bound to localhost or an otherwise private network.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||||
|
|
||||||
|
app.post(MCP_PATH, async (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.get('mcp-session-id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json(rpcError(-32001, 'Unknown or expired session. Re-initialize.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.lastSeen = Date.now();
|
||||||
|
await session.transport.handleRequest(req, res, req.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInitializeRequest(req.body)) {
|
||||||
|
res.status(400).json(rpcError(-32000, 'Missing Mcp-Session-Id header; send an initialize request first.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { server } = createServer(config);
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
|
onsessioninitialized: (id) => {
|
||||||
|
sessions.set(id, {
|
||||||
|
transport,
|
||||||
|
close: async () => {
|
||||||
|
await transport.close().catch(() => {});
|
||||||
|
await server.close().catch(() => {});
|
||||||
|
},
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
});
|
||||||
|
console.log(`[schulcloud-mcp] session ${id} initialized`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.onclose = () => {
|
||||||
|
if (transport.sessionId) {
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
console.log(`[schulcloud-mcp] session ${transport.sessionId} closed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[schulcloud-mcp] POST failed:', error);
|
||||||
|
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET opens the server→client SSE stream; DELETE ends the session.
|
||||||
|
const bySession = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const sessionId = req.get('mcp-session-id');
|
||||||
|
const session = sessionId ? sessions.get(sessionId) : undefined;
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json(rpcError(-32001, 'Unknown or expired session.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.lastSeen = Date.now();
|
||||||
|
try {
|
||||||
|
await session.transport.handleRequest(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[schulcloud-mcp] session request failed:', error);
|
||||||
|
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get(MCP_PATH, bySession);
|
||||||
|
app.delete(MCP_PATH, bySession);
|
||||||
|
|
||||||
|
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcError(code: number, message: string) {
|
||||||
|
return { jsonrpc: '2.0' as const, error: { code, message }, id: null };
|
||||||
|
}
|
||||||
81
src/render.ts
Normal file
81
src/render.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Formatting helpers shared by the tools.
|
||||||
|
*
|
||||||
|
* Tool results are read by a model, so everything renders to compact Markdown
|
||||||
|
* rather than raw JSON: ids stay visible (Claude needs them for follow-up
|
||||||
|
* calls) but the surrounding noise — display colours, positions, buffer-shaped
|
||||||
|
* Mongo ids — is dropped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Collapses Schulcloud's CKEditor HTML into plain text, keeping link targets. */
|
||||||
|
export function htmlToText(html: string | undefined | null): string {
|
||||||
|
if (!html) return '';
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
|
||||||
|
.replace(/<li[^>]*>/gi, '- ')
|
||||||
|
// Keep the href when the anchor text does not already contain it.
|
||||||
|
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
|
||||||
|
const text = label.replace(/<[^>]+>/g, '').trim();
|
||||||
|
if (!text) return href;
|
||||||
|
return text === href ? href : `${text} (${href})`;
|
||||||
|
})
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'|'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2026-08-17T08:00:00.000Z` → `2026-08-17 08:00`; passes other values through. */
|
||||||
|
export function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toISOString().replace('T', ' ').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days from now until `value`; negative when overdue. `undefined` if unset. */
|
||||||
|
export function daysUntil(value: string | null | undefined): number | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
|
return Math.round((date.getTime() - Date.now()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dueLabel(dueDate: string | null | undefined): string {
|
||||||
|
const days = daysUntil(dueDate);
|
||||||
|
if (days === undefined) return 'no due date';
|
||||||
|
if (days < 0) return `due ${formatDate(dueDate)} (${Math.abs(days)}d overdue)`;
|
||||||
|
if (days === 0) return `due ${formatDate(dueDate)} (today)`;
|
||||||
|
return `due ${formatDate(dueDate)} (in ${days}d)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heading(level: number, text: string): string {
|
||||||
|
return `${'#'.repeat(level)} ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Joins sections, dropping empties, with exactly one blank line between them. */
|
||||||
|
export function joinSections(parts: (string | undefined | null | false)[]): string {
|
||||||
|
return parts.filter((part): part is string => Boolean(part && part.trim())).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mongo ObjectIds sometimes come back from the legacy lesson API serialised as
|
||||||
|
* `{ buffer: { type: 'Buffer', data: [...] } }` instead of a hex string.
|
||||||
|
*/
|
||||||
|
export function normalizeObjectId(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const data = (value as { buffer?: { data?: unknown } }).buffer?.data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((byte) => Number(byte).toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
153
src/schulcloud/board.ts
Normal file
153
src/schulcloud/board.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type { SchulcloudClient } from './client.ts';
|
||||||
|
import { SchulcloudApiError } from './client.ts';
|
||||||
|
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles a column board into one self-contained structure.
|
||||||
|
*
|
||||||
|
* The API deliberately splits this across three calls — skeleton, card bodies,
|
||||||
|
* and (per file element) a files-storage lookup — because the web client
|
||||||
|
* renders them independently. A model asking "what's on this board" wants all
|
||||||
|
* of it at once, so this stitches the pieces together and resolves every file
|
||||||
|
* element to a real file record in parallel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AssembledElement {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
/** Plain-text body for richText/link elements. */
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
/** File records attached to this element, for `file` and `fileFolder`. */
|
||||||
|
files: FileRecord[];
|
||||||
|
/** Set when this element's files could not be resolved. */
|
||||||
|
fileError?: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembledCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
elements: AssembledElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembledColumn {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
cards: AssembledCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembledBoard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
context?: { id: string; type: string };
|
||||||
|
columns: AssembledColumn[];
|
||||||
|
fileCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Element types whose attachments live under the `boardnodes` parent type. */
|
||||||
|
const FILE_BEARING_TYPES = new Set(['file', 'fileFolder', 'drawing']);
|
||||||
|
|
||||||
|
export async function assembleBoard(
|
||||||
|
client: SchulcloudClient,
|
||||||
|
boardId: string,
|
||||||
|
schoolId: string,
|
||||||
|
options: { resolveFiles?: boolean } = {},
|
||||||
|
): Promise<AssembledBoard> {
|
||||||
|
const resolveFiles = options.resolveFiles ?? true;
|
||||||
|
|
||||||
|
const [skeleton, context] = await Promise.all([
|
||||||
|
client.getBoardSkeleton(boardId),
|
||||||
|
client.getBoardContext(boardId).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cardIds = skeleton.columns.flatMap((column) => column.cards.map((card) => card.cardId));
|
||||||
|
const cards = cardIds.length > 0 ? await client.getCards(cardIds) : [];
|
||||||
|
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||||
|
|
||||||
|
const assembled = buildColumns(skeleton, cardsById);
|
||||||
|
|
||||||
|
if (resolveFiles) {
|
||||||
|
await attachFiles(client, assembled, schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = assembled
|
||||||
|
.flatMap((column) => column.cards)
|
||||||
|
.flatMap((card) => card.elements)
|
||||||
|
.reduce((sum, element) => sum + element.files.length, 0);
|
||||||
|
|
||||||
|
return { id: skeleton.id, title: skeleton.title, context, columns: assembled, fileCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns(skeleton: BoardSkeleton, cardsById: Map<string, CardResponse>): AssembledColumn[] {
|
||||||
|
return skeleton.columns.map((column) => ({
|
||||||
|
id: column.id,
|
||||||
|
title: column.title?.trim() || '(untitled column)',
|
||||||
|
cards: column.cards
|
||||||
|
.map((ref) => cardsById.get(ref.cardId))
|
||||||
|
// A card can be missing if it was deleted between the two calls.
|
||||||
|
.filter((card): card is CardResponse => card !== undefined)
|
||||||
|
.map(buildCard),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(card: CardResponse): AssembledCard {
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
title: card.title?.trim() || '(untitled card)',
|
||||||
|
elements: (card.elements ?? []).map(buildElement),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildElement(element: ContentElement): AssembledElement {
|
||||||
|
const content = element.content ?? {};
|
||||||
|
const assembled: AssembledElement = { id: element.id, type: element.type, files: [], raw: content };
|
||||||
|
|
||||||
|
if (element.type === 'richText' && typeof content.text === 'string') {
|
||||||
|
assembled.text = content.text;
|
||||||
|
}
|
||||||
|
if (element.type === 'link') {
|
||||||
|
if (typeof content.url === 'string') assembled.url = content.url;
|
||||||
|
if (typeof content.title === 'string') assembled.text = content.title;
|
||||||
|
}
|
||||||
|
if ((element.type === 'file' || element.type === 'fileFolder') && typeof content.caption === 'string') {
|
||||||
|
const caption = content.caption.trim();
|
||||||
|
if (caption) assembled.text = caption;
|
||||||
|
}
|
||||||
|
if (element.type === 'collaborativeTextEditor' || element.type === 'externalTool') {
|
||||||
|
if (typeof content.title === 'string') assembled.text = content.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves file-bearing elements to file records.
|
||||||
|
*
|
||||||
|
* One request per element is unavoidable — files-storage only lists by
|
||||||
|
* parent — so they all go out at once. A per-element failure is recorded on
|
||||||
|
* that element rather than failing the whole board: a single blocked or
|
||||||
|
* deleted attachment shouldn't cost the user the rest of the content.
|
||||||
|
*/
|
||||||
|
async function attachFiles(client: SchulcloudClient, columns: AssembledColumn[], schoolId: string): Promise<void> {
|
||||||
|
const targets = columns
|
||||||
|
.flatMap((column) => column.cards)
|
||||||
|
.flatMap((card) => card.elements)
|
||||||
|
.filter((element) => FILE_BEARING_TYPES.has(element.type));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
targets.map(async (element) => {
|
||||||
|
try {
|
||||||
|
const page = await client.listFiles({
|
||||||
|
storageLocationId: schoolId,
|
||||||
|
parentType: 'boardnodes',
|
||||||
|
parentId: element.id,
|
||||||
|
});
|
||||||
|
element.files = page.data;
|
||||||
|
} catch (error) {
|
||||||
|
element.fileError =
|
||||||
|
error instanceof SchulcloudApiError ? `HTTP ${error.status}` : String((error as Error).message ?? error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/schulcloud/client.ts
Normal file
315
src/schulcloud/client.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import type { Config } from '../config.ts';
|
||||||
|
import type {
|
||||||
|
BoardContext,
|
||||||
|
BoardSkeleton,
|
||||||
|
CardResponse,
|
||||||
|
CourseBoardResponse,
|
||||||
|
CourseMetadata,
|
||||||
|
DashboardResponse,
|
||||||
|
FileParentType,
|
||||||
|
FileRecord,
|
||||||
|
LessonResponse,
|
||||||
|
MeResponse,
|
||||||
|
NewsResponse,
|
||||||
|
Paginated,
|
||||||
|
TaskContent,
|
||||||
|
} from './types.ts';
|
||||||
|
|
||||||
|
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
|
||||||
|
export class SchulcloudApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly path: string,
|
||||||
|
readonly body: string,
|
||||||
|
) {
|
||||||
|
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
|
||||||
|
this.name = 'SchulcloudApiError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the instance rejected our JWT — the one error the user must act on. */
|
||||||
|
get isAuthFailure(): boolean {
|
||||||
|
return this.status === 401;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, max: number): string {
|
||||||
|
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadedFile {
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string;
|
||||||
|
/** True when the file was longer than `maxDownloadBytes` and got cut short. */
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only HTTP client for a Schulcloud instance.
|
||||||
|
*
|
||||||
|
* Two services sit behind the same origin and both accept the same bearer
|
||||||
|
* token: the main server under `/api/v3/*`, and the files-storage service
|
||||||
|
* under `/api/v3/file/*`. The JWT from the browser's `jwt` cookie works
|
||||||
|
* verbatim as `Authorization: Bearer` — no cookie jar or session refresh is
|
||||||
|
* involved, and the token is valid for 30 days (see docs/AUTH.md).
|
||||||
|
*
|
||||||
|
* Every method here is a GET. Keeping the client incapable of writing is the
|
||||||
|
* main safety property of this server: whoever reaches the MCP endpoint can
|
||||||
|
* read this account's data but cannot act as the user inside Schulcloud.
|
||||||
|
*/
|
||||||
|
export class SchulcloudClient {
|
||||||
|
constructor(private readonly config: Config) {}
|
||||||
|
|
||||||
|
// --- transport -------------------------------------------------------
|
||||||
|
|
||||||
|
private url(path: string, query?: Record<string, string | number | string[] | undefined>): URL {
|
||||||
|
const url = new URL(`${this.config.baseUrl}${path}`);
|
||||||
|
for (const [key, value] of Object.entries(query ?? {})) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (Array.isArray(value)) for (const v of value) url.searchParams.append(key, v);
|
||||||
|
else url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(url: URL, accept: string): Promise<Response> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: accept },
|
||||||
|
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
throw new SchulcloudApiError(response.status, url.pathname + url.search, body);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated GET returning JSON. `path` is absolute, e.g. `/api/v3/courses`. */
|
||||||
|
async getJson<T>(path: string, query?: Record<string, string | number | string[] | undefined>): Promise<T> {
|
||||||
|
const response = await this.request(this.url(path, query), 'application/json');
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated GET returning bytes, capped at `maxDownloadBytes`.
|
||||||
|
*
|
||||||
|
* The cap is enforced while streaming rather than via Content-Length, so a
|
||||||
|
* mis-declared or chunked response still can't exhaust memory.
|
||||||
|
*/
|
||||||
|
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
|
||||||
|
const url = this.url(path);
|
||||||
|
const response = await this.request(url, '*/*');
|
||||||
|
const limit = this.config.maxDownloadBytes;
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
try {
|
||||||
|
while (total < limit) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = Buffer.from(value);
|
||||||
|
const room = limit - total;
|
||||||
|
if (chunk.length > room) {
|
||||||
|
chunks.push(chunk.subarray(0, room));
|
||||||
|
total = limit;
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
total += chunk.length;
|
||||||
|
}
|
||||||
|
if (total >= limit) {
|
||||||
|
// Anything still queued is beyond the cap; drop the rest.
|
||||||
|
const { done } = await reader.read();
|
||||||
|
if (!done) truncated = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: Buffer.concat(chunks),
|
||||||
|
mimeType: response.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream',
|
||||||
|
fileName: filenameFromDisposition(response.headers.get('content-disposition')) ?? fallbackName,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity --------------------------------------------------------
|
||||||
|
|
||||||
|
me(): Promise<MeResponse> {
|
||||||
|
return this.getJson<MeResponse>('/api/v3/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- courses and the classic course board ----------------------------
|
||||||
|
|
||||||
|
listCourses(params: { skip?: number; limit?: number } = {}): Promise<Paginated<CourseMetadata>> {
|
||||||
|
return this.getJson<Paginated<CourseMetadata>>('/api/v3/courses', {
|
||||||
|
skip: params.skip,
|
||||||
|
limit: clampPageSize(params.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every course the account can see, paging past the API's per-page ceiling. */
|
||||||
|
listAllCourses(max = 500): Promise<CourseMetadata[]> {
|
||||||
|
return collectPages((skip, limit) => this.listCourses({ skip, limit }), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contents of one course, as the course page shows them: lessons,
|
||||||
|
* tasks and column boards interleaved. The route is `course-rooms`, and
|
||||||
|
* its `:roomId` is the *course* id.
|
||||||
|
*/
|
||||||
|
getCourseBoard(courseId: string): Promise<CourseBoardResponse> {
|
||||||
|
return this.getJson<CourseBoardResponse>(`/api/v3/course-rooms/${encodeURIComponent(courseId)}/board`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDashboard(): Promise<DashboardResponse> {
|
||||||
|
return this.getJson<DashboardResponse>('/api/v3/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tasks -----------------------------------------------------------
|
||||||
|
|
||||||
|
listTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
|
||||||
|
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks', {
|
||||||
|
skip: params.skip,
|
||||||
|
limit: clampPageSize(params.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listFinishedTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
|
||||||
|
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks/finished', {
|
||||||
|
skip: params.skip,
|
||||||
|
limit: clampPageSize(params.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lessons ---------------------------------------------------------
|
||||||
|
|
||||||
|
getLesson(lessonId: string): Promise<LessonResponse> {
|
||||||
|
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLessonTasks(lessonId: string): Promise<Paginated<TaskContent>> {
|
||||||
|
return this.getJson<Paginated<TaskContent>>(`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- column boards ---------------------------------------------------
|
||||||
|
|
||||||
|
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
|
||||||
|
return this.getJson<BoardSkeleton>(`/api/v3/boards/${encodeURIComponent(boardId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBoardContext(boardId: string): Promise<BoardContext> {
|
||||||
|
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card bodies for the given ids. The upstream endpoint takes repeated
|
||||||
|
* `ids` query params with no documented ceiling, so we chunk purely to
|
||||||
|
* keep request URLs a sane length.
|
||||||
|
*/
|
||||||
|
async getCards(cardIds: string[]): Promise<CardResponse[]> {
|
||||||
|
const CHUNK = 40;
|
||||||
|
const out: CardResponse[] = [];
|
||||||
|
for (let i = 0; i < cardIds.length; i += CHUNK) {
|
||||||
|
const chunk = cardIds.slice(i, i + CHUNK);
|
||||||
|
const page = await this.getJson<{ data: CardResponse[] }>('/api/v3/cards', { ids: chunk });
|
||||||
|
out.push(...page.data);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- files -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files attached to one parent entity.
|
||||||
|
*
|
||||||
|
* `storageLocationId` is the school id for `storageLocation: 'school'`,
|
||||||
|
* which is what every parent type in normal use resolves to. Board file
|
||||||
|
* elements are addressed with `parentType: 'boardnodes'` and the *element*
|
||||||
|
* id as `parentId`.
|
||||||
|
*/
|
||||||
|
listFiles(args: {
|
||||||
|
storageLocationId: string;
|
||||||
|
parentType: FileParentType;
|
||||||
|
parentId: string;
|
||||||
|
storageLocation?: 'school' | 'instance';
|
||||||
|
}): Promise<Paginated<FileRecord>> {
|
||||||
|
const location = args.storageLocation ?? 'school';
|
||||||
|
const path =
|
||||||
|
`/api/v3/file/list/${location}/${encodeURIComponent(args.storageLocationId)}` +
|
||||||
|
`/${args.parentType}/${encodeURIComponent(args.parentId)}`;
|
||||||
|
return this.getJson<Paginated<FileRecord>>(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileRecord(fileRecordId: string): Promise<FileRecord> {
|
||||||
|
return this.getJson<FileRecord>(`/api/v3/file/${encodeURIComponent(fileRecordId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(record: Pick<FileRecord, 'id' | 'name'>): Promise<DownloadedFile> {
|
||||||
|
const path = `/api/v3/file/download/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}`;
|
||||||
|
return this.getBytes(path, record.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- misc ------------------------------------------------------------
|
||||||
|
|
||||||
|
listNews(params: { skip?: number; limit?: number } = {}): Promise<Paginated<NewsResponse>> {
|
||||||
|
return this.getJson<Paginated<NewsResponse>>('/api/v3/news', {
|
||||||
|
skip: params.skip,
|
||||||
|
limit: clampPageSize(params.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list endpoints reject `limit` above 100 and document a maximum of 99, so
|
||||||
|
* page at 99 and let `collectPages` stitch the results back together.
|
||||||
|
*/
|
||||||
|
export const MAX_PAGE_SIZE = 99;
|
||||||
|
|
||||||
|
function clampPageSize(limit: number | undefined): number | undefined {
|
||||||
|
if (limit === undefined) return undefined;
|
||||||
|
return Math.min(Math.max(1, Math.trunc(limit)), MAX_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows `skip`/`limit` paging until `max` items or the server runs out. */
|
||||||
|
async function collectPages<T>(
|
||||||
|
fetchPage: (skip: number, limit: number) => Promise<Paginated<T>>,
|
||||||
|
max: number,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const items: T[] = [];
|
||||||
|
let skip = 0;
|
||||||
|
while (items.length < max) {
|
||||||
|
const page = await fetchPage(skip, Math.min(MAX_PAGE_SIZE, max - items.length));
|
||||||
|
items.push(...page.data);
|
||||||
|
skip += page.data.length;
|
||||||
|
// Stop on an empty page too, so a server that ignores `skip` can't loop forever.
|
||||||
|
if (page.data.length === 0 || skip >= page.total) break;
|
||||||
|
}
|
||||||
|
return items.slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromDisposition(header: string | null): string | undefined {
|
||||||
|
if (!header) return undefined;
|
||||||
|
// Prefer RFC 5987 `filename*`, which carries the encoding explicitly.
|
||||||
|
const extended = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(header);
|
||||||
|
if (extended?.[1]) return safeDecode(extended[1].trim());
|
||||||
|
const plain = /filename="?([^";]+)"?/.exec(header);
|
||||||
|
if (plain?.[1]) return safeDecode(plain[1].trim());
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecode(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/schulcloud/types.ts
Normal file
243
src/schulcloud/types.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Response shapes for the parts of the Schulcloud API this server touches.
|
||||||
|
*
|
||||||
|
* These were read off the live instance's OpenAPI documents
|
||||||
|
* (`/api/v3/docs-json` and `/api/v3/file/docs-json`) and confirmed against
|
||||||
|
* real responses; they cover only the fields we actually use, so upstream
|
||||||
|
* additions won't break them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Paginated<T> {
|
||||||
|
total: number;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
data: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
school: { id: string; name: string };
|
||||||
|
user: { id: string; firstName: string; lastName: string; customAvatarBackgroundColor?: string };
|
||||||
|
roles: { id: string; name: string }[];
|
||||||
|
permissions: string[];
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseMetadata {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
shortTitle: string;
|
||||||
|
displayColor: string;
|
||||||
|
startDate?: string;
|
||||||
|
untilDate?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An entry on a *course* board — the classic learnroom view. */
|
||||||
|
export type CourseBoardElement =
|
||||||
|
| { type: 'task'; content: TaskContent }
|
||||||
|
| { type: 'lesson'; content: LessonMetaContent }
|
||||||
|
| { type: 'column-board'; content: ColumnBoardMetaContent };
|
||||||
|
|
||||||
|
export interface CourseBoardResponse {
|
||||||
|
roomId: string;
|
||||||
|
title: string;
|
||||||
|
displayColor: string;
|
||||||
|
elements: CourseBoardElement[];
|
||||||
|
isArchived?: boolean;
|
||||||
|
isSynchronized?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatus {
|
||||||
|
submitted: number;
|
||||||
|
maxSubmissions: number;
|
||||||
|
graded: number;
|
||||||
|
isDraft: boolean;
|
||||||
|
isSubstitutionTeacher: boolean;
|
||||||
|
isFinished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskContent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
courseName?: string;
|
||||||
|
courseId?: string;
|
||||||
|
lessonName?: string;
|
||||||
|
description?: string;
|
||||||
|
availableDate?: string;
|
||||||
|
dueDate?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
displayColor?: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonMetaContent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hidden: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
numberOfPublishedTasks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnBoardMetaContent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
published?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
layout?: string;
|
||||||
|
columnBoardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lesson's body. `contents[].content` varies by `component`
|
||||||
|
* (`text`, `geoGebra`, `Etherpad`, `resources`, `internal`, `neXboard`).
|
||||||
|
*/
|
||||||
|
export interface LessonResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
courseId: string;
|
||||||
|
hidden: boolean;
|
||||||
|
position?: number;
|
||||||
|
contents: LessonContent[];
|
||||||
|
materials: LessonMaterial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonContent {
|
||||||
|
id?: unknown;
|
||||||
|
title?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
component?: string;
|
||||||
|
content?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonMaterial {
|
||||||
|
id?: unknown;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
client?: string;
|
||||||
|
description?: string;
|
||||||
|
merlinReference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Board skeleton: structure and card ids only — card bodies come from `/cards`. */
|
||||||
|
export interface BoardSkeleton {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
layout?: string;
|
||||||
|
isVisible?: boolean;
|
||||||
|
readersCanEdit?: boolean;
|
||||||
|
columns: {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
cards: { cardId: string; height: number }[];
|
||||||
|
timestamps?: Timestamps;
|
||||||
|
}[];
|
||||||
|
timestamps?: Timestamps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Timestamps {
|
||||||
|
createdAt?: string;
|
||||||
|
lastUpdatedAt?: string;
|
||||||
|
deletedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardContext {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardResponse {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
height: number;
|
||||||
|
elements: ContentElement[];
|
||||||
|
visibilitySettings?: Record<string, unknown>;
|
||||||
|
timestamps?: Timestamps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentElement {
|
||||||
|
id: string;
|
||||||
|
type: ContentElementType;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
timestamps?: Timestamps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentElementType =
|
||||||
|
| 'file'
|
||||||
|
| 'fileFolder'
|
||||||
|
| 'drawing'
|
||||||
|
| 'link'
|
||||||
|
| 'richText'
|
||||||
|
| 'externalTool'
|
||||||
|
| 'collaborativeTextEditor'
|
||||||
|
| 'videoConference'
|
||||||
|
| 'h5p'
|
||||||
|
| 'deleted';
|
||||||
|
|
||||||
|
/** A file in the files-storage service. `url` is instance-relative. */
|
||||||
|
export interface FileRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string;
|
||||||
|
parentType: FileParentType;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
securityCheckStatus: 'pending' | 'verified' | 'blocked' | 'wont-check' | string;
|
||||||
|
previewStatus: string;
|
||||||
|
creatorId?: string;
|
||||||
|
isCollaboraEditable?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
contentLastModifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Values accepted by files-storage for the `:parentType` path segment. */
|
||||||
|
export type FileParentType =
|
||||||
|
| 'users'
|
||||||
|
| 'schools'
|
||||||
|
| 'courses'
|
||||||
|
| 'tasks'
|
||||||
|
| 'lessons'
|
||||||
|
| 'submissions'
|
||||||
|
| 'gradings'
|
||||||
|
| 'boardnodes'
|
||||||
|
| 'externaltools';
|
||||||
|
|
||||||
|
export const FILE_PARENT_TYPES: FileParentType[] = [
|
||||||
|
'users',
|
||||||
|
'schools',
|
||||||
|
'courses',
|
||||||
|
'tasks',
|
||||||
|
'lessons',
|
||||||
|
'submissions',
|
||||||
|
'gradings',
|
||||||
|
'boardnodes',
|
||||||
|
'externaltools',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
id: string;
|
||||||
|
gridElements: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
shortTitle: string;
|
||||||
|
displayColor: string;
|
||||||
|
xPosition: number;
|
||||||
|
yPosition: number;
|
||||||
|
groupElements?: { id: string; title: string; shortTitle: string; displayColor: string }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsResponse {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
displayAt: string;
|
||||||
|
source?: string;
|
||||||
|
targetId?: string;
|
||||||
|
creator?: { id: string; firstName?: string; lastName?: string };
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
45
src/server.ts
Normal file
45
src/server.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import type { Config } from './config.ts';
|
||||||
|
import { ServerContext } from './context.ts';
|
||||||
|
import { registerContentTools } from './tools/content.ts';
|
||||||
|
import { registerFileTools } from './tools/files.ts';
|
||||||
|
import { registerOverviewTools } from './tools/overview.ts';
|
||||||
|
import { registerRawTool } from './tools/raw.ts';
|
||||||
|
import { registerSearchTool } from './tools/search.ts';
|
||||||
|
|
||||||
|
export const SERVER_NAME = 'schulcloud-mcp';
|
||||||
|
export const SERVER_VERSION = '0.1.0';
|
||||||
|
|
||||||
|
const INSTRUCTIONS = `Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software) account.
|
||||||
|
|
||||||
|
How the content is organised, and the usual path through it:
|
||||||
|
|
||||||
|
- **Courses** ("Kurse") are the top level — list_courses, or get_dashboard for the ones the user has pinned.
|
||||||
|
- A course page (get_course) holds three kinds of thing:
|
||||||
|
- **Column boards** — where most current teaching material lives. get_board returns every column, card,
|
||||||
|
text block, link and attached file in one call.
|
||||||
|
- **Topics / lessons** ("Themen") — the older format. get_lesson.
|
||||||
|
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
|
||||||
|
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
|
||||||
|
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
|
||||||
|
|
||||||
|
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
|
||||||
|
courses and matches client-side, which takes a few seconds but covers board text and file names.
|
||||||
|
|
||||||
|
Everything here is read-only; nothing in this server can modify the account.`;
|
||||||
|
|
||||||
|
export function createServer(config: Config): { server: McpServer; context: ServerContext } {
|
||||||
|
const context = new ServerContext(config);
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
||||||
|
{ capabilities: { tools: {}, logging: {} }, instructions: INSTRUCTIONS },
|
||||||
|
);
|
||||||
|
|
||||||
|
registerOverviewTools(server, context);
|
||||||
|
registerContentTools(server, context);
|
||||||
|
registerFileTools(server, context);
|
||||||
|
registerSearchTool(server, context);
|
||||||
|
registerRawTool(server, context);
|
||||||
|
|
||||||
|
return { server, context };
|
||||||
|
}
|
||||||
341
src/tools/content.ts
Normal file
341
src/tools/content.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ServerContext } from '../context.ts';
|
||||||
|
import { formatBytes } from '../extract.ts';
|
||||||
|
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../render.ts';
|
||||||
|
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../schulcloud/board.ts';
|
||||||
|
import type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../schulcloud/types.ts';
|
||||||
|
import { failure, text, toToolError } from './result.ts';
|
||||||
|
|
||||||
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||||
|
|
||||||
|
export function registerContentTools(server: McpServer, context: ServerContext): void {
|
||||||
|
server.registerTool(
|
||||||
|
'get_course',
|
||||||
|
{
|
||||||
|
title: 'Get course contents',
|
||||||
|
description:
|
||||||
|
'Everything inside one course: its topics ("Themen"/lessons), tasks, and column boards, in the order ' +
|
||||||
|
'shown on the course page. Returns ids for each, which get_board, get_lesson and get_task take. ' +
|
||||||
|
'Most teaching material lives on column boards.',
|
||||||
|
inputSchema: {
|
||||||
|
courseId: z.string().describe('Course id from list_courses or get_dashboard.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ courseId }) => {
|
||||||
|
try {
|
||||||
|
const board = await context.client.getCourseBoard(courseId);
|
||||||
|
return text(formatCourseBoard(board));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `read course ${courseId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_board',
|
||||||
|
{
|
||||||
|
title: 'Get column board',
|
||||||
|
description:
|
||||||
|
'The full contents of a column board: every column, card, text block, link and attached file, with ' +
|
||||||
|
'file ids ready for download_file. This is where course material actually lives — prefer it over ' +
|
||||||
|
'poking at cards individually.',
|
||||||
|
inputSchema: {
|
||||||
|
boardId: z.string().describe('Board id, from get_course.'),
|
||||||
|
includeFiles: z
|
||||||
|
.boolean()
|
||||||
|
.default(true)
|
||||||
|
.describe('Resolve attachments to real file records. Turn off for a faster structure-only view.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ boardId, includeFiles }) => {
|
||||||
|
try {
|
||||||
|
const schoolId = await context.schoolId();
|
||||||
|
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles });
|
||||||
|
return text(formatBoard(board, includeFiles));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `read board ${boardId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_lesson',
|
||||||
|
{
|
||||||
|
title: 'Get lesson',
|
||||||
|
description:
|
||||||
|
'One topic/lesson ("Thema") from a course: its text sections, linked materials, attached files and ' +
|
||||||
|
'the tasks that belong to it. Lessons are the older content format; newer courses use column boards.',
|
||||||
|
inputSchema: {
|
||||||
|
lessonId: z.string().describe('Lesson id, from get_course.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ lessonId }) => {
|
||||||
|
try {
|
||||||
|
const schoolId = await context.schoolId();
|
||||||
|
const [lesson, tasks, files] = await Promise.all([
|
||||||
|
context.client.getLesson(lessonId),
|
||||||
|
context.client.getLessonTasks(lessonId).catch(() => undefined),
|
||||||
|
context.client
|
||||||
|
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
|
||||||
|
.catch(() => undefined),
|
||||||
|
]);
|
||||||
|
return text(formatLesson(lesson, tasks?.data ?? [], files?.data ?? []));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `read lesson ${lessonId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_task',
|
||||||
|
{
|
||||||
|
title: 'Get task',
|
||||||
|
description:
|
||||||
|
'Full detail for one task: description, due date, submission status and attached files. ' +
|
||||||
|
'The API has no single-task endpoint, so this locates the task through the task lists and its ' +
|
||||||
|
'course page — pass courseId when you know it to skip the search.',
|
||||||
|
inputSchema: {
|
||||||
|
taskId: z.string().describe('Task id, from list_tasks or get_course.'),
|
||||||
|
courseId: z.string().optional().describe('Course the task belongs to. Optional; speeds up the lookup.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ taskId, courseId }) => {
|
||||||
|
try {
|
||||||
|
const schoolId = await context.schoolId();
|
||||||
|
const found = await findTask(context, taskId, courseId);
|
||||||
|
if (!found) {
|
||||||
|
return failure(
|
||||||
|
`Task ${taskId} was not found in the open or finished task lists, nor on the given course page. ` +
|
||||||
|
`It may belong to a course this account cannot see, or the id may be wrong.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const files = await context.client
|
||||||
|
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
|
||||||
|
.catch(() => undefined);
|
||||||
|
return text(formatTask(found, files?.data ?? []));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `read task ${taskId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- task lookup -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a task by id.
|
||||||
|
*
|
||||||
|
* There is no `GET /tasks/{id}`, and the list endpoints omit `description`,
|
||||||
|
* which is only present on the course page's task element. So: use the lists
|
||||||
|
* to learn which course the task belongs to (unless told), then read the
|
||||||
|
* description off that course's page.
|
||||||
|
*/
|
||||||
|
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<TaskContent | undefined> {
|
||||||
|
if (courseId) {
|
||||||
|
const fromCourse = await taskFromCourse(context, courseId, taskId);
|
||||||
|
if (fromCourse) return fromCourse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [open, finished] = await Promise.all([
|
||||||
|
context.client.listTasks({ limit: 99 }).catch(() => undefined),
|
||||||
|
context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId);
|
||||||
|
if (!listed) return undefined;
|
||||||
|
|
||||||
|
// The list entry lacks the description; the course page has it.
|
||||||
|
if (listed.courseId) {
|
||||||
|
const enriched = await taskFromCourse(context, listed.courseId, taskId);
|
||||||
|
if (enriched) return { ...listed, ...enriched };
|
||||||
|
}
|
||||||
|
return listed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function taskFromCourse(
|
||||||
|
context: ServerContext,
|
||||||
|
courseId: string,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<TaskContent | undefined> {
|
||||||
|
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
|
||||||
|
if (!board) return undefined;
|
||||||
|
for (const element of board.elements) {
|
||||||
|
if (element.type === 'task' && element.content.id === taskId) {
|
||||||
|
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- formatting --------------------------------------------------------
|
||||||
|
|
||||||
|
function formatCourseBoard(board: CourseBoardResponse): string {
|
||||||
|
const boards: string[] = [];
|
||||||
|
const lessons: string[] = [];
|
||||||
|
const tasks: string[] = [];
|
||||||
|
|
||||||
|
for (const element of board.elements) {
|
||||||
|
if (element.type === 'column-board') {
|
||||||
|
boards.push(`- **${element.content.title}** (\`${element.content.id}\`)`);
|
||||||
|
} else if (element.type === 'lesson') {
|
||||||
|
const taskCount = element.content.numberOfPublishedTasks
|
||||||
|
? ` — ${element.content.numberOfPublishedTasks} task(s)`
|
||||||
|
: '';
|
||||||
|
const hidden = element.content.hidden ? ' [hidden]' : '';
|
||||||
|
lessons.push(`- **${element.content.name}** (\`${element.content.id}\`)${taskCount}${hidden}`);
|
||||||
|
} else if (element.type === 'task') {
|
||||||
|
const status = element.content.status.submitted > 0 ? 'submitted' : 'not submitted';
|
||||||
|
tasks.push(`- **${element.content.name}** (\`${element.content.id}\`) — ${dueLabel(element.content.dueDate)}, ${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boards.length + lessons.length + tasks.length === 0) {
|
||||||
|
return `${heading(2, board.title)}\n\nThis course page is empty.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinSections([
|
||||||
|
heading(2, board.title),
|
||||||
|
`Course id: \`${board.roomId}\``,
|
||||||
|
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
|
||||||
|
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
|
||||||
|
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
|
||||||
|
const columns = board.columns.map((column) => {
|
||||||
|
const cards = column.cards.map((card) => {
|
||||||
|
const body = card.elements
|
||||||
|
.map((element) => formatElement(element, includeFiles))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
return joinSections([heading(4, card.title), body || '_(empty card)_']);
|
||||||
|
});
|
||||||
|
return joinSections([heading(3, column.title), cards.length > 0 ? cards.join('\n\n') : '_(no cards)_']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
`Board id: \`${board.id}\`` +
|
||||||
|
(board.context ? ` — in ${board.context.type} \`${board.context.id}\`` : '') +
|
||||||
|
(includeFiles ? ` — ${board.fileCount} attached file(s)` : '');
|
||||||
|
|
||||||
|
return joinSections([
|
||||||
|
heading(2, board.title),
|
||||||
|
summary,
|
||||||
|
columns.length > 0 ? columns.join('\n\n') : '_(no columns)_',
|
||||||
|
includeFiles && board.fileCount > 0 ? 'Read any attachment with download_file using its file id.' : undefined,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElement(element: AssembledElement, includeFiles: boolean): string {
|
||||||
|
switch (element.type) {
|
||||||
|
case 'richText': {
|
||||||
|
const body = htmlToText(element.text);
|
||||||
|
return body ? body : '';
|
||||||
|
}
|
||||||
|
case 'link': {
|
||||||
|
const label = element.text?.trim();
|
||||||
|
return element.url ? `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}` : '';
|
||||||
|
}
|
||||||
|
case 'file':
|
||||||
|
case 'fileFolder':
|
||||||
|
case 'drawing': {
|
||||||
|
const caption = element.text ? ` — caption: ${element.text}` : '';
|
||||||
|
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`;
|
||||||
|
if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`;
|
||||||
|
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
|
||||||
|
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
|
||||||
|
}
|
||||||
|
case 'collaborativeTextEditor':
|
||||||
|
return `- Collaborative text document \`${element.id}\`${element.text ? ` — ${element.text}` : ''} (contents not available through the API)`;
|
||||||
|
case 'externalTool':
|
||||||
|
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||||||
|
case 'videoConference':
|
||||||
|
return `- Video conference \`${element.id}\``;
|
||||||
|
case 'h5p':
|
||||||
|
return `- H5P interactive content \`${element.id}\``;
|
||||||
|
case 'deleted':
|
||||||
|
return '- _(deleted element)_';
|
||||||
|
default:
|
||||||
|
return `- ${element.type} element \`${element.id}\``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileLine(file: FileRecord): string {
|
||||||
|
const blocked = file.securityCheckStatus === 'blocked' ? ' **[virus scan: blocked]**' : '';
|
||||||
|
const pending = file.securityCheckStatus === 'pending' ? ' _[virus scan pending]_' : '';
|
||||||
|
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string {
|
||||||
|
const sections = (lesson.contents ?? []).map((entry) => {
|
||||||
|
const title = entry.title?.trim();
|
||||||
|
const component = entry.component ?? 'unknown';
|
||||||
|
const hidden = entry.hidden ? ' [hidden]' : '';
|
||||||
|
const body = formatLessonComponent(component, entry.content ?? {});
|
||||||
|
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const materials = (lesson.materials ?? []).map((material) => {
|
||||||
|
const id = normalizeObjectId(material.id);
|
||||||
|
return `- ${material.title ?? 'Untitled material'}${material.url ? ` — ${material.url}` : ''}${id ? ` (\`${id}\`)` : ''}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return joinSections([
|
||||||
|
heading(2, lesson.name),
|
||||||
|
`Lesson id: \`${lesson.id}\` — in course \`${lesson.courseId}\`${lesson.hidden ? ' — hidden' : ''}`,
|
||||||
|
sections.length > 0 ? joinSections([heading(3, 'Contents'), sections.join('\n\n')]) : '_(no text contents)_',
|
||||||
|
materials.length > 0 && joinSections([heading(3, 'Linked materials'), materials.join('\n')]),
|
||||||
|
files.length > 0 &&
|
||||||
|
joinSections([heading(3, `Attached files (${files.length})`), files.map((file) => `- ${formatFileLine(file)}`).join('\n')]),
|
||||||
|
tasks.length > 0 &&
|
||||||
|
joinSections([
|
||||||
|
heading(3, `Tasks in this lesson (${tasks.length})`),
|
||||||
|
tasks.map((task) => `- **${task.name}** (\`${task.id}\`) — ${dueLabel(task.dueDate)}`).join('\n'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLessonComponent(component: string, content: Record<string, unknown>): string {
|
||||||
|
if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text);
|
||||||
|
if (component === 'resources' && Array.isArray(content.resources)) {
|
||||||
|
return content.resources
|
||||||
|
.map((resource) => {
|
||||||
|
const entry = resource as { title?: string; url?: string; description?: string };
|
||||||
|
return `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
if (typeof content.url === 'string') return `- ${content.url}`;
|
||||||
|
if (typeof content.title === 'string') return content.title;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTask(task: TaskContent, files: FileRecord[]): string {
|
||||||
|
const description = htmlToText(task.description);
|
||||||
|
return joinSections([
|
||||||
|
heading(2, task.name),
|
||||||
|
[
|
||||||
|
`- Task id: \`${task.id}\``,
|
||||||
|
task.courseName ? `- Course: ${task.courseName}${task.courseId ? ` (\`${task.courseId}\`)` : ''}` : undefined,
|
||||||
|
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
|
||||||
|
`- Available from: ${formatDate(task.availableDate)}`,
|
||||||
|
`- Due: ${dueLabel(task.dueDate)}`,
|
||||||
|
`- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
description ? joinSections([heading(3, 'Description'), description]) : '_(no description)_',
|
||||||
|
files.length > 0
|
||||||
|
? joinSections([
|
||||||
|
heading(3, `Attached files (${files.length})`),
|
||||||
|
files.map((file) => `- ${formatFileLine(file)}`).join('\n'),
|
||||||
|
'Read one with download_file.',
|
||||||
|
])
|
||||||
|
: undefined,
|
||||||
|
]);
|
||||||
|
}
|
||||||
149
src/tools/files.ts
Normal file
149
src/tools/files.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ServerContext } from '../context.ts';
|
||||||
|
import { extractContent, formatBytes } from '../extract.ts';
|
||||||
|
import { formatDate, heading, joinSections } from '../render.ts';
|
||||||
|
import { FILE_PARENT_TYPES, type FileParentType } from '../schulcloud/types.ts';
|
||||||
|
import { formatFileLine } from './content.ts';
|
||||||
|
import { failure, text, toToolError } from './result.ts';
|
||||||
|
|
||||||
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||||
|
|
||||||
|
export function registerFileTools(server: McpServer, context: ServerContext): void {
|
||||||
|
server.registerTool(
|
||||||
|
'list_files',
|
||||||
|
{
|
||||||
|
title: 'List files of an entity',
|
||||||
|
description:
|
||||||
|
'Files attached to one entity. Most of the time you do not need this — get_board, get_lesson and ' +
|
||||||
|
'get_task already list their own attachments. Reach for it to enumerate a course\'s own file area, ' +
|
||||||
|
'or a single board element\'s files (parentType "boardnodes", parentId = the element id).',
|
||||||
|
inputSchema: {
|
||||||
|
parentType: z
|
||||||
|
.enum(FILE_PARENT_TYPES as [FileParentType, ...FileParentType[]])
|
||||||
|
.describe('Kind of entity the files hang off.'),
|
||||||
|
parentId: z.string().describe('Id of that entity. For "boardnodes" this is a board element id.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ parentType, parentId }) => {
|
||||||
|
try {
|
||||||
|
const schoolId = await context.schoolId();
|
||||||
|
const page = await context.client.listFiles({ storageLocationId: schoolId, parentType, parentId });
|
||||||
|
if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`);
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})`),
|
||||||
|
page.data.map((file) => `- ${formatFileLine(file)} — uploaded ${formatDate(file.createdAt)}`).join('\n'),
|
||||||
|
'Read one with download_file.',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `list files of ${parentType} ${parentId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'download_file',
|
||||||
|
{
|
||||||
|
title: 'Download and read a file',
|
||||||
|
description:
|
||||||
|
'Fetches a file and returns its contents. PDFs, Word, Excel, PowerPoint and OpenDocument files are ' +
|
||||||
|
'extracted to text; images come back inline so you can look at them; anything else reports its type. ' +
|
||||||
|
'Pass raw=true to get base64 bytes instead of extracted text.',
|
||||||
|
inputSchema: {
|
||||||
|
fileId: z.string().describe('File record id, from get_board, get_task, get_lesson or list_files.'),
|
||||||
|
raw: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe('Return base64-encoded bytes instead of extracted text. Use for formats with no extractor.'),
|
||||||
|
maxChars: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(500)
|
||||||
|
.max(500_000)
|
||||||
|
.optional()
|
||||||
|
.describe('Override the character limit on extracted text.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ fileId, raw, maxChars }) => {
|
||||||
|
try {
|
||||||
|
const record = await context.client.getFileRecord(fileId);
|
||||||
|
|
||||||
|
// The instance scans uploads; serving a known-bad file to the user is
|
||||||
|
// exactly the thing that scan exists to prevent.
|
||||||
|
if (record.securityCheckStatus === 'blocked') {
|
||||||
|
return failure(
|
||||||
|
`"${record.name}" was blocked by the instance's virus scanner and will not be downloaded.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const file = await context.client.downloadFile(record);
|
||||||
|
const header = [
|
||||||
|
heading(2, record.name),
|
||||||
|
[
|
||||||
|
`- File id: \`${record.id}\``,
|
||||||
|
`- Type: ${record.mimeType}`,
|
||||||
|
`- Size: ${formatBytes(record.size)}`,
|
||||||
|
`- Attached to: ${record.parentType} \`${record.parentId}\``,
|
||||||
|
`- Uploaded: ${formatDate(record.createdAt)}`,
|
||||||
|
record.securityCheckStatus !== 'verified'
|
||||||
|
? `- Virus scan: ${record.securityCheckStatus}`
|
||||||
|
: undefined,
|
||||||
|
file.truncated
|
||||||
|
? `- **Download was capped at ${formatBytes(context.config.maxDownloadBytes)}; content is incomplete.**`
|
||||||
|
: undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
header,
|
||||||
|
`Base64 (${file.bytes.length} bytes):`,
|
||||||
|
'```',
|
||||||
|
file.bytes.toString('base64'),
|
||||||
|
'```',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraction = await extractContent(
|
||||||
|
file.bytes,
|
||||||
|
file.mimeType || record.mimeType,
|
||||||
|
record.name,
|
||||||
|
maxChars ?? context.config.maxExtractedChars,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extraction.kind === 'image' && extraction.image) {
|
||||||
|
const result: CallToolResult = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: joinSections([header, extraction.note]) },
|
||||||
|
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraction.kind === 'text') {
|
||||||
|
const body = extraction.text?.trim();
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
header,
|
||||||
|
extraction.note,
|
||||||
|
body ? joinSections([heading(3, 'Contents'), body]) : '_(the file contains no extractable text)_',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text(joinSections([header, extraction.note]));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `download file ${fileId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/tools/overview.ts
Normal file
217
src/tools/overview.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ServerContext } from '../context.ts';
|
||||||
|
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../render.ts';
|
||||||
|
import type { CourseMetadata, TaskContent } from '../schulcloud/types.ts';
|
||||||
|
import { text, toToolError } from './result.ts';
|
||||||
|
|
||||||
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||||
|
|
||||||
|
export function registerOverviewTools(server: McpServer, context: ServerContext): void {
|
||||||
|
server.registerTool(
|
||||||
|
'whoami',
|
||||||
|
{
|
||||||
|
title: 'Who am I',
|
||||||
|
description:
|
||||||
|
'Identity of the Schulcloud account this server is authenticated as: name, school, roles and ' +
|
||||||
|
'permissions. Useful as a connectivity check and to know whether the account is a student or teacher ' +
|
||||||
|
'before interpreting other results.',
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const me = await context.me();
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
heading(2, `${me.user.firstName} ${me.user.lastName}`),
|
||||||
|
[
|
||||||
|
`- User id: ${me.user.id}`,
|
||||||
|
`- School: ${me.school.name} (${me.school.id})`,
|
||||||
|
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
|
||||||
|
`- Instance: ${context.config.baseUrl}`,
|
||||||
|
`- Permissions: ${me.permissions.length}`,
|
||||||
|
].join('\n'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, 'read the current user');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_courses',
|
||||||
|
{
|
||||||
|
title: 'List courses',
|
||||||
|
description:
|
||||||
|
'All courses ("Kurse") the account is enrolled in, with their ids. Start here when the user asks ' +
|
||||||
|
'about a subject by name — match the name to a course id, then call get_course to see its contents.',
|
||||||
|
inputSchema: {
|
||||||
|
limit: z.number().int().min(1).max(500).default(200).describe('Maximum number of courses to return.'),
|
||||||
|
activeOnly: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe('Only courses whose date range covers today, i.e. currently running ones.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ limit, activeOnly }) => {
|
||||||
|
try {
|
||||||
|
const all = await context.client.listAllCourses(limit);
|
||||||
|
const courses = activeOnly ? all.filter(isCurrentlyRunning) : all;
|
||||||
|
if (courses.length === 0) {
|
||||||
|
return text(activeOnly ? 'No currently running courses.' : 'No courses found for this account.');
|
||||||
|
}
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
heading(2, `Courses (${courses.length}${activeOnly ? ` of ${all.length}` : ''})`),
|
||||||
|
courses.map(formatCourseLine).join('\n'),
|
||||||
|
'Use get_course with a course id to see its lessons, tasks and boards.',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, 'list courses');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_tasks',
|
||||||
|
{
|
||||||
|
title: 'List tasks',
|
||||||
|
description:
|
||||||
|
'Homework and assignments ("Aufgaben") across all courses, newest first, with due dates and ' +
|
||||||
|
'submission status. This is the tool for "what do I have to hand in". Task descriptions and ' +
|
||||||
|
'attachments come from get_task.',
|
||||||
|
inputSchema: {
|
||||||
|
scope: z
|
||||||
|
.enum(['open', 'finished'])
|
||||||
|
.default('open')
|
||||||
|
.describe('"open" = still outstanding; "finished" = archived/completed tasks.'),
|
||||||
|
limit: z.number().int().min(1).max(99).default(50).describe('Maximum number of tasks to return.'),
|
||||||
|
skip: z.number().int().min(0).default(0).describe('Number of tasks to skip, for paging.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ scope, limit, skip }) => {
|
||||||
|
try {
|
||||||
|
const page =
|
||||||
|
scope === 'finished'
|
||||||
|
? await context.client.listFinishedTasks({ limit, skip })
|
||||||
|
: await context.client.listTasks({ limit, skip });
|
||||||
|
if (page.data.length === 0) return text(`No ${scope} tasks.`);
|
||||||
|
|
||||||
|
const sorted = scope === 'open' ? [...page.data].sort(byDueDate) : page.data;
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
heading(2, `${scope === 'open' ? 'Open' : 'Finished'} tasks (${page.data.length} of ${page.total})`),
|
||||||
|
sorted.map(formatTaskLine).join('\n'),
|
||||||
|
'Use get_task with a task id for the full description and attachments.',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `list ${scope} tasks`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_dashboard',
|
||||||
|
{
|
||||||
|
title: 'Get dashboard',
|
||||||
|
description:
|
||||||
|
'The account\'s dashboard tiles, in the layout the user sees after logging in. Reflects which courses ' +
|
||||||
|
'the user has pinned and in what order — useful for "what am I currently taking" when list_courses ' +
|
||||||
|
'returns a long history.',
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const dashboard = await context.client.getDashboard();
|
||||||
|
if (dashboard.gridElements.length === 0) return text('The dashboard is empty.');
|
||||||
|
|
||||||
|
const tiles = [...dashboard.gridElements]
|
||||||
|
.sort((a, b) => a.yPosition - b.yPosition || a.xPosition - b.xPosition)
|
||||||
|
.map((tile) => {
|
||||||
|
const group = tile.groupElements?.length
|
||||||
|
? ` — group of ${tile.groupElements.length}: ${tile.groupElements.map((child) => child.title).join(', ')}`
|
||||||
|
: '';
|
||||||
|
return `- **${tile.title}** (\`${tile.id}\`)${group}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return text(joinSections([heading(2, `Dashboard (${tiles.length} tiles)`), tiles.join('\n')]));
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, 'read the dashboard');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_news',
|
||||||
|
{
|
||||||
|
title: 'List news',
|
||||||
|
description: 'School and course announcements ("Neuigkeiten"), newest first.',
|
||||||
|
inputSchema: {
|
||||||
|
limit: z.number().int().min(1).max(50).default(20).describe('Maximum number of items to return.'),
|
||||||
|
skip: z.number().int().min(0).default(0).describe('Number of items to skip, for paging.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ limit, skip }) => {
|
||||||
|
try {
|
||||||
|
const page = await context.client.listNews({ limit, skip });
|
||||||
|
if (page.data.length === 0) return text('No news items.');
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
heading(2, `News (${page.data.length} of ${page.total})`),
|
||||||
|
page.data
|
||||||
|
.map((item) =>
|
||||||
|
joinSections([
|
||||||
|
heading(3, item.title),
|
||||||
|
`_${formatDate(item.displayAt)}_`,
|
||||||
|
htmlToText(item.content),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.join('\n\n---\n\n'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, 'list news');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentlyRunning(course: CourseMetadata): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;
|
||||||
|
const until = course.untilDate ? new Date(course.untilDate).getTime() : undefined;
|
||||||
|
if (start !== undefined && Number.isFinite(start) && start > now) return false;
|
||||||
|
if (until !== undefined && Number.isFinite(until) && until < now) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCourseLine(course: CourseMetadata): string {
|
||||||
|
const range =
|
||||||
|
course.startDate || course.untilDate
|
||||||
|
? ` — ${formatDate(course.startDate).slice(0, 10)} to ${formatDate(course.untilDate).slice(0, 10)}`
|
||||||
|
: '';
|
||||||
|
return `- **${course.title}** (\`${course.id}\`)${course.isLocked ? ' [locked]' : ''}${range}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskLine(task: TaskContent): string {
|
||||||
|
const course = task.courseName ? ` — ${task.courseName}` : '';
|
||||||
|
const lesson = task.lessonName ? ` / ${task.lessonName}` : '';
|
||||||
|
const submitted = task.status.submitted > 0 ? 'submitted' : 'not submitted';
|
||||||
|
const graded = task.status.graded > 0 ? ', graded' : '';
|
||||||
|
return `- **${task.name}** (\`${task.id}\`)${course}${lesson} — ${dueLabel(task.dueDate)}, ${submitted}${graded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byDueDate(a: TaskContent, b: TaskContent): number {
|
||||||
|
// Tasks without a due date sort last; they are never urgent.
|
||||||
|
const left = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
||||||
|
const right = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
||||||
|
return left - right;
|
||||||
|
}
|
||||||
70
src/tools/raw.ts
Normal file
70
src/tools/raw.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ServerContext } from '../context.ts';
|
||||||
|
import { text, failure, toToolError } from './result.ts';
|
||||||
|
|
||||||
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape hatch for the parts of the API that have no dedicated tool.
|
||||||
|
*
|
||||||
|
* The instance exposes far more than this server models — groups, teams,
|
||||||
|
* external tools, school settings. Rather than guess at which of those matter,
|
||||||
|
* expose a GET-only passthrough and let the model reach them when asked.
|
||||||
|
* GET-only is the point: it keeps the whole server incapable of writing.
|
||||||
|
*/
|
||||||
|
export function registerRawTool(server: McpServer, context: ServerContext): void {
|
||||||
|
server.registerTool(
|
||||||
|
'api_get',
|
||||||
|
{
|
||||||
|
title: 'Raw API GET',
|
||||||
|
description:
|
||||||
|
'Performs an authenticated GET against an arbitrary path on this Schulcloud instance and returns the ' +
|
||||||
|
'JSON. For API surface the other tools do not cover (groups, teams, school info, tool configs). ' +
|
||||||
|
'Read-only: only GET is possible. The instance documents itself at /api/v3/docs-json and ' +
|
||||||
|
'/api/v3/file/docs-json — fetch those to discover paths.',
|
||||||
|
inputSchema: {
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.describe('Path beginning with /api/, e.g. "/api/v3/groups/class" or "/api/v3/rooms".'),
|
||||||
|
maxChars: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(500)
|
||||||
|
.max(200_000)
|
||||||
|
.default(20_000)
|
||||||
|
.describe('Truncate the JSON response to this many characters.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ path, maxChars }) => {
|
||||||
|
if (!path.startsWith('/api/')) {
|
||||||
|
return failure(`Path must start with /api/ — got "${path}".`);
|
||||||
|
}
|
||||||
|
// A path containing a scheme or authority would escape the configured
|
||||||
|
// instance entirely, sending the JWT somewhere it does not belong.
|
||||||
|
if (/^\/api\/\/|:\/\//.test(path)) {
|
||||||
|
return failure('Path must be a plain path on this instance, with no scheme or host.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await context.client.getJson<unknown>(path);
|
||||||
|
const json = JSON.stringify(body, null, 2);
|
||||||
|
const truncated = json.length > maxChars;
|
||||||
|
return text(
|
||||||
|
[
|
||||||
|
`GET ${path} → 200`,
|
||||||
|
'```json',
|
||||||
|
truncated ? json.slice(0, maxChars) : json,
|
||||||
|
'```',
|
||||||
|
truncated ? `_(truncated from ${json.length} characters)_` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `GET ${path}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/tools/result.ts
Normal file
42
src/tools/result.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { SchulcloudApiError } from '../schulcloud/client.ts';
|
||||||
|
|
||||||
|
export function text(body: string): CallToolResult {
|
||||||
|
return { content: [{ type: 'text', text: body }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failure(body: string): CallToolResult {
|
||||||
|
return { content: [{ type: 'text', text: body }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a thrown error into a tool result the model can act on.
|
||||||
|
*
|
||||||
|
* The distinction that matters is 401 — an expired JWT is the one failure the
|
||||||
|
* user has to fix by hand, and it otherwise looks identical to "this course
|
||||||
|
* doesn't exist". 403 is separated out for the same reason: it means the
|
||||||
|
* account genuinely lacks access, not that the call was malformed.
|
||||||
|
*/
|
||||||
|
export function toToolError(error: unknown, action: string): CallToolResult {
|
||||||
|
if (error instanceof SchulcloudApiError) {
|
||||||
|
if (error.isAuthFailure) {
|
||||||
|
return failure(
|
||||||
|
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
|
||||||
|
`The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` +
|
||||||
|
`the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` +
|
||||||
|
`environment and restart it. See docs/AUTH.md.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error.status === 403) {
|
||||||
|
return failure(`No permission to ${action} (HTTP 403). This account cannot see that resource.`);
|
||||||
|
}
|
||||||
|
if (error.status === 404) {
|
||||||
|
return failure(`Not found while trying to ${action} (HTTP 404). Check the id.`);
|
||||||
|
}
|
||||||
|
return failure(`Failed to ${action}: ${error.message}`);
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||||
|
return failure(`Timed out trying to ${action}. The instance may be slow or unreachable.`);
|
||||||
|
}
|
||||||
|
return failure(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
234
src/tools/search.ts
Normal file
234
src/tools/search.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ServerContext } from '../context.ts';
|
||||||
|
import { heading, htmlToText, joinSections } from '../render.ts';
|
||||||
|
import { assembleBoard } from '../schulcloud/board.ts';
|
||||||
|
import type { CourseMetadata } from '../schulcloud/types.ts';
|
||||||
|
import { text, toToolError } from './result.ts';
|
||||||
|
|
||||||
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||||
|
|
||||||
|
interface Hit {
|
||||||
|
course: string;
|
||||||
|
courseId: string;
|
||||||
|
where: string;
|
||||||
|
/** Id the model should pass to a follow-up tool to see this hit in context. */
|
||||||
|
target: string;
|
||||||
|
targetTool: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSearchTool(server: McpServer, context: ServerContext): void {
|
||||||
|
server.registerTool(
|
||||||
|
'search',
|
||||||
|
{
|
||||||
|
title: 'Search across courses',
|
||||||
|
description:
|
||||||
|
'Keyword search over course titles, board and card titles, board text, file names, lesson titles and ' +
|
||||||
|
'task names. The Schulcloud API has no search endpoint, so this walks the courses and matches ' +
|
||||||
|
'client-side: thorough, but it takes a few seconds. Use it when the user names a topic rather than a ' +
|
||||||
|
'course ("where is the stuff about encryption?"). Matching is case- and accent-insensitive.',
|
||||||
|
inputSchema: {
|
||||||
|
query: z.string().min(2).describe('Words to look for. All of them must appear somewhere in the item.'),
|
||||||
|
scope: z
|
||||||
|
.enum(['boards', 'everything'])
|
||||||
|
.default('boards')
|
||||||
|
.describe('"boards" searches course pages and column boards; "everything" also opens each lesson.'),
|
||||||
|
courseId: z.string().optional().describe('Restrict the search to a single course.'),
|
||||||
|
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY,
|
||||||
|
},
|
||||||
|
async ({ query, scope, courseId, limit }) => {
|
||||||
|
try {
|
||||||
|
const terms = tokenize(query);
|
||||||
|
if (terms.length === 0) return text('Query contained no searchable words.');
|
||||||
|
|
||||||
|
const schoolId = await context.schoolId();
|
||||||
|
const courses = courseId
|
||||||
|
? [{ id: courseId, title: courseId } as CourseMetadata]
|
||||||
|
: await context.client.listAllCourses();
|
||||||
|
|
||||||
|
const hits: Hit[] = [];
|
||||||
|
await forEachLimited(courses, 6, async (course) => {
|
||||||
|
await searchCourse(context, schoolId, course, terms, scope, hits);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hits.length === 0) {
|
||||||
|
return text(
|
||||||
|
`No matches for "${query}" across ${courses.length} course(s).` +
|
||||||
|
(scope === 'boards' ? ' Try scope="everything" to also search inside lessons.' : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = hits.slice(0, limit);
|
||||||
|
return text(
|
||||||
|
joinSections([
|
||||||
|
heading(2, `${hits.length} match(es) for "${query}"${hits.length > shown.length ? `, showing ${shown.length}` : ''}`),
|
||||||
|
shown.map(formatHit).join('\n\n'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toToolError(error, `search for "${query}"`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCourse(
|
||||||
|
context: ServerContext,
|
||||||
|
schoolId: string,
|
||||||
|
course: CourseMetadata,
|
||||||
|
terms: string[],
|
||||||
|
scope: 'boards' | 'everything',
|
||||||
|
hits: Hit[],
|
||||||
|
): Promise<void> {
|
||||||
|
const page = await context.client.getCourseBoard(course.id).catch(() => undefined);
|
||||||
|
if (!page) return;
|
||||||
|
const courseTitle = page.title || course.title;
|
||||||
|
|
||||||
|
if (matches(courseTitle, terms)) {
|
||||||
|
hits.push({
|
||||||
|
course: courseTitle,
|
||||||
|
courseId: course.id,
|
||||||
|
where: 'course title',
|
||||||
|
target: course.id,
|
||||||
|
targetTool: 'get_course',
|
||||||
|
snippet: courseTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardIds: string[] = [];
|
||||||
|
for (const element of page.elements) {
|
||||||
|
if (element.type === 'column-board') {
|
||||||
|
boardIds.push(element.content.id);
|
||||||
|
if (matches(element.content.title, terms)) {
|
||||||
|
hits.push({
|
||||||
|
course: courseTitle,
|
||||||
|
courseId: course.id,
|
||||||
|
where: 'board title',
|
||||||
|
target: element.content.id,
|
||||||
|
targetTool: 'get_board',
|
||||||
|
snippet: element.content.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (element.type === 'task') {
|
||||||
|
const haystack = `${element.content.name} ${htmlToText(element.content.description)}`;
|
||||||
|
if (matches(haystack, terms)) {
|
||||||
|
hits.push({
|
||||||
|
course: courseTitle,
|
||||||
|
courseId: course.id,
|
||||||
|
where: 'task',
|
||||||
|
target: element.content.id,
|
||||||
|
targetTool: 'get_task',
|
||||||
|
snippet: snippet(haystack, terms),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (element.type === 'lesson') {
|
||||||
|
if (matches(element.content.name, terms)) {
|
||||||
|
hits.push({
|
||||||
|
course: courseTitle,
|
||||||
|
courseId: course.id,
|
||||||
|
where: 'lesson title',
|
||||||
|
target: element.content.id,
|
||||||
|
targetTool: 'get_lesson',
|
||||||
|
snippet: element.content.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (scope === 'everything') {
|
||||||
|
const lesson = await context.client.getLesson(element.content.id).catch(() => undefined);
|
||||||
|
const body = (lesson?.contents ?? [])
|
||||||
|
.map((entry) => `${entry.title ?? ''} ${htmlToText(String(entry.content?.text ?? ''))}`)
|
||||||
|
.join('\n');
|
||||||
|
if (body.trim() && matches(body, terms)) {
|
||||||
|
hits.push({
|
||||||
|
course: courseTitle,
|
||||||
|
courseId: course.id,
|
||||||
|
where: `lesson "${element.content.name}"`,
|
||||||
|
target: element.content.id,
|
||||||
|
targetTool: 'get_lesson',
|
||||||
|
snippet: snippet(body, terms),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await forEachLimited(boardIds, 4, async (boardId) => {
|
||||||
|
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: true }).catch(() => undefined);
|
||||||
|
if (!board) return;
|
||||||
|
for (const column of board.columns) {
|
||||||
|
for (const card of column.cards) {
|
||||||
|
const parts = [card.title];
|
||||||
|
for (const element of card.elements) {
|
||||||
|
if (element.text) parts.push(htmlToText(element.text));
|
||||||
|
if (element.url) parts.push(element.url);
|
||||||
|
for (const file of element.files) parts.push(file.name);
|
||||||
|
}
|
||||||
|
const haystack = parts.join('\n');
|
||||||
|
if (matches(haystack, terms)) {
|
||||||
|
hits.push({
|
||||||
|
course: courseTitle,
|
||||||
|
courseId: course.id,
|
||||||
|
where: `board "${board.title}" → card "${card.title}"`,
|
||||||
|
target: board.id,
|
||||||
|
targetTool: 'get_board',
|
||||||
|
snippet: snippet(haystack, terms),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHit(hit: Hit): string {
|
||||||
|
return [
|
||||||
|
`- **${hit.course}** — ${hit.where}`,
|
||||||
|
` ${hit.snippet}`,
|
||||||
|
` → \`${hit.targetTool}\` with id \`${hit.target}\``,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercases and strips diacritics so "Verschlusselung" finds "Verschlüsselung". */
|
||||||
|
function fold(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(query: string): string[] {
|
||||||
|
return fold(query)
|
||||||
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
|
.filter((token) => token.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(haystack: string | undefined, terms: string[]): boolean {
|
||||||
|
if (!haystack) return false;
|
||||||
|
const folded = fold(haystack);
|
||||||
|
return terms.every((term) => folded.includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-line excerpt centred on the first matching term. */
|
||||||
|
function snippet(haystack: string, terms: string[], width = 180): string {
|
||||||
|
const flat = haystack.replace(/\s+/g, ' ').trim();
|
||||||
|
const folded = fold(flat);
|
||||||
|
const at = terms.map((term) => folded.indexOf(term)).filter((index) => index >= 0);
|
||||||
|
const centre = at.length > 0 ? Math.min(...at) : 0;
|
||||||
|
const start = Math.max(0, centre - width / 3);
|
||||||
|
const excerpt = flat.slice(start, start + width);
|
||||||
|
return `${start > 0 ? '…' : ''}${excerpt}${start + width < flat.length ? '…' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `task` over `items` with at most `limit` in flight, preserving no order. */
|
||||||
|
async function forEachLimited<T>(items: T[], limit: number, task: (item: T) => Promise<void>): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const item = items[cursor++];
|
||||||
|
if (item !== undefined) await task(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
55
test/auth.test.ts
Normal file
55
test/auth.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { bearerAuth } from '../src/http/auth.ts';
|
||||||
|
|
||||||
|
function run(headers: Record<string, string>): { status?: number; passed: boolean } {
|
||||||
|
const middleware = bearerAuth('correct-horse-battery-staple');
|
||||||
|
let status: number | undefined;
|
||||||
|
let passed = false;
|
||||||
|
const req = { get: (name: string) => headers[name.toLowerCase()] } as never;
|
||||||
|
const res = {
|
||||||
|
setHeader() {},
|
||||||
|
status(code: number) {
|
||||||
|
status = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
middleware(req, res, () => {
|
||||||
|
passed = true;
|
||||||
|
});
|
||||||
|
return { status, passed };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bearerAuth', () => {
|
||||||
|
it('accepts the exact token', () => {
|
||||||
|
assert.equal(run({ authorization: 'Bearer correct-horse-battery-staple' }).passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts it via x-api-key, for connector UIs without an Authorization field', () => {
|
||||||
|
assert.equal(run({ 'x-api-key': 'correct-horse-battery-staple' }).passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive about the scheme but not the token', () => {
|
||||||
|
assert.equal(run({ authorization: 'bearer correct-horse-battery-staple' }).passed, true);
|
||||||
|
assert.equal(run({ authorization: 'Bearer CORRECT-HORSE-BATTERY-STAPLE' }).passed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing, empty, wrong or truncated token with 401', () => {
|
||||||
|
for (const headers of [
|
||||||
|
{},
|
||||||
|
{ authorization: '' },
|
||||||
|
{ authorization: 'Bearer ' },
|
||||||
|
{ authorization: 'Bearer wrong' },
|
||||||
|
{ authorization: 'Bearer correct-horse-battery-stapl' },
|
||||||
|
{ authorization: 'Bearer correct-horse-battery-staple-extra' },
|
||||||
|
{ authorization: 'Basic correct-horse-battery-staple' },
|
||||||
|
]) {
|
||||||
|
const result = run(headers as Record<string, string>);
|
||||||
|
assert.equal(result.passed, false, `should reject ${JSON.stringify(headers)}`);
|
||||||
|
assert.equal(result.status, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
36
test/config.test.ts
Normal file
36
test/config.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { afterEach, describe, it } from 'node:test';
|
||||||
|
import { loadConfig } from '../src/config.ts';
|
||||||
|
|
||||||
|
const SAVED = { ...process.env };
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...SAVED };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('requires the instance URL and token', () => {
|
||||||
|
delete process.env.TSC_URL;
|
||||||
|
process.env.TSC_JWT_COOKIE = 'x';
|
||||||
|
assert.throws(() => loadConfig(), /TSC_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing slashes so paths concatenate cleanly', () => {
|
||||||
|
process.env.TSC_URL = 'https://example.org///';
|
||||||
|
process.env.TSC_JWT_COOKIE = 'x';
|
||||||
|
assert.equal(loadConfig().baseUrl, 'https://example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-numeric port rather than silently defaulting', () => {
|
||||||
|
process.env.TSC_URL = 'https://example.org';
|
||||||
|
process.env.TSC_JWT_COOKIE = 'x';
|
||||||
|
process.env.PORT = 'not-a-number';
|
||||||
|
assert.throws(() => loadConfig(), /PORT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a blank auth token as absent', () => {
|
||||||
|
process.env.TSC_URL = 'https://example.org';
|
||||||
|
process.env.TSC_JWT_COOKIE = 'x';
|
||||||
|
process.env.MCP_AUTH_TOKEN = ' ';
|
||||||
|
assert.equal(loadConfig().authToken, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
test/extract.test.ts
Normal file
55
test/extract.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { extractContent, formatBytes } from '../src/extract.ts';
|
||||||
|
|
||||||
|
const MAX = 10_000;
|
||||||
|
|
||||||
|
describe('extractContent', () => {
|
||||||
|
it('returns images inline as base64 without touching the bytes', async () => {
|
||||||
|
const png = Buffer.from('89504e470d0a1a0a', 'hex');
|
||||||
|
const result = await extractContent(png, 'image/png', 'a.png', MAX);
|
||||||
|
assert.equal(result.kind, 'image');
|
||||||
|
assert.equal(result.image?.base64, png.toString('base64'));
|
||||||
|
assert.equal(result.image?.mimeType, 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads plain text and normalises CRLF', async () => {
|
||||||
|
const result = await extractContent(Buffer.from('a\r\nb\r\n\r\n\r\n\r\nc'), 'text/plain', 'a.txt', MAX);
|
||||||
|
assert.equal(result.kind, 'text');
|
||||||
|
assert.equal(result.text, 'a\nb\n\nc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises text even when the server mislabels it as octet-stream', async () => {
|
||||||
|
const result = await extractContent(Buffer.from('hello world'), 'application/octet-stream', 'note', MAX);
|
||||||
|
assert.equal(result.kind, 'text');
|
||||||
|
assert.equal(result.text, 'hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports binary content instead of emitting mojibake', async () => {
|
||||||
|
const bytes = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00]);
|
||||||
|
const result = await extractContent(bytes, 'application/octet-stream', 'blob.bin', MAX);
|
||||||
|
assert.equal(result.kind, 'binary');
|
||||||
|
assert.match(result.note, /no text extractor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates at the limit and says so', async () => {
|
||||||
|
const result = await extractContent(Buffer.from('x'.repeat(5000)), 'text/plain', 'a.txt', 100);
|
||||||
|
assert.equal(result.truncated, true);
|
||||||
|
assert.equal(result.text?.length, 100);
|
||||||
|
assert.match(result.note, /truncated to 100 characters \(of 5000\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a parser failure into a note rather than throwing', async () => {
|
||||||
|
const result = await extractContent(Buffer.from('not really a pdf'), 'application/pdf', 'broken.pdf', MAX);
|
||||||
|
assert.equal(result.kind, 'binary');
|
||||||
|
assert.match(result.note, /Could not extract text|no text extractor/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
it('scales units', () => {
|
||||||
|
assert.equal(formatBytes(512), '512 B');
|
||||||
|
assert.equal(formatBytes(2048), '2.0 KB');
|
||||||
|
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
test/render.test.ts
Normal file
73
test/render.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { daysUntil, formatDate, htmlToText, joinSections, normalizeObjectId } from '../src/render.ts';
|
||||||
|
|
||||||
|
describe('htmlToText', () => {
|
||||||
|
it('unwraps the CKEditor markup Schulcloud stores', () => {
|
||||||
|
assert.equal(htmlToText('<p>Hallo <strong>Welt</strong></p>'), 'Hallo Welt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the href when the link text differs from it', () => {
|
||||||
|
assert.equal(
|
||||||
|
htmlToText('<p><a href="https://example.org/x">Beispiel</a></p>'),
|
||||||
|
'Beispiel (https://example.org/x)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate a bare URL used as its own label', () => {
|
||||||
|
assert.equal(htmlToText('<a href="https://example.org">https://example.org</a>'), 'https://example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders list items as bullets and collapses blank runs', () => {
|
||||||
|
assert.equal(htmlToText('<ul><li>eins</li><li>zwei</li></ul>'), '- eins\n- zwei');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes entities, ampersand last so &lt; stays literal', () => {
|
||||||
|
assert.equal(htmlToText('<p>a &lt; b < c d</p>'), 'a < b < c d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for missing input', () => {
|
||||||
|
assert.equal(htmlToText(undefined), '');
|
||||||
|
assert.equal(htmlToText(null), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('renders ISO timestamps as minute-precision UTC', () => {
|
||||||
|
assert.equal(formatDate('2026-08-17T08:00:00.000Z'), '2026-08-17 08:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through unparseable values rather than printing Invalid Date', () => {
|
||||||
|
assert.equal(formatDate('not a date'), 'not a date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks absent dates', () => {
|
||||||
|
assert.equal(formatDate(null), '—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daysUntil', () => {
|
||||||
|
it('is negative for past dates and undefined when unset', () => {
|
||||||
|
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
|
||||||
|
assert.ok((daysUntil(yesterday) ?? 0) < 0);
|
||||||
|
assert.equal(daysUntil(undefined), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joinSections', () => {
|
||||||
|
it('drops empty and falsy parts', () => {
|
||||||
|
assert.equal(joinSections(['a', '', undefined, false, ' ', 'b']), 'a\n\nb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeObjectId', () => {
|
||||||
|
it('converts the buffer shape the legacy lesson API returns', () => {
|
||||||
|
const id = { buffer: { type: 'Buffer', data: [106, 130, 219, 101, 127, 25, 207, 115, 254, 60, 242, 13] } };
|
||||||
|
assert.equal(normalizeObjectId(id), '6a82db657f19cf73fe3cf20d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes plain strings through and gives up on anything else', () => {
|
||||||
|
assert.equal(normalizeObjectId('abc'), 'abc');
|
||||||
|
assert.equal(normalizeObjectId({}), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
// Source uses .ts specifiers so `node --experimental-strip-types src/...` runs
|
||||||
|
// the tree directly in dev; tsc rewrites them to .js on build.
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user