Files
summercms/modules/pier/pier.go
Jakub Zych 24cf6cd931 fix(pier): end only the revoked token's or client's MCP sessions and log the client address (B-WR-04, B-IN-04)
- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a
  narrowed revoked event closes only matching sessions and stateless servers
- cabana fires the admin token revoke with the token row id
- towel.WithClientIP/ClientIP; pier puts the address (surf applies
  http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
2026-10-23 16:21:37 +02:00

359 lines
11 KiB
Go

// Package pier is the opt-in MCP endpoint of a SummerCMS application: the
// place where outside agents dock to reach the application's plugins.
// Plugins declare tools and prompts through pact.HasMCP, bound to their own
// token-guarded routes; pier lists them per caller and runs every call
// in-process through the route's compiled middleware chain as the caller's
// pre-resolved principal. It is linked only in binaries built with the
// summercms_mcp tag and mounted only with mcp.enabled true.
package pier
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"runtime/debug"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/modelcontextprotocol/go-sdk/auth"
)
// streamRecheckInterval is how often a long-lived GET stream re-verifies
// its bearer (Pitfall 10); a failing bearer closes the stream.
const streamRecheckInterval = time.Minute
// frameworkModule is the module path whose version names the server.
const frameworkModule = "git.golem15.com/golem15/summercms"
// maxRequestBody bounds one MCP request body, as the SDK's default does.
const maxRequestBody = 4 << 20
// wellKnownPRM is the RFC 9728 well-known path.
const wellKnownPRM = "/.well-known/oauth-protected-resource"
// Pier is the mounted MCP endpoint: its configuration, the compiled
// catalog, the identity resolver, the dispatcher, the session store and the
// stateful session router.
type Pier struct {
cfg Config
app *backpack.App
host Host
catalog *catalog
identities *identityResolver
dispatch *dispatcher
store Store
sessions *sessionRouter
stateless *statelessPath
handler http.Handler
recheck time.Duration
prmURL string
appName string
version string
instance string
// stopReap ends the session reaper (and a Redis store's warm-up) Mount
// starts. backpack.App has no shutdown hook yet, so in production the
// reaper runs for the life of the process, like the store subscription;
// when backpack gains one, Mount registers stopReap there.
stopReap context.CancelFunc
}
// Mount reads the mcp.* keys and, when mcp.enabled is true, compiles every
// plugin's MCP declaration against the host's route table, registers the
// built-in backend guard provider, builds the session store and mounts
// POST, GET and DELETE at mcp.path through the host's streaming route kind
// behind the inline throttle. Nothing is mounted when the flag is off. A
// bad binding, a duplicate name, an unknown guard or a route already
// registered at mcp.path fails with an error naming the plugin, so boot
// (and route:list) fail. Mount never waits for Redis: a Redis store is
// opened by a background warm-up with a backoff, so the event subscription
// exists before traffic arrives. Mount also starts the session reaper. The
// mounted *Pier is published on the application.
func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
if host == nil || app == nil {
return errors.New("pier: a host and an application are required")
}
cfg, err := LoadConfig(app.Config)
if err != nil {
return err
}
if !cfg.Enabled {
return nil
}
reg, err := guardRegistry(app)
if err != nil {
return err
}
if err := registerBackendGuard(app, reg); err != nil {
return err
}
cat, err := compileCatalog(host, reg, plugins)
if err != nil {
return err
}
frontend, err := frontendGuardName(reg)
if err != nil {
return err
}
p := &Pier{
cfg: cfg,
app: app,
host: host,
catalog: cat,
appName: appName(app),
version: frameworkVersion(),
instance: rand.Text(),
recheck: streamRecheckInterval,
}
p.identities = newIdentityResolver(app, cfg, frontend)
p.dispatch = &dispatcher{host: host, app: app, cfg: cfg}
p.sessions = newSessionRouter(p)
p.stateless = newStatelessPath(p)
p.store = p.newStore()
p.prmURL = p.metadataURL()
p.handler = p.originCheck(p.bodyLimit(p.bearer(http.HandlerFunc(p.route))))
methods := []string{http.MethodPost, http.MethodGet, http.MethodDelete}
for _, m := range methods {
if owner, ok := host.Owner(m, cfg.Path); ok {
return fmt.Errorf("pier: route %s %s (plugin %q) collides with the MCP endpoint %s mounts", m, cfg.Path, owner, PathKey)
}
}
for _, m := range methods {
if err := host.MountStream(m, cfg.Path, p.handler, "throttle:"+cfg.Throttle); err != nil {
return err
}
}
if err := p.mountPRM(); err != nil {
return err
}
p.listenEvents()
if err := app.Publish(p); err != nil {
return fmt.Errorf("pier: publishing the endpoint: %w", err)
}
reapCtx, stop := context.WithCancel(context.Background())
p.stopReap = stop
go p.sessions.reap(reapCtx, reapInterval(cfg.IdleTTL))
if ls, ok := p.store.(*lazyStore); ok {
go ls.warm(reapCtx)
}
return nil
}
// newStore builds the session store mcp.sessions.store selects: the memory
// store, subscribed to its own events at once, or a Redis store opened on
// first use from redis.url under cooler's key prefix (nothing dials at
// mount itself; a background warm-up opens it with a backoff, and a missing
// URL is a 503 on stateful requests, logged).
func (p *Pier) newStore() Store {
if p.cfg.SessionStore != StoreRedis {
ms := newMemoryStore()
_ = ms.Subscribe(context.Background(), p.handleEvent)
return ms
}
url := ""
prefix := cooler.KeyPrefix(nil)
if p.app.Config != nil {
url = strings.TrimSpace(p.app.Config.String(cooler.RedisURLKey))
prefix = cooler.KeyPrefix(p.app.Config)
}
return &lazyStore{open: func(ctx context.Context) (Store, error) {
if err := redisURLError(url); err != nil {
return nil, err
}
client, err := cooler.OpenRedis(ctx, url)
if err != nil {
return nil, err
}
rs := newRedisStore(client, prefix)
if err := rs.Subscribe(context.Background(), p.handleEvent); err != nil {
_ = client.Close()
return nil, err
}
return rs, nil
}}
}
// Handler returns the endpoint's handler (the Origin check, the body limit,
// the bearer verification and the session router), for tests and hosts
// that serve it outside the route table.
func (p *Pier) Handler() http.Handler {
return p.handler
}
// Config returns the loaded configuration.
func (p *Pier) Config() Config {
return p.cfg
}
// ToolNames lists the exposed tool names of the compiled catalog, sorted.
func (p *Pier) ToolNames() []string {
return toolNames(p.catalog.tools)
}
// metadataURL is the protected resource metadata URL named in the 401
// challenge: <app.url>/.well-known/oauth-protected-resource<mcp.path>, or
// the resource's origin when app.url is unset.
func (p *Pier) metadataURL() string {
base := p.cfg.AppURL
if base == "" {
if u, err := url.Parse(p.cfg.Resource); err == nil && u.Scheme != "" && u.Host != "" {
base = u.Scheme + "://" + u.Host
}
}
return base + wellKnownPRM + p.cfg.Path
}
// originCheck refuses a browser request whose Origin is not allowed (403
// before any body is read); a request without Origin passes.
func (p *Pier) originCheck(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" {
allowed := false
lower := strings.ToLower(origin)
for _, o := range p.cfg.AllowedOrigins {
if o == lower {
allowed = true
break
}
}
if !allowed {
http.Error(w, "Forbidden: origin not allowed", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
// bodyLimit caps the request body at maxRequestBody.
func (p *Pier) bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
}
next.ServeHTTP(w, r)
})
}
// bearer verifies the bearer of every request afresh and stores the
// resolved Identity on the request for the session router and the SDK
// handlers; a failure is a 401 carrying WWW-Authenticate with the
// protected resource metadata URL and the read scope.
func (p *Pier) bearer(next http.Handler) http.Handler {
verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) {
ctx = towel.WithClientIP(ctx, p.clientIP(r))
id, err := p.identities.resolve(ctx, token)
if err != nil {
return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err)
}
return &auth.TokenInfo{
Scopes: id.Scopes,
Expiration: id.ExpiresAt,
UserID: id.Binding,
Extra: map[string]any{extraIdentity: id, extraCall: callInfoFrom(r)},
}, nil
}
h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
ResourceMetadataURL: p.prmURL,
AllowMissingExpiration: true,
})(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.ServeHTTP(&challengeWriter{ResponseWriter: w}, r)
})
}
// clientIP is the request's client address: the host's answer when it
// implements clientIPHost (surf applies http.trusted_proxies), otherwise the
// host part of RemoteAddr, or RemoteAddr itself when it has no port.
func (p *Pier) clientIP(r *http.Request) string {
if h, ok := p.host.(clientIPHost); ok {
return h.ClientIP(r)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// challengeWriter completes the 401 challenge with scope="read", which the
// SDK middleware would otherwise enforce on every request.
type challengeWriter struct {
http.ResponseWriter
}
func (c *challengeWriter) WriteHeader(status int) {
if status == http.StatusUnauthorized {
h := c.Header()
if v := h.Get("WWW-Authenticate"); v != "" && !strings.Contains(v, "scope=") {
h.Set("WWW-Authenticate", v+`, scope="read"`)
}
}
c.ResponseWriter.WriteHeader(status)
}
func (c *challengeWriter) Flush() {
if f, ok := c.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (c *challengeWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter }
// identityFromRequest reads the Identity the bearer middleware stored.
func identityFromRequest(r *http.Request) (Identity, bool) {
ti := auth.TokenInfoFromContext(r.Context())
if ti == nil || ti.Extra == nil {
return Identity{}, false
}
id, ok := ti.Extra[extraIdentity].(Identity)
return id, ok && id.Binding != ""
}
// storeUnavailable answers 503 for a session store failure, logged once per
// call without the store's address.
func (p *Pier) storeUnavailable(w http.ResponseWriter, err error) {
logger().Error("pier: session store unavailable", "error", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "session_store_unavailable"})
}
// appName is app.name, or "summercms" when unset.
func appName(app *backpack.App) string {
if app != nil && app.Config != nil {
if name := strings.TrimSpace(app.Config.String("app.name")); name != "" {
return name
}
}
return "summercms"
}
// frameworkVersion is the framework module's version in the binary's build
// info, or "devel" for a replace or a checkout build.
func frameworkVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "devel"
}
if info.Main.Path == frameworkModule && info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
for _, dep := range info.Deps {
if dep.Path == frameworkModule {
if dep.Replace != nil || dep.Version == "" || dep.Version == "(devel)" {
return "devel"
}
return dep.Version
}
}
return "devel"
}