diff --git a/docs/services/mcp.md b/docs/services/mcp.md index be1cba94..affd4e87 100644 --- a/docs/services/mcp.md +++ b/docs/services/mcp.md @@ -33,7 +33,7 @@ path: /mcp | `mcp.enabled` | `false` | Mounts POST, GET and DELETE at `mcp.path`. | | `mcp.path` | `/mcp` | The endpoint path. A route a plugin already registered there fails boot. | | `mcp.allow_personal_tokens` | `false` | Also accepts personal API tokens (see Identity). | -| `mcp.sessions.store` | `memory` | `memory` for one instance, `redis` to share sessions and events across instances through `redis.url`. | +| `mcp.sessions.store` | `memory` | `memory` for one instance, `redis` to share sessions and events across instances through `redis.url` (a standalone Redis; Redis Cluster is not supported). | | `mcp.sessions.idle_ttl` | `30m` | Sliding idle TTL of a session. | | `mcp.sessions.max_per_binding` | `20` | Open sessions one client-and-user pair (or one personal token) may hold. | | `mcp.resource` | `app.url` + `mcp.path` | The resource indicator; must equal the OAuth server's MCP resource. | diff --git a/modules/pier/README.md b/modules/pier/README.md index fb550687..4bf81297 100644 --- a/modules/pier/README.md +++ b/modules/pier/README.md @@ -121,7 +121,7 @@ Configuration keys are exported as constants: `pier.EnabledKey`, `pier.PathKey`, | `mcp.enabled` | `false` | Mounts the endpoint. `true` in a binary built without the `summercms_mcp` tag fails boot naming the tag. | | `mcp.path` | `/mcp` | The endpoint path: an absolute URL path without scheme, host, query, wildcard or dot segments. A route a plugin registered there fails boot naming the plugin. | | `mcp.allow_personal_tokens` | `false` | Lets personal API tokens open sessions: an admin `g15_` token yields backend tools only, a frontend token (through the published resolver) frontend tools only, each capped by the token's own scope. OAuth tokens are always accepted. | -| `mcp.sessions.store` | `memory` | `memory` (one instance; sessions end with the process and events reach only its own sessions) or `redis` (sessions and events shared by every instance through `redis.url`, opened on the first request). | +| `mcp.sessions.store` | `memory` | `memory` (one instance; sessions end with the process and events reach only its own sessions) or `redis` (sessions and events shared by every instance through `redis.url`, opened at mount by a background warm-up). The Redis store needs a standalone Redis, not Redis Cluster: its atomic reservation script reads keys of several hash slots, some not declared in `KEYS`, so an ACL restricted by key pattern must allow the whole `mcp:` range. | | `mcp.sessions.idle_ttl` | `30m` | Sliding idle TTL of a session record. | | `mcp.sessions.max_per_binding` | `20` | Open sessions one binding (client and user, or personal token) may hold; the next `initialize` answers 429. | | `mcp.resource` | `app.url` + `mcp.path` | The resource indicator the endpoint identifies itself by; must equal the OAuth server's MCP resource. Required (or `app.url`) when enabled. | diff --git a/modules/pier/store_redis.go b/modules/pier/store_redis.go index 3802e405..71435e29 100644 --- a/modules/pier/store_redis.go +++ b/modules/pier/store_redis.go @@ -141,6 +141,11 @@ func (s *redisStore) Count(ctx context.Context, binding string) (int, error) { // writes the record with its PX TTL, adds the id to the set and slides the // set's TTL (1). KEYS: the binding set, the record. ARGV: the record key // prefix, max, the record JSON, the TTL in milliseconds, the session id. +// The script also reads the record keys of the set's members, which it +// cannot declare in KEYS, and the binding set and the record do not share a +// hash slot: the store needs a standalone Redis (or a primary with +// replicas). Redis Cluster refuses the script as cross-slot, and an ACL +// that restricts keys by pattern must allow the whole mcp: range. var reserveScript = redis.NewScript(` local members = redis.call('SMEMBERS', KEYS[1]) local n = 0