feat: MCP view of BM answers (absolute links, no material body by default)

- jz.bm.mcp-links on the token twins and the management API: under MCP,
  url and *_url strings starting with a single / get the app base
- material answers under MCP leave the body out unless include_body,
  marking it with body_omitted; write answers never echo it
- jz.bm.manage keys a dispatched call by user
- bm_materials and bm_material student tools
Plain HTTP answers are unchanged.
This commit is contained in:
2026-10-28 14:37:10 +01:00
parent 464c74b80f
commit df42e30034
9 changed files with 301 additions and 46 deletions

View File

@@ -43,6 +43,10 @@ type Handlers struct {
// (jz.bm.public_url), used for the calendar feed URL and the links in
// it. Empty means the scheme and Host of the request.
PublicURL string
// AppURL is the application URL (app.url). The MCP link rewriter
// (jz.bm.mcp-links) prefers it to PublicURL when it makes the links of
// an MCP answer absolute.
AppURL string
// ChatService returns golem15.chat's service, or nil while none is published
// (golem15.chat inactive, or no database yet).
ChatService func() *chatclasses.Service

View File

@@ -163,7 +163,8 @@ func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
}
// ManagedMaterials answers GET /materials: every material, drafts
// included, newest first.
// included, newest first. An MCP call gets the bodies only with
// include_body (see managedMaterialView).
func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
@@ -174,9 +175,9 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
writeOpaque500(w)
return
}
out := make([]managedMaterialJSON, 0, len(rows))
out := make([]any, 0, len(rows))
for _, m := range rows {
out = append(out, newManagedMaterialJSON(m))
out = append(out, managedMaterialView(newManagedMaterialJSON(m), viaMCP(r), includeBody(r)))
}
writeData(w, out)
}
@@ -326,15 +327,16 @@ func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now
return nil
}
// writeManaged answers with the material id, reloaded with its course and
// tags, plus any extra top-level keys.
func writeManaged(w http.ResponseWriter, db *gorm.DB, id uint, status int, extra map[string]any) {
// writeManaged answers a write of r with the material id, reloaded with its
// course and tags, plus any extra top-level keys. Under MCP the answer never
// echoes the body.
func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int, extra map[string]any) {
var out models.Material
if err := preloadMaterial(db).First(&out, id).Error; err != nil {
writeOpaque500(w)
return
}
payload := map[string]any{"data": newManagedMaterialJSON(out)}
payload := map[string]any{"data": managedMaterialView(newManagedMaterialJSON(out), viaMCP(r), false)}
for k, v := range extra {
payload[k] = v
}
@@ -373,7 +375,7 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
if created {
status = http.StatusCreated
}
writeManaged(w, db, m.ID, status, map[string]any{"created": created})
writeManaged(w, r, db, m.ID, status, map[string]any{"created": created})
}
// UpdateMaterial answers PUT /materials/{id}: the same body as POST, applied
@@ -423,7 +425,7 @@ func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
case taken:
writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}})
default:
writeManaged(w, db, m.ID, http.StatusOK, nil)
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
}
}
@@ -446,12 +448,13 @@ func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
writeNotFound(w)
return
}
h.writeMaterialDetail(w, db, uint(id), http.StatusOK)
h.writeMaterialDetail(w, r, db, uint(id), http.StatusOK)
}
// writeMaterialDetail answers with the material id as GET /materials/{id}
// shows it, or 404 when there is none.
func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, db *gorm.DB, id uint, status int) {
// writeMaterialDetail answers r with the material id as GET /materials/{id}
// shows it, or 404 when there is none. Under MCP a GET carries the body only
// with include_body, and any other method's answer never does.
func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int) {
var m models.Material
res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
@@ -475,7 +478,7 @@ func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, db *gorm.DB, id ui
}
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
}
wire.WriteJSON(w, status, map[string]any{"data": out})
wire.WriteJSON(w, status, map[string]any{"data": managedMaterialDetailView(out, viaMCP(r), r.Method == http.MethodGet && includeBody(r))})
}
// DeleteMaterial answers DELETE /materials/{id}.

View File

