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 // , 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, and the guard checks // run while a session or stateless server is re-filtered, 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 , 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 ,. func validateThrottle(raw string) error { nStr, mStr, ok := strings.Cut(raw, ",") if !ok { return fmt.Errorf("not ,") } 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 ,") } 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 }