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:
@@ -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
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
141
mcp_links.go
141
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)
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
22
routes.go
22
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user