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:
35
README.md
35
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 <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/`.
|
||||
|
||||
@@ -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
2
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()...)}
|
||||
}
|
||||
|
||||
327
mcp_manage.go
Normal file
327
mcp_manage.go
Normal 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{}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user