Fan-out matching snapshots the sessions (and stateless entries) under the router-wide lock, releases it, and reads each identity through a small identity box that is never held across I/O, so an event no longer waits on a session that is re-filtering while it holds sr.mu or sp.mu. Widening and the fan-out recomputes run their guard checks with guardContext: not cancelled with the triggering request, but bounded by mcp.call_timeout, so a provider that never answers cannot hold a session lock forever. README and docs/services/mcp.md describe the wider meaning of mcp.call_timeout. TestLockScopeUnderWidening blocks a widening guard check, fires a permission event for that session and asserts that another session's call still finishes, and that a never-answering check ends at call_timeout.
264 lines
8.7 KiB
Go
264 lines
8.7 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, 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 <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
|
|
}
|