lazyStore's and newStore's doc comments no longer claim that nothing dials at mount: Mount starts a background warm-up that dials Redis with a backoff and logs each failed attempt. The comment records the decision to keep the warm-up for commands that mount pier without serving, such as route:list: it only dials and logs until the process exits.
359 lines
11 KiB
Go
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
|
|
// from redis.url under cooler's key prefix by a background warm-up that
|
|
// Mount starts (Mount itself never waits for it); 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"
|
|
}
|