From df42e30034a4fbdc33d0a1595f3c7582e587fc88 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 14:37:10 +0100 Subject: [PATCH] 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. --- controllers/api/api.go | 4 + controllers/api/manage.go | 31 ++++--- controllers/api/manage_files.go | 2 +- controllers/api/materials.go | 5 +- controllers/api/mcp_view.go | 85 ++++++++++++++++--- controllers/api/mcp_view_test.go | 19 +++-- mcp_links.go | 141 ++++++++++++++++++++++++++++++- mcp_student.go | 38 +++++++++ routes.go | 22 +++-- 9 files changed, 301 insertions(+), 46 deletions(-) diff --git a/controllers/api/api.go b/controllers/api/api.go index 0941c1b..be14103 100644 --- a/controllers/api/api.go +++ b/controllers/api/api.go @@ -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 diff --git a/controllers/api/manage.go b/controllers/api/manage.go index 08d6eee..0fa784c 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -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}. diff --git a/controllers/api/manage_files.go b/controllers/api/manage_files.go index 0d1aa22..076e129 100644 --- a/controllers/api/manage_files.go +++ b/controllers/api/manage_files.go @@ -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 diff --git a/controllers/api/materials.go b/controllers/api/materials.go index dfcd15b..f678753 100644 --- a/controllers/api/materials.go +++ b/controllers/api/materials.go @@ -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 diff --git a/controllers/api/mcp_view.go b/controllers/api/mcp_view.go index 4df915d..68b8c08 100644 --- a/controllers/api/mcp_view.go +++ b/controllers/api/mcp_view.go @@ -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, + } +} diff --git a/controllers/api/mcp_view_test.go b/controllers/api/mcp_view_test.go index 46872cf..6df5d58 100644 --- a/controllers/api/mcp_view_test.go +++ b/controllers/api/mcp_view_test.go @@ -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) } diff --git a/mcp_links.go b/mcp_links.go index ff52fb3..e053af8 100644 --- a/mcp_links.go +++ b/mcp_links.go @@ -1,9 +1,142 @@ package bm -import "net/http" +import ( + "bytes" + "encoding/json" + "mime" + "net/http" + "strconv" + "strings" -// MiddlewareMCPLinks makes the links of an MCP answer absolute. + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// MiddlewareMCPLinks makes the links of an MCP answer absolute (D-16). It +// runs after user.api_token on the token twins of the student API and on the +// management API. const MiddlewareMCPLinks = "jz.bm.mcp-links" -// mcpLinks is not implemented yet. -func (p *Plugin) mcpLinks(next http.Handler) http.Handler { return next } +// mcpLinks rewrites the 2xx JSON answer of an MCP tool call (a request pier +// dispatched in-process with the api_token marker): every string under a key +// named url or ending in _url that starts with a single / gets the +// application's base URL in front, so an agent can follow it. Any other +// request passes straight through, unbuffered and byte for byte. +func (p *Plugin) mcpLinks(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := bouncer.Dispatched(r.Context(), "api_token"); !ok { + next.ServeHTTP(w, r) + return + } + rec := &linkRecorder{header: http.Header{}} + next.ServeHTTP(rec, r) + status := rec.status + if status == 0 { + status = http.StatusOK + } + out := rec.body.Bytes() + if status >= 200 && status < 300 && isJSON(rec.header.Get("Content-Type")) && len(out) > 0 { + if rewritten, ok := absoluteLinks(out, p.linkBase(r)); ok { + out = rewritten + rec.header.Set("Content-Length", strconv.Itoa(len(out))) + } + } + for k, v := range rec.header { + w.Header()[k] = v + } + w.WriteHeader(status) + _, _ = w.Write(out) + }) +} + +// linkBase is the base of the absolute links: app.url, else +// jz.bm.public_url, else the scheme (TLS or X-Forwarded-Proto) and Host of +// the request (pier copies both onto the request it dispatches). +func (p *Plugin) linkBase(r *http.Request) string { + if p.app != nil && p.app.Config != nil { + for _, key := range []string{"app.url", "jz.bm.public_url"} { + if base := strings.TrimRight(strings.TrimSpace(p.app.Config.String(key)), "/"); base != "" { + return base + } + } + } + scheme := "http" + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + scheme = "https" + } + return scheme + "://" + r.Host +} + +// isJSON reports whether a Content-Type is JSON. +func isJSON(contentType string) bool { + mt, _, err := mime.ParseMediaType(contentType) + return err == nil && (mt == "application/json" || strings.HasSuffix(mt, "+json")) +} + +// absoluteLinks decodes body (numbers kept exactly), prefixes its relative +// links with base and encodes it again. It reports false when body is not a +// single JSON value, so the caller sends it unchanged. +func absoluteLinks(body []byte, base string) ([]byte, bool) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, false + } + if dec.More() { + return nil, false + } + v = rewriteLinks(v, "", base) + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, false + } + return buf.Bytes(), true +} + +// rewriteLinks walks v; key is the object key v sits under ("" inside an +// array or at the top). +func rewriteLinks(v any, key, base string) any { + switch t := v.(type) { + case map[string]any: + for k, child := range t { + t[k] = rewriteLinks(child, k, base) + } + return t + case []any: + for i, child := range t { + t[i] = rewriteLinks(child, "", base) + } + return t + case string: + if (key == "url" || strings.HasSuffix(key, "_url")) && strings.HasPrefix(t, "/") && !strings.HasPrefix(t, "//") { + return base + t + } + return t + default: + return v + } +} + +// linkRecorder buffers a handler's answer so mcpLinks can rewrite it. +type linkRecorder struct { + header http.Header + status int + body bytes.Buffer +} + +func (l *linkRecorder) Header() http.Header { return l.header } + +func (l *linkRecorder) WriteHeader(status int) { + if l.status == 0 { + l.status = status + } +} + +func (l *linkRecorder) Write(b []byte) (int, error) { + if l.status == 0 { + l.status = http.StatusOK + } + return l.body.Write(b) +} diff --git a/mcp_student.go b/mcp_student.go index 04a8824..aa407f1 100644 --- a/mcp_student.go +++ b/mcp_student.go @@ -6,6 +6,10 @@ import ( "git.golem15.com/golem15/summercms/modules/pact" ) +// MCPMaterialPrivacy is the sentence every material tool description +// carries (D-17, D-21). +const MCPMaterialPrivacy = "Returns metadata and links only, never file contents; a material's body (possible transcript text) is left out unless include_body is true." + // The sentences every student tool description ends with (D-15). const ( needsScopeRead = " Needs scope read." @@ -41,6 +45,22 @@ type readingsInput struct { Course uint `json:"course,omitempty" query:"course" jsonschema:"only readings of this course id"` } +// materialsInput is the input of bm_materials. +type materialsInput struct { + Course uint `json:"course,omitempty" query:"course" jsonschema:"only materials of this course id"` + Meeting uint `json:"meeting,omitempty" query:"meeting" jsonschema:"only materials of this zjazd (meeting) id"` + Tag string `json:"tag,omitempty" query:"tag" jsonschema:"only materials with this tag slug, at most 100 characters"` + Type string `json:"type,omitempty" query:"type" jsonschema:"only materials of this type, one of: pdf, notatka, link, inne"` + Page int `json:"page,omitempty" query:"page" jsonschema:"page number, 1 or more (default 1)"` + PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"materials per page, 1 to 100"` +} + +// materialInput is the input of bm_material. +type materialInput struct { + ID uint `json:"id" path:"id" jsonschema:"the material id, as listed by bm_materials"` + IncludeBody bool `json:"include_body,omitempty" query:"include_body" jsonschema:"true to include the material's body (it may hold transcript text)"` +} + // boolPtr returns a pointer to b, for the tool annotations. func boolPtr(b bool) *bool { return &b } @@ -152,5 +172,23 @@ func studentTools() []pact.MCPTool { Destructive: boolPtr(false), Idempotent: boolPtr(true), }, + { + Name: "materials", + Title: "BM materials", + Description: "Lists a page of published materials, newest first, with title, type, course, zjazd, source, link, tags and file count, plus the page meta. " + MCPMaterialPrivacy + needsScopeRead, + Guard: "user", + Method: http.MethodGet, + Path: TokenPrefix + "/materials", + Input: materialsInput{}, + }, + { + Name: "material", + Title: "BM material", + Description: "Returns one published material with its tags and its files (id, absolute link, name, size and type). " + MCPMaterialPrivacy + needsScopeRead, + Guard: "user", + Method: http.MethodGet, + Path: TokenPrefix + "/materials/{id}", + Input: materialInput{}, + }, } } diff --git a/routes.go b/routes.go index aa3632e..fa02e56 100644 --- a/routes.go +++ b/routes.go @@ -31,8 +31,8 @@ var ( const ( bucketCalendarIP = "throttle:jz.bm.calendar-ip" bucketCalendarToken = "throttle:jz.bm.calendar-token" - // bucketManage throttles the management API per token, or per client IP - // for a request without one. + // bucketManage throttles the management API per user for an MCP tool + // call, else per token, or per client IP for a request without one. bucketManage = "throttle:jz.bm.manage" // bucketRealtime throttles the realtime token route per user (it runs // after jwt.auth), or per client IP for a request without one. The @@ -70,6 +70,13 @@ func (p *Plugin) Buckets() map[string]surf.Bucket { Max: 120, Decay: time.Minute, Key: func(r *http.Request) string { + // An MCP tool call carries no bearer and comes from the + // endpoint's own address; the dispatch marker (set in-process + // only, never by an HTTP request) names its user. The bucket + // runs before user.api_token, so bouncer.User is not set yet. + if d, ok := bouncer.Dispatched(r.Context(), "api_token"); ok && d.Principal.ID != 0 { + return "jz.bm.manage.user|" + strconv.FormatUint(uint64(d.Principal.ID), 10) + } if secret := usermiddleware.BearerToken(r); secret != "" { return "jz.bm.manage.token|" + userclasses.HashAPIToken(secret) } @@ -142,15 +149,17 @@ func (p *Plugin) Routes(r pact.Router) error { // and handlers behind user.api_token (golem15.user's MCP guard dispatches // only to user.api_token routes), with a scope on each. The handlers read // bouncer.User, so a token reaches only its own user's data. The bucket - // runs after the guard, so it keys by user. The calendar feed and the - // chat have no twin. - r.Group(TokenPrefix, surf.Use("user.api_token", bucketToken, "locale.from-principal"), func(g pact.Router) { + // runs after the guard, so it keys by user. jz.bm.mcp-links makes the + // links of an MCP answer absolute. The calendar feed and the chat have no + // twin. + r.Group(TokenPrefix, surf.Use("user.api_token", bucketToken, MiddlewareMCPLinks, "locale.from-principal"), func(g pact.Router) { mountStudent(g, h, []string{"user.scope:read"}, []string{"user.scope:write"}) }) // The management API: what bm:seed does, plus materials, // behind the application-wide user API token. Scope limits delegation; // user.permission checks the owner's current group permissions. - r.Group(ManagePrefix, surf.Use(bucketManage, "user.api_token"), func(g pact.Router) { + // jz.bm.mcp-links makes the links of an MCP answer absolute. + r.Group(ManagePrefix, surf.Use(bucketManage, "user.api_token", MiddlewareMCPLinks), func(g pact.Router) { g.Get("/me", h.TokenInfo, "user.scope:read") g.Get("/materials", h.ManagedMaterials, "user.scope:read", "user.permission:"+controllers.PermMaterials) g.Get("/materials/{id}", h.ManagedMaterial, "user.scope:read", "user.permission:"+controllers.PermMaterials) @@ -290,6 +299,7 @@ func (p *Plugin) handlers() *api.Handlers { h.PerPage = p.app.Config.Int("jz.bm.materials_per_page") h.DashboardLimit = p.app.Config.Int("jz.bm.dashboard_limit") h.PublicURL = p.app.Config.String("jz.bm.public_url") + h.AppURL = p.app.Config.String("app.url") } return h }