@@ -112,7 +112,7 @@ func (h *Handlers) AttachMaterialFiles(w http.ResponseWriter, r *http.Request) {
}
return
}
h.writeMaterialDetail(w, db, m.ID, http.StatusCreated)
h.writeMaterialDetail(w, r, db, m.ID, http.StatusCreated)
}
// storeParts stores and binds every "file" part in one transaction. A

View File

@@ -124,7 +124,8 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
}
// Material answers GET /materials/{id}: one published material with its
// body, tags and files. A draft or a future publication is a 404.
// body, tags and files. A draft or a future publication is a 404. An MCP
// call gets the body only with include_body (see materialView).
func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
if _, ok := h.caller(w, r); !ok {
return
@@ -169,7 +170,7 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
}
out.FilesCount = int64(len(out.Files))
writeData(w, out)
writeData(w, materialView(out, viaMCP(r), includeBody(r)))
}
// publishedMaterialsOfCourse lists a course's published materials, newest

View File

@@ -2,19 +2,84 @@ package api
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// viaMCP reports whether r is an MCP tool call.
func viaMCP(r *http.Request) bool { return false }
// The MCP view of the material answers (D-17, D-21): a material's body may
// hold transcript text, so an answer to an MCP tool call leaves it out
// unless the call asks for it with include_body, and a write answer never
// echoes it. Plain HTTP answers are unchanged.
// includeBody reports whether r asks for the material body.
func includeBody(r *http.Request) bool { return false }
// viaMCP reports whether r is an MCP tool call: a request pier dispatched
// in-process with the api_token marker. The marker lives on the context
// only, so an HTTP request can never carry it.
func viaMCP(r *http.Request) bool {
_, ok := bouncer.Dispatched(r.Context(), "api_token")
return ok
}
// materialView is the answer of GET /materials/{id} for r.
func materialView(r *http.Request, d materialDetailJSON) any { return d }
// includeBody reports whether r asks for the material body
// (include_body=true or 1).
func includeBody(r *http.Request) bool {
return flag(r, "include_body")
}
// managedMaterialView is a managed material for r.
func managedMaterialView(r *http.Request, m managedMaterialJSON, write bool) any { return m }
// bodyOmitted reports whether leaving body out hides content.
func bodyOmitted(body *string) bool {
return body != nil && *body != ""
}
// managedMaterialDetailView is a managed material with its files for r.
func managedMaterialDetailView(r *http.Request, d managedMaterialDetailJSON, write bool) any { return d }
// mcpMaterialDetailJSON is a published material under MCP without its body:
// the list fields and the files, with body_omitted when a non-empty body was
// left out. Body shadows the embedded one and stays nil.
type mcpMaterialDetailJSON struct {
materialDetailJSON
Body *string `json:"body,omitempty"`
BodyOmitted bool `json:"body_omitted,omitempty"`
}
// mcpManagedMaterialJSON is a managed material under MCP without its body.
type mcpManagedMaterialJSON struct {
managedMaterialJSON
Body *string `json:"body,omitempty"`
BodyOmitted bool `json:"body_omitted,omitempty"`
}
// mcpManagedMaterialDetailJSON is a managed material with its files under
// MCP without its body.
type mcpManagedMaterialDetailJSON struct {
mcpManagedMaterialJSON
Files []fileJSON `json:"files"`
}
// materialView is the answer of GET /materials/{id} (student): unchanged
// over plain HTTP; under MCP (mcp) without the body unless withBody.
func materialView(d materialDetailJSON, mcp, withBody bool) any {
if !mcp || withBody {
return d
}
return mcpMaterialDetailJSON{materialDetailJSON: d, BodyOmitted: bodyOmitted(d.Body)}
}
// managedMaterialView is a managed material: unchanged over plain HTTP;
// under MCP (mcp) without the body unless withBody (a write answer passes
// false, so it never echoes the body).
func managedMaterialView(m managedMaterialJSON, mcp, withBody bool) any {
if !mcp || withBody {
return m
}
return mcpManagedMaterialJSON{managedMaterialJSON: m, BodyOmitted: bodyOmitted(m.Body)}
}
// managedMaterialDetailView is a managed material with its files, under the
// same rule as managedMaterialView.
func managedMaterialDetailView(d managedMaterialDetailJSON, mcp, withBody bool) any {
if !mcp || withBody {
return d
}
return mcpManagedMaterialDetailJSON{
mcpManagedMaterialJSON: mcpManagedMaterialJSON{managedMaterialJSON: d.managedMaterialJSON, BodyOmitted: bodyOmitted(d.Body)},
Files: d.Files,
}
}

