From 35125b7683dcfa2d54c57dc6f2bea14da820b2f1 Mon Sep 17 00:00:00 2001 From: Fabian Hamm Date: Fri, 11 Sep 2026 23:52:12 +0200 Subject: [PATCH] Initial schulcloud-mcp server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 10 + .env.example | 38 + .gitignore | 11 + CLAUDE.md | 112 ++ Dockerfile | 41 + README.md | 123 ++ deploy/Caddyfile.snippet | 44 + docker-compose.yml | 43 + docs/API.md | 135 +++ docs/AUTH.md | 93 ++ docs/DEPLOYMENT.md | 157 +++ package-lock.json | 2494 ++++++++++++++++++++++++++++++++++++++ package.json | 38 + scripts/probe.mjs | 114 ++ scripts/smoke.mjs | 170 +++ src/bin/http.ts | 32 + src/bin/stdio.ts | 22 + src/config.ts | 52 + src/context.ts | 38 + src/extract.ts | 223 ++++ src/http/auth.ts | 50 + src/http/server.ts | 144 +++ src/render.ts | 81 ++ src/schulcloud/board.ts | 153 +++ src/schulcloud/client.ts | 315 +++++ src/schulcloud/types.ts | 243 ++++ src/server.ts | 45 + src/tools/content.ts | 341 ++++++ src/tools/files.ts | 149 +++ src/tools/overview.ts | 217 ++++ src/tools/raw.ts | 70 ++ src/tools/result.ts | 42 + src/tools/search.ts | 234 ++++ test/auth.test.ts | 55 + test/config.test.ts | 36 + test/extract.test.ts | 55 + test/render.test.ts | 73 ++ tsconfig.json | 24 + 38 files changed, 6317 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy/Caddyfile.snippet create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/AUTH.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/probe.mjs create mode 100644 scripts/smoke.mjs create mode 100644 src/bin/http.ts create mode 100644 src/bin/stdio.ts create mode 100644 src/config.ts create mode 100644 src/context.ts create mode 100644 src/extract.ts create mode 100644 src/http/auth.ts create mode 100644 src/http/server.ts create mode 100644 src/render.ts create mode 100644 src/schulcloud/board.ts create mode 100644 src/schulcloud/client.ts create mode 100644 src/schulcloud/types.ts create mode 100644 src/server.ts create mode 100644 src/tools/content.ts create mode 100644 src/tools/files.ts create mode 100644 src/tools/overview.ts create mode 100644 src/tools/raw.ts create mode 100644 src/tools/result.ts create mode 100644 src/tools/search.ts create mode 100644 test/auth.test.ts create mode 100644 test/config.test.ts create mode 100644 test/extract.test.ts create mode 100644 test/render.test.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6e55035 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +vendor +.git +.env +*.log +test +scripts +docs +files.zip diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4d0c072 --- /dev/null +++ b/.env.example @@ -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 `. +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e924dab --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4a67035 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c41bc3c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9697cc6 --- /dev/null +++ b/README.md @@ -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). diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet new file mode 100644 index 0000000..7f75a10 --- /dev/null +++ b/deploy/Caddyfile.snippet @@ -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. + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f907e32 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..c4eac88 --- /dev/null +++ b/docs/API.md @@ -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//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=&ids=` | +| 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. diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 0000000..52d9292 --- /dev/null +++ b/docs/AUTH.md @@ -0,0 +1,93 @@ +# Authentication + +## What this server uses + +The `jwt` cookie from a logged-in browser session, sent verbatim as +`Authorization: Bearer `. 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 → 200 # what this server does +Cookie: 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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..6484dd6 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -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 /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}}' +``` + +Then in `docker-compose.yml`, set that name and mark it external: + +```yaml +networks: + caddy: + external: true + name: +``` + +Append `deploy/Caddyfile.snippet` to the Pi's Caddyfile, replacing +`mcp.example.org` with the real hostname, and reload: + +```bash +docker exec 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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..231b679 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2494 @@ +{ + "name": "schulcloud-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "schulcloud-mcp", + "version": "0.1.0", + "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" + }, + "bin": { + "schulcloud-mcp": "dist/bin/stdio.js" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.15.0", + "@types/unzipper": "^0.10.11", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/unzipper": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz", + "integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/exceljs/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/exceljs/node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/exceljs/node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/mammoth": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.2.tgz", + "integrity": "sha512-MH2vkgafD/2MYUaEOtoXLKrHQZ7yLYTHGQgyLNtYZHiUxU1K1QQ+8qMFquDAfhBm06wSGSws3J+Q9QTsKtR44g==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpdf": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.8.1.tgz", + "integrity": "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69 || ^1.0.0" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1aa1931 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/probe.mjs b/scripts/probe.mjs new file mode 100644 index 0000000..4a4356e --- /dev/null +++ b/scripts/probe.mjs @@ -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; + } +} diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..ac98d6d --- /dev/null +++ b/scripts/smoke.mjs @@ -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); diff --git a/src/bin/http.ts b/src/bin/http.ts new file mode 100644 index 0000000..cd46a65 --- /dev/null +++ b/src/bin/http.ts @@ -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 { + 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); +}); diff --git a/src/bin/stdio.ts b/src/bin/stdio.ts new file mode 100644 index 0000000..3009471 --- /dev/null +++ b/src/bin/stdio.ts @@ -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 { + 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); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..db7689d --- /dev/null +++ b/src/config.ts @@ -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), + }; +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..6bf801d --- /dev/null +++ b/src/context.ts @@ -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 | undefined; + + constructor(readonly config: Config) { + this.client = new SchulcloudClient(config); + } + + /** Cached `/me`. Shared promise, so concurrent first calls make one request. */ + me(): Promise { + 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 { + return (await this.me()).school.id; + } + + /** Drops the cached identity so the next call re-reads it. */ + reset(): void { + this.identity = undefined; + } +} diff --git a/src/extract.ts b/src/extract.ts new file mode 100644 index 0000000..21ebb1b --- /dev/null +++ b/src/extract.ts @@ -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 { + 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 { + 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 { + const mammoth = (await import('mammoth')).default; + const { value } = await mammoth.extractRawText({ buffer: bytes }); + return value; +} + +async function extractXlsx(bytes: Buffer): Promise { + 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; + 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 { + 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('�'); +} + +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`; +} diff --git a/src/http/auth.ts b/src/http/auth.ts new file mode 100644 index 0000000..a902b17 --- /dev/null +++ b/src/http/auth.ts @@ -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); +} diff --git a/src/http/server.ts b/src/http/server.ts new file mode 100644 index 0000000..2f8dd39 --- /dev/null +++ b/src/http/server.ts @@ -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; + 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(); + + 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 => { + 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 }; +} diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..9895408 --- /dev/null +++ b/src/render.ts @@ -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(//gi, '\n') + .replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n') + .replace(/]*>/gi, '- ') + // Keep the href when the anchor text does not already contain it. + .replace(/]*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; +} diff --git a/src/schulcloud/board.ts b/src/schulcloud/board.ts new file mode 100644 index 0000000..22cc4c8 --- /dev/null +++ b/src/schulcloud/board.ts @@ -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; +} + +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 { + 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): 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 { + 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); + } + }), + ); +} diff --git a/src/schulcloud/client.ts b/src/schulcloud/client.ts new file mode 100644 index 0000000..e1f3e3a --- /dev/null +++ b/src/schulcloud/client.ts @@ -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): 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 { + 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(path: string, query?: Record): Promise { + 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 { + 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 { + return this.getJson('/api/v3/me'); + } + + // --- courses and the classic course board ---------------------------- + + listCourses(params: { skip?: number; limit?: number } = {}): Promise> { + return this.getJson>('/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 { + 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 { + return this.getJson(`/api/v3/course-rooms/${encodeURIComponent(courseId)}/board`); + } + + getDashboard(): Promise { + return this.getJson('/api/v3/dashboard'); + } + + // --- tasks ----------------------------------------------------------- + + listTasks(params: { skip?: number; limit?: number } = {}): Promise> { + return this.getJson>('/api/v3/tasks', { + skip: params.skip, + limit: clampPageSize(params.limit), + }); + } + + listFinishedTasks(params: { skip?: number; limit?: number } = {}): Promise> { + return this.getJson>('/api/v3/tasks/finished', { + skip: params.skip, + limit: clampPageSize(params.limit), + }); + } + + // --- lessons --------------------------------------------------------- + + getLesson(lessonId: string): Promise { + return this.getJson(`/api/v3/lessons/${encodeURIComponent(lessonId)}`); + } + + getLessonTasks(lessonId: string): Promise> { + return this.getJson>(`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`); + } + + // --- column boards --------------------------------------------------- + + getBoardSkeleton(boardId: string): Promise { + return this.getJson(`/api/v3/boards/${encodeURIComponent(boardId)}`); + } + + getBoardContext(boardId: string): Promise { + return this.getJson(`/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 { + 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> { + const location = args.storageLocation ?? 'school'; + const path = + `/api/v3/file/list/${location}/${encodeURIComponent(args.storageLocationId)}` + + `/${args.parentType}/${encodeURIComponent(args.parentId)}`; + return this.getJson>(path); + } + + getFileRecord(fileRecordId: string): Promise { + return this.getJson(`/api/v3/file/${encodeURIComponent(fileRecordId)}`); + } + + downloadFile(record: Pick): Promise { + 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> { + return this.getJson>('/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( + fetchPage: (skip: number, limit: number) => Promise>, + max: number, +): Promise { + 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; + } +} diff --git a/src/schulcloud/types.ts b/src/schulcloud/types.ts new file mode 100644 index 0000000..b9aa50a --- /dev/null +++ b/src/schulcloud/types.ts @@ -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 { + 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; +} + +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; + timestamps?: Timestamps; +} + +export interface ContentElement { + id: string; + type: ContentElementType; + content: Record; + 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; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..1f466ab --- /dev/null +++ b/src/server.ts @@ -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 }; +} diff --git a/src/tools/content.ts b/src/tools/content.ts new file mode 100644 index 0000000..7c7d540 --- /dev/null +++ b/src/tools/content.ts @@ -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 { + 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 { + 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 { + 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, + ]); +} diff --git a/src/tools/files.ts b/src/tools/files.ts new file mode 100644 index 0000000..803e23f --- /dev/null +++ b/src/tools/files.ts @@ -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}`); + } + }, + ); +} diff --git a/src/tools/overview.ts b/src/tools/overview.ts new file mode 100644 index 0000000..adc57c6 --- /dev/null +++ b/src/tools/overview.ts @@ -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; +} diff --git a/src/tools/raw.ts b/src/tools/raw.ts new file mode 100644 index 0000000..f27e6db --- /dev/null +++ b/src/tools/raw.ts @@ -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(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}`); + } + }, + ); +} diff --git a/src/tools/result.ts b/src/tools/result.ts new file mode 100644 index 0000000..5291647 --- /dev/null +++ b/src/tools/result.ts @@ -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)}`); +} diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..da79556 --- /dev/null +++ b/src/tools/search.ts @@ -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 { + 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(items: T[], limit: number, task: (item: T) => Promise): Promise { + 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); +} diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..c41d3eb --- /dev/null +++ b/test/auth.test.ts @@ -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): { 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); + assert.equal(result.passed, false, `should reject ${JSON.stringify(headers)}`); + assert.equal(result.status, 401); + } + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..4982f63 --- /dev/null +++ b/test/config.test.ts @@ -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); + }); +}); diff --git a/test/extract.test.ts b/test/extract.test.ts new file mode 100644 index 0000000..bfad810 --- /dev/null +++ b/test/extract.test.ts @@ -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'); + }); +}); diff --git a/test/render.test.ts b/test/render.test.ts new file mode 100644 index 0000000..f95b94e --- /dev/null +++ b/test/render.test.ts @@ -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('

Hallo Welt

'), 'Hallo Welt'); + }); + + it('keeps the href when the link text differs from it', () => { + assert.equal( + htmlToText('

Beispiel

'), + 'Beispiel (https://example.org/x)', + ); + }); + + it('does not duplicate a bare URL used as its own label', () => { + assert.equal(htmlToText('https://example.org'), 'https://example.org'); + }); + + it('renders list items as bullets and collapses blank runs', () => { + assert.equal(htmlToText('
  • eins
  • zwei
'), '- eins\n- zwei'); + }); + + it('decodes entities, ampersand last so &lt; stays literal', () => { + assert.equal(htmlToText('

a &lt; b < c  d

'), '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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ad786d2 --- /dev/null +++ b/tsconfig.json @@ -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"] +}