- 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.
143 lines
3.9 KiB
Go
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)
|
|
}
|