View File

@@ -72,7 +72,7 @@ func TestMCPMaterialView(t *testing.T) {
}
t.Run("plain request unchanged", func(t *testing.T) {
got := keysOf(t, materialView(httptest.NewRequest(http.MethodGet, "/", nil), detail))
got := keysOf(t, materialView(detail, viaMCP(httptest.NewRequest(http.MethodGet, "/", nil)), false))
want := keysOf(t, detail)
gotRaw, _ := json.Marshal(got)
wantRaw, _ := json.Marshal(want)
@@ -82,7 +82,7 @@ func TestMCPMaterialView(t *testing.T) {
})
t.Run("MCP without include_body", func(t *testing.T) {
got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/"), detail))
got := keysOf(t, materialView(detail, viaMCP(mcpRequest(http.MethodGet, "/")), includeBody(mcpRequest(http.MethodGet, "/"))))
if _, ok := got["body"]; ok {
t.Errorf("body present: %v", got)
}
@@ -107,7 +107,7 @@ func TestMCPMaterialView(t *testing.T) {
t.Run("MCP without include_body, empty body", func(t *testing.T) {
empty := detail
empty.Body = nil
got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/"), empty))
got := keysOf(t, materialView(empty, true, false))
if _, ok := got["body"]; ok {
t.Errorf("body present: %v", got)
}
@@ -117,7 +117,7 @@ func TestMCPMaterialView(t *testing.T) {
})
t.Run("MCP with include_body", func(t *testing.T) {
got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/?include_body=true"), detail))
got := keysOf(t, materialView(detail, true, includeBody(mcpRequest(http.MethodGet, "/?include_body=true"))))
if got["body"] != body {
t.Errorf("body = %v, want %q", got["body"], body)
}
@@ -129,7 +129,7 @@ func TestMCPMaterialView(t *testing.T) {
managed := managedMaterialJSON{ID: 3, Title: "T", Type: "notatka", Body: &body, Tags: []tagJSON{}}
t.Run("managed plain unchanged", func(t *testing.T) {
got := keysOf(t, managedMaterialView(httptest.NewRequest(http.MethodGet, "/", nil), managed, false))
got := keysOf(t, managedMaterialView(managed, false, false))
if got["body"] != body {
t.Errorf("plain managed body = %v", got["body"])
}
@@ -139,23 +139,24 @@ func TestMCPMaterialView(t *testing.T) {
})
t.Run("managed MCP read", func(t *testing.T) {
got := keysOf(t, managedMaterialView(mcpRequest(http.MethodGet, "/"), managed, false))
got := keysOf(t, managedMaterialView(managed, true, false))
if _, ok := got["body"]; ok || got["body_omitted"] != true {
t.Errorf("managed MCP read = %v, want no body and body_omitted", got)
}
got = keysOf(t, managedMaterialView(mcpRequest(http.MethodGet, "/?include_body=1"), managed, false))
got = keysOf(t, managedMaterialView(managed, true, includeBody(mcpRequest(http.MethodGet, "/?include_body=1"))))
if got["body"] != body {
t.Errorf("managed MCP read with include_body = %v", got)
}
})
// A write answer passes withBody false whatever the request asks.
t.Run("managed MCP write never echoes the body", func(t *testing.T) {
got := keysOf(t, managedMaterialView(mcpRequest(http.MethodPost, "/?include_body=true"), managed, true))
got := keysOf(t, managedMaterialView(managed, true, false))
if _, ok := got["body"]; ok || got["body_omitted"] != true {
t.Errorf("managed MCP write = %v, want no body and body_omitted", got)
}
d := managedMaterialDetailJSON{managedMaterialJSON: managed, Files: []fileJSON{}}
got = keysOf(t, managedMaterialDetailView(mcpRequest(http.MethodPost, "/?include_body=true"), d, true))
got = keysOf(t, managedMaterialDetailView(d, true, false))
if _, ok := got["body"]; ok || got["body_omitted"] != true {
t.Errorf("managed MCP write detail = %v, want no body and body_omitted", got)
}