feat: attach material files over MCP as base64

- storeMaterialFile: the one attach.Store call of the management API,
  shared by the multipart route and the new inline route
- POST /api/v1/bm/materials/{id}/files/inline: JSON {name, mime?,
  content_base64}, at most 3000000 bytes decoded, body.limit 4300000
- bm_manage_attach_material_file and bm_manage_detach_material_file,
  both warning that material files are public to anyone with the link
This commit is contained in:
2026-10-28 15:03:43 +01:00
parent a0aaeba0b7
commit 93066c63ea
10 changed files with 406 additions and 29 deletions

View File

@@ -18,6 +18,7 @@ import (
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/surf"
bm "git.golem15.com/jakub/sm-bm-plugin"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
@@ -52,6 +53,7 @@ var pinnedTools = []string{
"bm_courses",
"bm_dashboard",
"bm_deadlines",
"bm_manage_attach_material_file",
"bm_manage_create_class_session",
"bm_manage_create_course",
"bm_manage_create_deadline",
@@ -61,6 +63,7 @@ var pinnedTools = []string{
"bm_manage_delete_deadline",
"bm_manage_delete_material",
"bm_manage_delete_reading",
"bm_manage_detach_material_file",
"bm_manage_list_class_sessions",
"bm_manage_list_courses",
"bm_manage_list_deadlines",
@@ -306,6 +309,13 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
}
}
fields := bodyFields(tool.Input)
if segment == "materials" && strings.Contains(rest, "/files") {
testFileTool(t, tool, fields)
if tool.Method == http.MethodDelete {
deletes[segment] = true
}
continue
}
if segment == "materials" {
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
@@ -353,8 +363,8 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
}
}
}
if manage != 28 {
t.Errorf("%d management tools, want 28", manage)
if manage != manageToolCount {
t.Errorf("%d management tools, want %d", manage, manageToolCount)
}
want := map[string]bool{"class-sessions": true, "readings": true, "deadlines": true, "materials": true}
if !reflect.DeepEqual(deletes, want) {
@@ -362,6 +372,57 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
}
}
// manageToolCount is the number of management tools: 28 record and
// material tools plus the material file tools.
const manageToolCount = 30
// fileBodyKeys are the body keys of each material file tool, exactly.
var fileBodyKeys = map[string]map[string]bool{
"manage_attach_material_file": {"name": false, "mime": true, "content_base64": false},
"manage_detach_material_file": {},
}
// testFileTool (D-10, D-11, D-17) checks one material file tool: its body
// keys, the public-file warning and the privacy sentence, and that the
// detach tool is destructive.
func testFileTool(t *testing.T, tool pact.MCPTool, fields map[string]bool) {
t.Helper()
want, ok := fileBodyKeys[tool.Name]
if !ok {
t.Errorf("%s: not a known material file tool", tool.Name)
return
}
if !reflect.DeepEqual(fields, want) {
t.Errorf("%s: body keys (optional) %v, want %v", tool.Name, fields, want)
}
if !strings.Contains(tool.Description, api.MaterialFileWarning) {
t.Errorf("%s: description lacks the public-file warning", tool.Name)
}
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
}
if !strings.Contains(tool.Description, "Needs scope write and the jz.bm.access_materials permission") {
t.Errorf("%s: description does not name scope write and jz.bm.access_materials", tool.Name)
}
rt := reflect.TypeOf(tool.Input)
idPath := false
for i := 0; i < rt.NumField(); i++ {
if rt.Field(i).Tag.Get("path") == "id" {
idPath = true
}
}
if !idPath {
t.Errorf("%s: no id path field", tool.Name)
}
if tool.Name == "manage_detach_material_file" {
if tool.Method != http.MethodDelete || (tool.Destructive != nil && !*tool.Destructive) {
t.Errorf("%s is not destructive (method %s)", tool.Name, tool.Method)
}
} else if tool.Method == http.MethodDelete || (tool.Destructive != nil && *tool.Destructive) {
t.Errorf("%s is destructive", tool.Name)
}
}
// studentRoutes are the routes mountStudent registers, in order.
var studentRoutes = []string{
"GET /dashboard",