T18: courses provider + courses:// resources #17

Merged
dzenda merged 55 commits from t18-courses into t7-fixtures 2026-11-14 17:49:59 +01:00
Owner

T18: Courses provider (course materials from GitLab) + courses:// in read

Stacked on #16 (t7-fixtures) → … → #1. Merge those first.

What

  • Catalogue: courses.fit.cvut.cz/data/courses-all.json. It's public, so no token is sent. 818 courses in semester B261, 499 with a pagesRepo. Cached for 6 h per isolate, read with a size cap and validated.
    • Codes resolve exactly or by base code (BI-PA1 → BI-PA1.21), because Sirius and the catalogue use the study-plan suffix.
  • pagesRepo → GitLab project only when the repo is on the configured GitLab host over https with a sane path. A repository URL on any other host is ignored, so the GitLab token can never be sent elsewhere (tested with a foreign-host course).
  • Functions (fit.courses namespace, Czech and English keywords):
    • list({ enrolledOnly = true, query? }): your courses from the Sirius timetable (−30 to +89 days), or a catalogue search (which needs query, up to 50 results). Doesn't need GitLab. If Sirius fails you get a warning, not an error.
    • tree({ code, path?, ref? }): up to 3×100 entries with truncated. Submodules are skipped, and a missing folder, ref or repo is an error, never an empty listing.
    • read({ code, path, ref? }): text up to 256 KB measured in UTF-8 bytes, cut at a character boundary, streamed and cancelled after the cap (Content-Length isn't trusted). Binaries (NUL bytes or known binary MIME types) return metadata and a web link. Empty files count as text.
    • search({ code | "all", query }): GitLab blob search. all covers up to 8 enrolled courses, and inaccessible repos produce per-course warnings instead of silent "no matches". Snippets are capped at 400 characters.
    • cloneInfo({ code }): SSH and HTTPS URLs, the default branch, plus git clone … and rg … commands.
  • MCP read now resolves courses://<CODE>/<path> through fit.courses.read. The URI is parsed by hand so the code keeps its case. fit://help lists the new resource.
  • FnContext.gitlabBaseUrl comes from GITLAB_BASE_URL (default gitlab.fit.cvut.cz).
  • Path, ref and code validation: no . or .. segments, no control characters, length caps, URL-encoded in API paths. GitLab 401 → reauth_required, 403 → "not a member", redirects never followed.

Verified live (your FIT token + the GitLab PAT you connected)

  • list → your 7 courses this semester (B261).
  • tree BI-PA1 → 17 entries; a missing folder → "Folder not found".
  • read BI-PA1 NEWS.adoc → text from master with a web link; a missing file → "File not found".
  • search BI-PA1 "rekurze" → 17 hits. search all "zkouška" → 22 hits across 3 courses.
  • cloneInfo BI-PA1 → git@gitlab.fit.cvut.cz:BI-PA1/bi-pa1.git.
  • MCP read courses://BI-PA1/elearning/functions/recursion.adoc → 1.5k characters.
  • This also verifies T17's PAT flow live: the read-only scope check passed and the connection was stored encrypted.

Tests

bun run lint && bun run typecheck && bun run test: 231 tests, 21 of them for courses. They cover code resolution, host pinning, the enrolled list with cache and Sirius-failure warning, catalogue search, tree (submodules, 404), read (text, binary, truncation, empty, UTF-8 byte limit, unbounded chunked stream, application/x-sh stays text, traversal, missing file), search (one course, all, 404 warning), cloneInfo, not connected, revoked token → reauth, bounded reader cancellation, UTF-8 trimming, search keywords, and courses:// parsing.

Astra review (gpt-6-astra), 2 rounds

No token-exfiltration route found through code, path, ref or pagesRepo.

Fixed:

  1. (P1) Unbounded res.text() when Content-Length was missing → readBounded streams with a hard cap and cancels the rest. Also applied to JSON and the catalogue.
  2. (P2) Large text files were reported as binary because the body was skipped → explicit truncation flag.
  3. (P2) The limit counted UTF-16 units → UTF-8 bytes with a character boundary.
  4. (P2) A search 404 looked like "no matches" → error / warning.
  5. (P2, round 2) A tree 404 looked like an empty folder → error.
  6. (P2, round 2) Every application/x-* was treated as binary → explicit binary MIME list.

🤖 Generated with Claude Code

## T18: Courses provider (course materials from GitLab) + `courses://` in `read` **Stacked on #16 (`t7-fixtures`) → … → #1.** Merge those first. ### What - **Catalogue:** `courses.fit.cvut.cz/data/courses-all.json`. It's public, so no token is sent. 818 courses in semester B261, 499 with a `pagesRepo`. Cached for 6 h per isolate, read with a size cap and validated. - Codes resolve exactly or by base code (`BI-PA1` → `BI-PA1.21`), because Sirius and the catalogue use the study-plan suffix. - **`pagesRepo` → GitLab project** only when the repo is on the configured GitLab host over https with a sane path. A repository URL on any other host is ignored, so **the GitLab token can never be sent elsewhere** (tested with a foreign-host course). - **Functions** (`fit.courses` namespace, Czech and English keywords): - **`list({ enrolledOnly = true, query? })`:** your courses from the Sirius timetable (−30 to +89 days), or a catalogue search (which needs `query`, up to 50 results). Doesn't need GitLab. If Sirius fails you get a warning, not an error. - **`tree({ code, path?, ref? })`:** up to 3×100 entries with `truncated`. Submodules are skipped, and a missing folder, ref or repo is an error, never an empty listing. - **`read({ code, path, ref? })`:** text up to **256 KB measured in UTF-8 bytes, cut at a character boundary**, streamed and cancelled after the cap (Content-Length isn't trusted). Binaries (NUL bytes or known binary MIME types) return metadata and a web link. Empty files count as text. - **`search({ code | "all", query })`:** GitLab blob search. `all` covers up to 8 enrolled courses, and inaccessible repos produce per-course warnings instead of silent "no matches". Snippets are capped at 400 characters. - **`cloneInfo({ code })`:** SSH and HTTPS URLs, the default branch, plus `git clone …` and `rg …` commands. - **MCP `read`** now resolves **`courses://<CODE>/<path>`** through `fit.courses.read`. The URI is parsed by hand so the code keeps its case. `fit://help` lists the new resource. - `FnContext.gitlabBaseUrl` comes from `GITLAB_BASE_URL` (default gitlab.fit.cvut.cz). - Path, ref and code validation: no `.` or `..` segments, no control characters, length caps, URL-encoded in API paths. GitLab 401 → `reauth_required`, 403 → "not a member", redirects never followed. ### Verified live (your FIT token + the GitLab PAT you connected) - `list` → your 7 courses this semester (B261). - `tree BI-PA1` → 17 entries; a missing folder → "Folder not found". - `read BI-PA1 NEWS.adoc` → text from `master` with a web link; a missing file → "File not found". - `search BI-PA1 "rekurze"` → 17 hits. **`search all "zkouška"` → 22 hits across 3 courses.** - `cloneInfo BI-PA1` → `git@gitlab.fit.cvut.cz:BI-PA1/bi-pa1.git`. - MCP `read courses://BI-PA1/elearning/functions/recursion.adoc` → 1.5k characters. - **This also verifies T17's PAT flow live:** the read-only scope check passed and the connection was stored encrypted. ### Tests `bun run lint && bun run typecheck && bun run test`: 231 tests, 21 of them for courses. They cover code resolution, host pinning, the enrolled list with cache and Sirius-failure warning, catalogue search, tree (submodules, 404), read (text, binary, truncation, empty, **UTF-8 byte limit**, **unbounded chunked stream**, `application/x-sh` stays text, traversal, missing file), search (one course, all, 404 warning), cloneInfo, not connected, revoked token → reauth, bounded reader cancellation, UTF-8 trimming, search keywords, and `courses://` parsing. ### Astra review (gpt-6-astra), 2 rounds No token-exfiltration route found through code, path, ref or `pagesRepo`. **Fixed:** 1. *(P1)* Unbounded `res.text()` when Content-Length was missing → `readBounded` streams with a hard cap and cancels the rest. Also applied to JSON and the catalogue. 2. *(P2)* Large text files were reported as binary because the body was skipped → explicit truncation flag. 3. *(P2)* The limit counted UTF-16 units → UTF-8 bytes with a character boundary. 4. *(P2)* A search 404 looked like "no matches" → error / warning. 5. *(P2, round 2)* A tree 404 looked like an empty folder → error. 6. *(P2, round 2)* Every `application/x-*` was treated as binary → explicit binary MIME list. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dzenda merged commit 1fd2beb591 into t7-fixtures 2026-11-14 17:49:59 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!17
No description provided.