// 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: /.well-known/oauth-protected-resource, 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" }