Files
sm-bm-plugin/mcp_manage.go
Jakub Zych a0aaeba0b7 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
2026-10-28 14:41:30 +01:00

328 lines
21 KiB
Go

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{}),
}
}