Files
summercms/modules/pier/config.go
Jakub Zych cfdbaf0e47 feat(19.7-02): pier MCP module tracer with in-process dispatch and sessions
- modules/pier: Host contract, mcp.* config, catalog compiled from
  pact.HasMCP (prefixing, route binding, guard resolution, schema
  reflection, annotations), per-request identity through the published
  verifier and resolvers, built-in backend guard provider, in-process
  dispatch with the bouncer marker, per-identity SDK server with the
  per-request re-filter, stateful session router with rehydration over a
  memory store, audit lines
- modules/surf/pier_on.go: the summercms_mcp hook adapting the router to
  pier.Host; the untagged hello binary carries no SDK
- go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed
  before this change)
- TestPierTracerSmoke: SDK client lists and calls acme_echo end to end
  through surf's compiled guard chain; README and root table row
2026-10-22 18:15:59 +02:00

263 lines
8.6 KiB
Go

package pier
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/compass"
)
// Configuration keys of the MCP module, all under mcp.* (config/mcp.yaml or
// SUMMER_MCP__* environment variables).
const (
// EnabledKey mounts the endpoint when true; false (the default) mounts
// nothing. surf reads it to call the mount hook.
EnabledKey = "mcp.enabled"
// PathKey is the endpoint path (POST, GET and DELETE), default /mcp.
PathKey = "mcp.path"
// AllowPersonalTokensKey lets personal API tokens (a frontend user token
// through the published resolver, an admin g15_ token through cabana)
// open sessions; false (the default) accepts OAuth access tokens only.
AllowPersonalTokensKey = "mcp.allow_personal_tokens"
// SessionStoreKey selects where session records live: memory (the
// default, one instance) or redis (shared through redis.url).
SessionStoreKey = "mcp.sessions.store"
// SessionIdleTTLKey is the sliding idle TTL of a session, default 30m.
SessionIdleTTLKey = "mcp.sessions.idle_ttl"
// SessionMaxPerBindingKey caps the open sessions of one binding (one
// client and user, or one personal token), default 20.
SessionMaxPerBindingKey = "mcp.sessions.max_per_binding"
// ResourceKey is the resource indicator the endpoint identifies itself
// by in the protected resource metadata; default app.url plus mcp.path.
ResourceKey = "mcp.resource"
// ThrottleKey is the inline throttle of the endpoint, surf's
// <max>,<minutes> spelling, default 120,1.
ThrottleKey = "mcp.throttle"
// AllowedOriginsKey lists the browser origins allowed to call the
// endpoint; default the origin of app.url. A request without an Origin
// header is never refused by it.
AllowedOriginsKey = "mcp.allowed_origins"
// CallTimeoutKey bounds one in-process tool call, default 30s.
CallTimeoutKey = "mcp.call_timeout"
// MaxResultBytesKey caps the route answer captured for a tool result,
// default 1048576 (1 MiB); a larger answer is a tool error.
MaxResultBytesKey = "mcp.max_result_bytes"
)
// Defaults of the mcp.* keys.
const (
DefaultPath = "/mcp"
DefaultSessionStore = "memory"
DefaultIdleTTL = 30 * time.Minute
DefaultMaxPerBinding = 20
DefaultThrottle = "120,1"
DefaultCallTimeout = 30 * time.Second
DefaultMaxResultBytes = int64(1 << 20)
)
// Session store names.
const (
StoreMemory = "memory"
StoreRedis = "redis"
)
// Config is the MCP module's configuration as LoadConfig reads it from the
// mcp.* keys, with defaults applied and every value validated.
type Config struct {
Enabled bool
Path string
AllowPersonalTokens bool
SessionStore string
IdleTTL time.Duration
MaxPerBinding int
Resource string
Throttle string
AllowedOrigins []string
CallTimeout time.Duration
MaxResultBytes int64
// AppURL is app.url trimmed of its trailing slash, the base of the
// default Resource and of the protected resource metadata URL.
AppURL string
}
// LoadConfig reads the mcp.* keys. A nil config is every default with the
// endpoint disabled. An invalid value fails with an error naming the key.
func LoadConfig(cfg *compass.Config) (Config, error) {
out := Config{
Path: DefaultPath,
SessionStore: DefaultSessionStore,
IdleTTL: DefaultIdleTTL,
MaxPerBinding: DefaultMaxPerBinding,
Throttle: DefaultThrottle,
CallTimeout: DefaultCallTimeout,
MaxResultBytes: DefaultMaxResultBytes,
}
if cfg == nil {
return out, nil
}
out.Enabled = cfg.Bool(EnabledKey)
out.AllowPersonalTokens = cfg.Bool(AllowPersonalTokensKey)
out.AppURL = strings.TrimRight(strings.TrimSpace(cfg.String("app.url")), "/")
path, err := endpointPath(cfg.String(PathKey))
if err != nil {
return out, err
}
out.Path = path
store := strings.ToLower(strings.TrimSpace(cfg.String(SessionStoreKey)))
switch store {
case "":
case StoreMemory, StoreRedis:
out.SessionStore = store
default:
return out, fmt.Errorf("pier: %s must be %s or %s, got %q", SessionStoreKey, StoreMemory, StoreRedis, store)
}
if out.IdleTTL, err = durationKey(cfg, SessionIdleTTLKey, DefaultIdleTTL); err != nil {
return out, err
}
if out.CallTimeout, err = durationKey(cfg, CallTimeoutKey, DefaultCallTimeout); err != nil {
return out, err
}
if cfg.Has(SessionMaxPerBindingKey) {
n := cfg.Int(SessionMaxPerBindingKey)
if n < 1 {
return out, fmt.Errorf("pier: %s must be a whole number of at least 1", SessionMaxPerBindingKey)
}
out.MaxPerBinding = n
}
if cfg.Has(MaxResultBytesKey) {
n := cfg.Int(MaxResultBytesKey)
if n < 1 {
return out, fmt.Errorf("pier: %s must be a whole number of bytes of at least 1", MaxResultBytesKey)
}
out.MaxResultBytes = int64(n)
}
if raw := strings.TrimSpace(cfg.String(ThrottleKey)); raw != "" {
if err := validateThrottle(raw); err != nil {
return out, fmt.Errorf("pier: %s must be <max>,<minutes> with both at least 1 (for example 120,1), got %q", ThrottleKey, raw)
}
out.Throttle = raw
}
if raw := strings.TrimSpace(cfg.String(ResourceKey)); raw != "" {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Fragment != "" || u.RawQuery != "" {
return out, fmt.Errorf("pier: %s must be an http or https URL without query or fragment, got %q", ResourceKey, raw)
}
out.Resource = strings.TrimRight(raw, "/")
} else if out.AppURL != "" {
out.Resource = out.AppURL + out.Path
} else if out.Enabled {
return out, fmt.Errorf("pier: %s or app.url is required when %s is true", ResourceKey, EnabledKey)
}
origins, err := originsKey(cfg, AllowedOriginsKey)
if err != nil {
return out, err
}
if origins == nil && out.AppURL != "" {
if u, err := url.Parse(out.AppURL); err == nil && u.Scheme != "" && u.Host != "" {
origins = []string{u.Scheme + "://" + u.Host}
}
}
out.AllowedOrigins = origins
return out, nil
}
// endpointPath validates mcp.path: an absolute URL path without scheme,
// host, query, wildcard or dot segments, with no trailing slash.
func endpointPath(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return DefaultPath, nil
}
path := strings.TrimSuffix(value, "/")
bad := !strings.HasPrefix(path, "/") || strings.ContainsAny(path, "{}?# \t\r\n\\") || path == ""
if !bad {
for _, seg := range strings.Split(path[1:], "/") {
if seg == "" || seg == "." || seg == ".." {
bad = true
break
}
}
}
if bad {
return "", fmt.Errorf("pier: %s must be an absolute URL path such as /mcp (no scheme, host, query or wildcard), got %q", PathKey, raw)
}
return path, nil
}
// durationKey reads a Go duration (30m, 45s) or a whole number of seconds.
func durationKey(cfg *compass.Config, key string, fallback time.Duration) (time.Duration, error) {
raw := strings.TrimSpace(cfg.String(key))
if raw == "" {
return fallback, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
n, nerr := strconv.Atoi(raw)
if nerr != nil {
return 0, fmt.Errorf("pier: %s must be a duration such as 30m or 45s, got %q", key, raw)
}
d = time.Duration(n) * time.Second
}
if d <= 0 {
return 0, fmt.Errorf("pier: %s must be a positive duration, got %q", key, raw)
}
return d, nil
}
// validateThrottle checks surf's inline throttle spelling <max>,<minutes>.
func validateThrottle(raw string) error {
nStr, mStr, ok := strings.Cut(raw, ",")
if !ok {
return fmt.Errorf("not <max>,<minutes>")
}
n, errN := strconv.Atoi(strings.TrimSpace(nStr))
m, errM := strconv.Atoi(strings.TrimSpace(mStr))
if errN != nil || errM != nil || n < 1 || m < 1 {
return fmt.Errorf("not <max>,<minutes>")
}
return nil
}
// originsKey reads a YAML list or a comma-separated string of origins
// (scheme://host[:port]); nil when the key is absent or empty.
func originsKey(cfg *compass.Config, key string) ([]string, error) {
raw, ok := cfg.Lookup(key)
if !ok || raw == nil {
return nil, nil
}
var items []string
switch v := raw.(type) {
case []any:
for _, item := range v {
items = append(items, fmt.Sprint(item))
}
case []string:
items = append(items, v...)
case string:
items = strings.Split(v, ",")
default:
return nil, fmt.Errorf("pier: %s must be a list of origins such as https://example.com", key)
}
var out []string
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
u, err := url.Parse(item)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, fmt.Errorf("pier: %s entries must be origins such as https://example.com, got %q", key, item)
}
out = append(out, strings.ToLower(u.Scheme+"://"+u.Host))
}
return out, nil
}