From a0aaeba0b785451d20d2cbb91151c2050a9f05c4 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 14:41:30 +0100 Subject: [PATCH] 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 --- README.md | 35 +++- controllers/api/manage.go | 33 +++- mcp.go | 2 +- mcp_manage.go | 327 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 mcp_manage.go diff --git a/README.md b/README.md index 78cf786..c9e4b71 100644 --- a/README.md +++ b/README.md @@ -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 `, 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 `, 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_`, 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|`) 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/`. diff --git a/controllers/api/manage.go b/controllers/api/manage.go index 0fa784c..90ee9d5 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -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 { diff --git a/mcp.go b/mcp.go index 433dfa7..f16ee21 100644 --- a/mcp.go +++ b/mcp.go @@ -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()...)} } diff --git a/mcp_manage.go b/mcp_manage.go new file mode 100644 index 0000000..fd826e5 --- /dev/null +++ b/mcp_manage.go @@ -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{}), + } +}