Files
sm-bm-plugin/mcp_links.go
Jakub Zych df42e30034 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.
2026-10-28 14:37:10 +01:00

143 lines
3.9 KiB
Go

package bm
import (
"bytes"
"encoding/json"
"mime"
"net/http"
"strconv"
"strings"
"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 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)
}