feat: add the BM management MCP tools

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

View File

@@ -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 {