feat: add the BM management MCP tools

- 28 typed tools over /api/v1/bm: list, show, create and update for
  courses and meetings, plus delete for class sessions, readings,
  deadlines and materials (save upserts on title and course)
- inputs mirror Fillable and Rules; enums, lengths and date formats in
  the field descriptions; no seed, me or dry_run tool
- materials accept meeting_id (absent keeps, null clears, must exist)
  and the managed material JSON shows it
- README: MCP tools section
This commit is contained in:
2026-10-28 14:41:30 +01:00
parent b08d40cba2
commit a0aaeba0b7
4 changed files with 388 additions and 9 deletions

View File

@@ -191,7 +191,7 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
## Management API
`/api/v1/bm` is what `bm:seed` and the materials screen do, over HTTP, for scripts: it sits behind the application-wide API token of `golem15.user` (`Authorization: Bearer <token>`, middleware `user.api_token`), not the students' `jwt.auth`. Every route needs a token scope (`user.scope:read` or `user.scope:write`) and, except `/me`, a permission of the token owner's groups (`jz.bm.access_courses`, `jz.bm.access_schedule`, `jz.bm.access_materials`, the codes of the admin screens); a missing one is a 403. The group is throttled per token (`throttle:jz.bm.manage`, 120 a minute). Responses are `{"data": …}`; validation errors are a 422 in Laravel's shape (`{"message", "errors": {field: [...]}}`), a bad or oversized body a 400 or 413.
`/api/v1/bm` is what `bm:seed` and the materials screen do, over HTTP, for scripts: it sits behind the application-wide API token of `golem15.user` (`Authorization: Bearer <token>`, middleware `user.api_token`), not the students' `jwt.auth`. Every route needs a token scope (`user.scope:read` or `user.scope:write`) and, except `/me`, a permission of the token owner's groups (`jz.bm.access_courses`, `jz.bm.access_schedule`, `jz.bm.access_materials`, the codes of the admin screens); a missing one is a 403. The group is throttled per token (`throttle:jz.bm.manage`, 120 a minute); an MCP tool call is throttled per user instead (see [MCP tools](#mcp-tools)). Responses are `{"data": …}`; validation errors are a 422 in Laravel's shape (`{"message", "errors": {field: [...]}}`), a bad or oversized body a 400 or 413.
| Method | Route | Scope, permission | Answer |
|--------|-------|-------------------|--------|
@@ -225,17 +225,48 @@ A row is answered as an object of its columns (`id`, `created_at` and `updated_a
The material body (POST and PUT), unknown keys are a 422:
```json
{"course_id": 7, "title": "Definicje globalizacji", "type": "notatka",
{"course_id": 7, "meeting_id": 2, "title": "Definicje globalizacji", "type": "notatka",
"body": "# Robertson\n\n> …", "source": null, "url": null,
"published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja", "Zjazd 2"]}
```
- `type`: `pdf`, `notatka`, `link`, `inne`. `body` is Markdown, rendered by the student app (headings start at h2 there; the title is the page's h1).
- `course_id`: null or an existing course. `body`, `source` and `url` are replaced as sent (absent means null).
- `meeting_id`: absent keeps the stored meeting (so scripts that never send it keep working), `null` clears it, otherwise an existing meeting (else a 422 on `meeting_id`). The managed material JSON shows `meeting_id`, and the material then lists under that zjazd in `GET /_bm/api/v1/materials?meeting=`.
- `published_at`: absent publishes a new material now and keeps an existing one as it is; `null` makes it a draft; otherwise a time as in the seed file.
- `tags`: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded).
- Files go through `POST /materials/{id}/files` (`attach.Store`: the default document and image extensions, content type sniffed from the bytes, a random disk name; `file_name` keeps the client's name). The file `url` is under `storage.uploads.public_path_prefix`, served by the application when `storage.uploads.serve` is true (the app's `config/storage.yaml` sets it): GET only, `is_public` rows only, anything but a raster image as `Content-Disposition: attachment`.
## MCP tools
`jz.bm` implements `pact.HasMCP`. With the framework's MCP endpoint (`pier`, the `summercms_mcp` build tag) its tools are offered at `/mcp` as `bm_<name>`, to OAuth sessions and personal tokens of `golem15.user` (guard `user`). Every tool binds a token-guarded route, every description is static text naming the scope it needs (and, for management tools, the permission code), and no tool has a `dry_run`. `jz.bm` still requires only `golem15.user`.
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the four reading marks. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
| Tool | Route | Scope |
|------|-------|-------|
| `bm_dashboard`, `bm_courses`, `bm_course`, `bm_schedule`, `bm_meetings`, `bm_deadlines`, `bm_readings`, `bm_materials`, `bm_material` | `GET /_bm/api/v1/token/…` | read |
| `bm_mark_reading_read`, `bm_unmark_reading_read`, `bm_mark_reading_owned`, `bm_unmark_reading_owned` | `PUT` / `DELETE /_bm/api/v1/token/readings/{id}/progress` and `/owned` | write |
**Management tools** bind the existing `/api/v1/bm` routes, whose `user.scope:*` and `user.permission:jz.bm.access_*` middleware filter the listing: a user whose groups lack the codes never sees them, and write tools need the write grant. Each input mirrors the model's `Fillable()` columns and `Rules()` (required columns are required on create, every update field is optional and nullable); enum values, lengths and date and time formats are in the field descriptions, and the routes still answer 422.
| Table | Tools | Permission |
|-------|-------|------------|
| courses | `bm_manage_list_courses`, `_show_course`, `_create_course`, `_update_course` | `jz.bm.access_courses` |
| meetings | `bm_manage_list_meetings`, `_show_meeting`, `_create_meeting`, `_update_meeting` | `jz.bm.access_schedule` |
| class sessions | `bm_manage_list_class_sessions`, `_show_class_session`, `_create_class_session`, `_update_class_session`, `_delete_class_session` | `jz.bm.access_schedule` |
| readings | `bm_manage_list_readings`, `_show_reading`, `_create_reading`, `_update_reading`, `_delete_reading` | `jz.bm.access_courses` |
| deadlines | `bm_manage_list_deadlines`, `_show_deadline`, `_create_deadline`, `_update_deadline`, `_delete_deadline` | `jz.bm.access_schedule` |
| materials | `bm_manage_list_materials`, `_show_material`, `_save_material` (upsert on title and course), `_update_material`, `_delete_material` | `jz.bm.access_materials` |
`POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body.
**The MCP view.** Only an answer to an MCP tool call (a request `pier` dispatched in-process, carrying the `api_token` dispatch marker, which an HTTP request can never carry) is changed; plain HTTP answers are byte for byte as before.
- Links: the middleware `jz.bm.mcp-links` (on the token twins and the management API) prefixes every JSON string under a key named `url` or ending in `_url` that starts with a single `/` with `app.url`, else `jz.bm.public_url`, else the request's scheme and Host, so file and syllabus links are ready to follow.
- Material bodies (possible transcript text): `bm_material`, `bm_manage_list_materials` and `bm_manage_show_material` leave `body` out unless `include_body=true`, and mark an omitted non-empty body with `body_omitted: true`; the material write answers never echo it. Material tools return metadata and links, never file contents.
- Throttling: `jz.bm.manage` keys an MCP call by its user (`jz.bm.manage.user|<id>`) before the bearer and IP keys.
## Student app
The built single-page app (Vue, source in the application repository's `frontend/`) is embedded from `public/` and served at `/`. Existing files are served as they are: `assets/*` (content-hashed) with `Cache-Control: public, max-age=31536000, immutable`, everything else with `no-cache` and an ETag (304 on revalidation). A GET for any other extension-less path answers `index.html` (client routes); a missing path with an extension is a 404. `/_user`, `/_bm`, `/_quizzes`, `/storage` and the admin path (`backend.uri`, default `/bmadmin`) are never answered by the app. Routes fails when `public/index.html` is missing. The output of `npm run build` in `frontend/` is committed in `public/`.

View File

@@ -141,6 +141,7 @@ type managedMaterialJSON struct {
ID uint `json:"id"`
CourseID *uint `json:"course_id"`
Course *courseRef `json:"course"`
MeetingID *uint `json:"meeting_id"`
Title string `json:"title"`
Type string `json:"type"`
Body *string `json:"body"`
@@ -152,7 +153,7 @@ type managedMaterialJSON struct {
func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
out := managedMaterialJSON{
ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), Title: m.Title, Type: m.Type,
ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), MeetingID: m.MeetingID, Title: m.Title, Type: m.Type,
Body: m.Body, Source: m.Source, URL: m.URL, Tags: newTagsJSON(m.Tags),
}
if m.PublishedAt != nil {
@@ -185,6 +186,7 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
// materialInput is the body of POST /materials and PUT /materials/{id}.
type materialInput struct {
CourseID *uint `json:"course_id"`
MeetingID *uint `json:"meeting_id"`
Title string `json:"title"`
Type string `json:"type"`
Body *string `json:"body"`
@@ -200,8 +202,12 @@ type materialDraft struct {
// hasPublishedAt tells a sent published_at (null included) from an
// absent one.
hasPublishedAt bool
publishedAt *time.Time
tags []models.Tag
// hasMeetingID tells a sent meeting_id (null included) from an absent
// one: absent keeps the stored meeting, so scripts that never send it
// keep working.
hasMeetingID bool
publishedAt *time.Time
tags []models.Tag
}
// readMaterial decodes and validates the body of POST /materials and
@@ -222,6 +228,7 @@ func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm
var keys map[string]json.RawMessage
_ = json.Unmarshal(raw, &keys)
_, d.hasPublishedAt = keys["published_at"]
_, d.hasMeetingID = keys["meeting_id"]
in := &d.in
in.Title = strings.TrimSpace(in.Title)
@@ -268,6 +275,16 @@ func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm
errs["course_id"] = []string{"The selected course_id is invalid."}
}
}
if in.MeetingID != nil {
var n int64
if err := db.Model(&models.Meeting{}).Where("id = ?", *in.MeetingID).Count(&n).Error; err != nil {
writeOpaque500(w)
return d, false
}
if n == 0 {
errs["meeting_id"] = []string{"The selected meeting_id is invalid."}
}
}
if len(errs) > 0 {
writeValidation(w, errs)
return d, false
@@ -287,19 +304,23 @@ func (d materialDraft) identity(q *gorm.DB) *gorm.DB {
// apply writes the draft onto m inside tx. published_at: absent publishes
// a new material now and keeps an existing one as it is; null makes a
// draft; otherwise the sent time. tags: absent keeps them; otherwise the
// list replaces them, and unknown tags are created.
// draft; otherwise the sent time. meeting_id: absent keeps it; null clears
// it. tags: absent keeps them; otherwise the list replaces them, and unknown
// tags are created.
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time) error {
in := d.in
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
if d.hasMeetingID {
m.MeetingID = in.MeetingID
}
switch {
case d.hasPublishedAt:
m.PublishedAt = d.publishedAt
case created:
m.PublishedAt = &now
}
if err := tx.Omit("Course", "Tags").Save(m).Error; err != nil {
if err := tx.Omit("Course", "Meeting", "Tags").Save(m).Error; err != nil {
return err
}
if in.Tags == nil {

2
mcp.go
View File

@@ -12,5 +12,5 @@ var _ pact.HasMCP = (*Plugin)(nil)
// signed-in user under golem15.user's "user" guard, and their titles and
// descriptions are static text, never built from data.
func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: studentTools()}
return pact.MCP{Tools: append(studentTools(), manageTools()...)}
}

327
mcp_manage.go Normal file
View File

@@ -0,0 +1,327 @@
package bm
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The sentences the management tool descriptions end with (D-15): the scope
// and the permission code the route checks.
const (
readCourses = " Needs scope read and the jz.bm.access_courses permission."
writeCourses = " Needs scope write and the jz.bm.access_courses permission."
readSchedule = " Needs scope read and the jz.bm.access_schedule permission."
writeSchedule = " Needs scope write and the jz.bm.access_schedule permission."
readMaterials = " Needs scope read and the jz.bm.access_materials permission."
writeMaterials = " Needs scope write and the jz.bm.access_materials permission."
)
// Shared description fragments (D-22).
const (
recordAnswer = " Returns the row as an object of its columns (id, created_at and updated_at included); times are RFC 3339 in Europe/Warsaw, dates YYYY-MM-DD."
invalidAnswer = " A value that breaks a rule is refused with 422 and the errors per field."
patchSemantics = " Only the fields sent change; null or an empty string clears an optional column."
deleteForever = " The delete cannot be undone."
replacedAsSent = " body, source and url are replaced as sent (null clears them): read the material with include_body true first to keep its body."
)
// --- courses -------------------------------------------------------------
// listCoursesInput filters bm_manage_list_courses.
type listCoursesInput struct {
Semester int `json:"semester,omitempty" query:"semester" jsonschema:"only courses of this semester, 1 to 4"`
Form string `json:"form,omitempty" query:"form" jsonschema:"only courses of this form, one of: wyklad, cwiczenia, konwersatorium, seminarium, inne"`
Code string `json:"code,omitempty" query:"code" jsonschema:"only the course with this code; null for courses without one"`
}
// createCourseInput is the body of bm_manage_create_course.
type createCourseInput struct {
Code *string `json:"code,omitempty" jsonschema:"course code, at most 50 characters"`
Name string `json:"name" jsonschema:"course name, at most 255 characters"`
Form string `json:"form" jsonschema:"one of: wyklad, cwiczenia, konwersatorium, seminarium, inne"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters, with the title as the university writes it"`
Semester int `json:"semester" jsonschema:"semester, 1 to 4"`
ECTS *float64 `json:"ects,omitempty" jsonschema:"ECTS points, 0 to 60"`
Assessment *string `json:"assessment,omitempty" jsonschema:"form of assessment, at most 100 characters"`
ElectiveGroup *string `json:"elective_group,omitempty" jsonschema:"elective group, at most 100 characters"`
Description *string `json:"description,omitempty" jsonschema:"description"`
SyllabusURL *string `json:"syllabus_url,omitempty" jsonschema:"syllabus link, at most 2048 characters"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the semester, an integer"`
}
// updateCourseInput is the body of bm_manage_update_course.
type updateCourseInput struct {
ID uint `json:"id" path:"id" jsonschema:"the course id"`
Code *string `json:"code,omitempty" jsonschema:"course code, at most 50 characters"`
Name *string `json:"name,omitempty" jsonschema:"course name, at most 255 characters; required, so not null"`
Form *string `json:"form,omitempty" jsonschema:"one of: wyklad, cwiczenia, konwersatorium, seminarium, inne"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters"`
Semester *int `json:"semester,omitempty" jsonschema:"semester, 1 to 4"`
ECTS *float64 `json:"ects,omitempty" jsonschema:"ECTS points, 0 to 60"`
Assessment *string `json:"assessment,omitempty" jsonschema:"form of assessment, at most 100 characters"`
ElectiveGroup *string `json:"elective_group,omitempty" jsonschema:"elective group, at most 100 characters"`
Description *string `json:"description,omitempty" jsonschema:"description"`
SyllabusURL *string `json:"syllabus_url,omitempty" jsonschema:"syllabus link, at most 2048 characters"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the semester, an integer"`
}
// --- meetings ------------------------------------------------------------
// createMeetingInput is the body of bm_manage_create_meeting.
type createMeetingInput struct {
Number int `json:"number" jsonschema:"the zjazd number, 1 or more, unique"`
StartsOn string `json:"starts_on" jsonschema:"first day, YYYY-MM-DD"`
EndsOn string `json:"ends_on" jsonschema:"last day, YYYY-MM-DD, not before starts_on"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
}
// updateMeetingInput is the body of bm_manage_update_meeting.
type updateMeetingInput struct {
ID uint `json:"id" path:"id" jsonschema:"the meeting id"`
Number *int `json:"number,omitempty" jsonschema:"the zjazd number, 1 or more, unique"`
StartsOn *string `json:"starts_on,omitempty" jsonschema:"first day, YYYY-MM-DD"`
EndsOn *string `json:"ends_on,omitempty" jsonschema:"last day, YYYY-MM-DD, not before starts_on"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
}
// --- class sessions ------------------------------------------------------
// listClassSessionsInput filters bm_manage_list_class_sessions.
type listClassSessionsInput struct {
CourseID string `json:"course_id,omitempty" query:"course_id" jsonschema:"only sessions of this course id; null for sessions without a course"`
MeetingID string `json:"meeting_id,omitempty" query:"meeting_id" jsonschema:"only sessions of this meeting (zjazd) id; null for sessions without one"`
}
// createClassSessionInput is the body of bm_manage_create_class_session.
type createClassSessionInput struct {
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id"`
StartsAt string `json:"starts_at" jsonschema:"start time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 08:00"`
EndsAt string `json:"ends_at" jsonschema:"end time, after starts_at: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 09:30"`
Room *string `json:"room,omitempty" jsonschema:"room, at most 100 characters"`
Kind *string `json:"kind,omitempty" jsonschema:"kind of class, at most 100 characters"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
IsOnline *bool `json:"is_online,omitempty" jsonschema:"true when the class is online"`
}
// updateClassSessionInput is the body of bm_manage_update_class_session.
type updateClassSessionInput struct {
ID uint `json:"id" path:"id" jsonschema:"the class session id"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; null clears it"`
StartsAt *string `json:"starts_at,omitempty" jsonschema:"start time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 08:00"`
EndsAt *string `json:"ends_at,omitempty" jsonschema:"end time, after starts_at: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 09:30"`
Room *string `json:"room,omitempty" jsonschema:"room, at most 100 characters"`
Kind *string `json:"kind,omitempty" jsonschema:"kind of class, at most 100 characters"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
IsOnline *bool `json:"is_online,omitempty" jsonschema:"true when the class is online"`
}
// --- readings ------------------------------------------------------------
// listReadingsInput filters bm_manage_list_readings.
type listReadingsInput struct {
CourseID string `json:"course_id,omitempty" query:"course_id" jsonschema:"only readings of this course id; null for readings without a course"`
MeetingID string `json:"meeting_id,omitempty" query:"meeting_id" jsonschema:"only readings of this meeting (zjazd) id; null for readings without one"`
IsRequired string `json:"is_required,omitempty" query:"is_required" jsonschema:"true for required readings only, false for optional ones only"`
}
// createReadingInput is the body of bm_manage_create_reading.
type createReadingInput struct {
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id"`
Title string `json:"title" jsonschema:"title, at most 500 characters"`
Author *string `json:"author,omitempty" jsonschema:"author, at most 255 characters"`
Source *string `json:"source,omitempty" jsonschema:"source (book, journal, chapter), at most 500 characters"`
URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"`
IsRequired *bool `json:"is_required,omitempty" jsonschema:"true when the reading is required"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the course, an integer"`
}
// updateReadingInput is the body of bm_manage_update_reading.
type updateReadingInput struct {
ID uint `json:"id" path:"id" jsonschema:"the reading id"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; null clears it"`
Title *string `json:"title,omitempty" jsonschema:"title, at most 500 characters; required, so not null"`
Author *string `json:"author,omitempty" jsonschema:"author, at most 255 characters"`
Source *string `json:"source,omitempty" jsonschema:"source (book, journal, chapter), at most 500 characters"`
URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"`
IsRequired *bool `json:"is_required,omitempty" jsonschema:"true when the reading is required"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the course, an integer"`
}
// --- deadlines -----------------------------------------------------------
// listDeadlinesInput filters bm_manage_list_deadlines.
type listDeadlinesInput struct {
CourseID string `json:"course_id,omitempty" query:"course_id" jsonschema:"only deadlines of this course id; null for deadlines without a course"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"only deadlines of this kind, one of: zaliczenie, egzamin, praca, organizacyjne, inne"`
}
// createDeadlineInput is the body of bm_manage_create_deadline.
type createDeadlineInput struct {
Title string `json:"title" jsonschema:"title, at most 255 characters"`
DueAt string `json:"due_at" jsonschema:"due time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 23:59"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
Kind string `json:"kind" jsonschema:"one of: zaliczenie, egzamin, praca, organizacyjne, inne"`
Description *string `json:"description,omitempty" jsonschema:"description"`
}
// updateDeadlineInput is the body of bm_manage_update_deadline.
type updateDeadlineInput struct {
ID uint `json:"id" path:"id" jsonschema:"the deadline id"`
Title *string `json:"title,omitempty" jsonschema:"title, at most 255 characters; required, so not null"`
DueAt *string `json:"due_at,omitempty" jsonschema:"due time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 23:59"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id; null clears it"`
Kind *string `json:"kind,omitempty" jsonschema:"one of: zaliczenie, egzamin, praca, organizacyjne, inne"`
Description *string `json:"description,omitempty" jsonschema:"description"`
}
// --- materials -----------------------------------------------------------
// listManagedMaterialsInput is the input of bm_manage_list_materials.
type listManagedMaterialsInput struct {
IncludeBody bool `json:"include_body,omitempty" query:"include_body" jsonschema:"true to include each material's body (it may hold transcript text)"`
}
// showManagedMaterialInput is the input of bm_manage_show_material.
type showManagedMaterialInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
IncludeBody bool `json:"include_body,omitempty" query:"include_body" jsonschema:"true to include the material's body (it may hold transcript text)"`
}
// saveMaterialInput is the body of bm_manage_save_material. body, source
// and url have no omitempty: the route replaces them as sent, so the agent
// must state each (null clears it).
type saveMaterialInput struct {
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id; together with title it identifies the material (absent or null: no course)"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent publishes a new material now and keeps an existing one as it is; null makes a draft"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
}
// updateMaterialInput is the body of bm_manage_update_material.
type updateMaterialInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id (absent or null: no course)"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent keeps it; null makes a draft"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
}
// manageTools are the tools over the management API (/api/v1/bm): typed
// CRUD of the BM tables (D-13). The routes' user.scope and user.permission
// middleware filter the listing, so only a user whose groups hold the
// jz.bm.access_* codes sees them, and write tools only with the write
// grant. There is no tool for POST /seed or GET /me, and no dry_run (D-14).
func manageTools() []pact.MCPTool {
tool := func(name, title, desc, method, path string, input any) pact.MCPTool {
return pact.MCPTool{Name: name, Title: title, Description: desc, Guard: "user", Method: method, Path: ManagePrefix + path, Input: input}
}
g, po, pa, pu, d := http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete
return []pact.MCPTool{
tool("manage_list_courses", "List courses (manage)",
"Lists every course as rows of columns, ordered by semester and position, optionally filtered."+readCourses,
g, "/courses", listCoursesInput{}),
tool("manage_show_course", "Show a course (manage)",
"Returns one course; 404 when unknown."+recordAnswer+readCourses,
g, "/courses/{id}", idInput{}),
tool("manage_create_course", "Create a course",
"Creates a course."+recordAnswer+invalidAnswer+writeCourses,
po, "/courses", createCourseInput{}),
tool("manage_update_course", "Update a course",
"Updates one course."+patchSemantics+recordAnswer+invalidAnswer+writeCourses,
pa, "/courses/{id}", updateCourseInput{}),
tool("manage_list_meetings", "List meetings (manage)",
"Lists every zjazd (meeting) as rows of columns, ordered by number."+readSchedule,
g, "/meetings", noInput{}),
tool("manage_show_meeting", "Show a meeting (manage)",
"Returns one zjazd (meeting); 404 when unknown."+recordAnswer+readSchedule,
g, "/meetings/{id}", idInput{}),
tool("manage_create_meeting", "Create a meeting",
"Creates a zjazd (meeting)."+recordAnswer+invalidAnswer+writeSchedule,
po, "/meetings", createMeetingInput{}),
tool("manage_update_meeting", "Update a meeting",
"Updates one zjazd (meeting)."+patchSemantics+recordAnswer+invalidAnswer+writeSchedule,
pa, "/meetings/{id}", updateMeetingInput{}),
tool("manage_list_class_sessions", "List class sessions (manage)",
"Lists every class session as rows of columns, ordered by start time, optionally filtered by course or meeting."+readSchedule,
g, "/class-sessions", listClassSessionsInput{}),
tool("manage_show_class_session", "Show a class session (manage)",
"Returns one class session; 404 when unknown."+recordAnswer+readSchedule,
g, "/class-sessions/{id}", idInput{}),
tool("manage_create_class_session", "Create a class session",
"Creates a class session; course_id and meeting_id must exist."+recordAnswer+invalidAnswer+writeSchedule,
po, "/class-sessions", createClassSessionInput{}),
tool("manage_update_class_session", "Update a class session",
"Updates one class session."+patchSemantics+recordAnswer+invalidAnswer+writeSchedule,
pa, "/class-sessions/{id}", updateClassSessionInput{}),
tool("manage_delete_class_session", "Delete a class session",
"Deletes one class session; 404 when unknown."+deleteForever+writeSchedule,
d, "/class-sessions/{id}", idInput{}),
tool("manage_list_readings", "List readings (manage)",
"Lists every reading as rows of columns, ordered by course and position, optionally filtered."+readCourses,
g, "/readings", listReadingsInput{}),
tool("manage_show_reading", "Show a reading (manage)",
"Returns one reading; 404 when unknown."+recordAnswer+readCourses,
g, "/readings/{id}", idInput{}),
tool("manage_create_reading", "Create a reading",
"Creates a reading; course_id and meeting_id must exist."+recordAnswer+invalidAnswer+writeCourses,
po, "/readings", createReadingInput{}),
tool("manage_update_reading", "Update a reading",
"Updates one reading."+patchSemantics+recordAnswer+invalidAnswer+writeCourses,
pa, "/readings/{id}", updateReadingInput{}),
tool("manage_delete_reading", "Delete a reading",
"Deletes one reading with every student's read and owned marks of it; 404 when unknown."+deleteForever+writeCourses,
d, "/readings/{id}", idInput{}),
tool("manage_list_deadlines", "List deadlines (manage)",
"Lists every deadline as rows of columns, ordered by due time, optionally filtered by course or kind."+readSchedule,
g, "/deadlines", listDeadlinesInput{}),
tool("manage_show_deadline", "Show a deadline (manage)",
"Returns one deadline; 404 when unknown."+recordAnswer+readSchedule,
g, "/deadlines/{id}", idInput{}),
tool("manage_create_deadline", "Create a deadline",
"Creates a deadline; course_id must exist."+recordAnswer+invalidAnswer+writeSchedule,
po, "/deadlines", createDeadlineInput{}),
tool("manage_update_deadline", "Update a deadline",
"Updates one deadline."+patchSemantics+recordAnswer+invalidAnswer+writeSchedule,
pa, "/deadlines/{id}", updateDeadlineInput{}),
tool("manage_delete_deadline", "Delete a deadline",
"Deletes one deadline; 404 when unknown."+deleteForever+writeSchedule,
d, "/deadlines/{id}", idInput{}),
tool("manage_list_materials", "List materials (manage)",
"Lists every material, drafts included, newest first, with course, zjazd, type, source, link, publication time and tags. "+MCPMaterialPrivacy+readMaterials,
g, "/materials", listManagedMaterialsInput{}),
tool("manage_show_material", "Show a material (manage)",
"Returns one material, draft or not, with its public files (id, absolute link, name, size and type); 404 when unknown. "+MCPMaterialPrivacy+readMaterials,
g, "/materials/{id}", showManagedMaterialInput{}),
tool("manage_save_material", "Save a material",
"Creates a material, or updates the one with the same title and course (answer created true or false); a renamed material is a new one here, use bm_manage_update_material to rename."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
po, "/materials", saveMaterialInput{}),
tool("manage_update_material", "Update a material",
"Updates one material, so its title or course can change; 404 when unknown, 422 when another material has the same title and course."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
pu, "/materials/{id}", updateMaterialInput{}),
tool("manage_delete_material", "Delete a material",
"Deletes one material with all its files; 404 when unknown."+deleteForever+" "+MCPMaterialPrivacy+writeMaterials,
d, "/materials/{id}", idInput{}),
}
}