Redis

A FrontMCP server keeps some state between requests: the sessions of clients on protocol versions before 2026-07-28, background tasks, and questions waiting for the user's answer. By default that state lives in the process, so it's lost on a restart and invisible to a second instance. @FrontMcp({ redis }) moves it to Redis, where it survives restarts and every instance sees it. Clients on MCP 2026-07-28 keep no session, so a server that only serves them needs Redis for less than you might expect.

@FrontMcp({ redis: { url, keyPrefix?, defaultTtlMs? } })
@FrontMcp({ redis: { host, port?, password?, db?, tls?, keyPrefix?, defaultTtlMs? } })

Reference

redis

main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  redis: { url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379" },
})
export default class Server {}

See more examples below.

Options

OptionTypeDefaultWhat it does
urlstringA redis:// or rediss:// URL, redis://[:password@]host[:port][/db], in place of the next five fields: rediss:// sets tls. The user, when there is one, must be default. Since 1.9.
hoststringThe Redis server. Required without url.
portnumber6379
passwordstringnone
dbnumber0The database number.
tlsbooleanfalseConnect over TLS, as managed Redis services usually require.
keyPrefixstring"mcp:"Put before the session keys, and the keys of pending questions. Background tasks ignore it.
defaultTtlMsnumber3600000How long a session's key lives, in milliseconds. See how long sessions last.
provider"redis" or "vercel-kv""redis""vercel-kv" talks to Vercel KV or Upstash over HTTP instead, with url and token: see Vercel.

Give either url or the fields. With a url, the URL is the base, and port, password, db and tls beside it fill in only what it leaves out: { url: "redis://127.0.0.1", port: 6392 } connected on port 6392, and { url: "redis://127.0.0.1:6391", password } signed in with the password. A field that contradicts the URL stops the server at startup, with a ZodError whose message names the field and not its value:

redis host contradicts redis.url. Fields beside a url only fill in what the URL leaves out (port, password, db, tls); put the value in the URL or drop the field.

So host beside a url always fails, and port or db fails when the URL has its own (port contradicts, db contradicts); a field equal to the URL's value is accepted. Before 1.9.2, host won and the URL was ignored, and a password beside a url was dropped without a word. keyPrefix and defaultTtlMs go next to either. A url FrontMCP can't use stops the server at startup, with redis.url names the ACL user "support"; only the default user is supported, redis.url must use the redis:// or rediss:// scheme, got "http://" or redis.url is not a valid URL (expected redis://[[user]:password@]host[:port][/db]).

pubsub, a second option of the same shape, url included, is the Redis for resource-subscription events. It defaults to redis, and is needed when redis is Vercel KV, which has no publish and subscribe.

throttle.storage's redis.config (below) and, since 1.9.3, jobs.store.redis read a url and the fields beside it by the same rule: jobs: { enabled: true, store: { redis: { url: "redis://127.0.0.1:6399", port: 6400 } } } stopped the server at startup with redis port contradicts redis.url, and { url: "redis://127.0.0.1", port: 6398 } dialled port 6398 (connect ECONNREFUSED 127.0.0.1:6398, with nothing listening there). (In 1.9.2 the job stores used only host and port.)

What's kept in Redis

StateWith redis setKey
Sessions of clients before MCP 2026-07-28In Redis, unless transport.persistence is false. The server logs redis session store will be initialized for transport persistence.mcp:session:<session id>, a JSON string with the session, the hash of its authorization, createdAt, lastAccessedAt, initialized and the client's capabilities. It isn't encrypted.
Background tasksIn Redis: Created task store { type: 'redis', keyPrefix: 'mcp:task:' }.Under mcp:task:, whatever keyPrefix says.
Questions waiting for an answer (elicitation: { enabled: true })In elicitation.redis, else in redis. The startup log says Created elicitation store { type: 'redis', … }.mcp:pending:<session id>, under keyPrefix, while a question of an older client waits. Clients on MCP 2026-07-28 carry their answers in requestState, and nothing is stored.
SSE events, for clients that resume a streamOnly with transport.eventStore, or in distributed mode.
Plugin storesOnly for plugins initialized with type: "global-store", like CachePlugin.init({ type: "global-store" }), which use the server's redis. See Cache and Remember.The plugin's own

Clients on MCP 2026-07-28 open no session: nothing is written to Redis for their calls unless a tool starts a task or asks a question.

These have storage options of their own, and don't use redis:

StateOptionPage
local and remote auth: pending sign-ins, codes, refresh tokensauth.tokenStorage: { redis }Local auth
Job and workflow runsjobs.store: { redis }, with the same fields as redis, url includedJob
Rate-limit and concurrency countsthrottle.storage: keys mcp:guard:<entity>:…. In production a server refuses to start when it can't reach that Redis, unless fallback: "memory": see below.Guard options

From the environment

Without redis, the environment variables REDIS_URL or REDIS_HOST move background tasks, and questions waiting for an answer when elicitation is on, to Redis. Sessions stay in memory, and so do rate-limit counts. So REDIS_HOST=redis, which the Docker Compose file of frontmcp create sets, doesn't store sessions until main.ts reads it into redis. KV_REST_API_URL is checked too: the server turns background tasks off with a warning, since Vercel KV can't serve them, and refuses elicitation: { enabled: true } unless elicitation.redis names a real Redis.

How long sessions last

A session's key lives an hour after it's written, or redis.defaultTtlMs, in milliseconds, when you set it. FrontMCP writes the key when the session starts, and again when an instance loads a session it doesn't have in memory. The instance that holds the session pushes the expiry out again while it serves the session's requests, at most once every quarter of the TTL, so a session used all day on one instance stays in Redis, and another instance can still pick it up if that one stops.

redis: { host: "127.0.0.1", port: 6379, defaultTtlMs: 600_000 },   // sessions live 600 s
transport: { persistence: { defaultTtlMs: 600_000 } },             // the same, and it wins if both are set

With defaultTtlMs: 60000, redis-cli ttl showed 60 after initialize, 40 twenty seconds later, and 60 again after a request on the instance that holds the session. (With 1.8.7, a request that reached the other instance set it back too.) Changed in 1.8.7: in 1.8.6 redis.defaultTtlMs was ignored, and a session its own instance kept serving expired an hour after it was loaded.

Once the key has expired, the session is gone on every instance, the one that holds it in memory included: with defaultTtlMs: 4000, a request after six idle seconds got 404 {"code":-32001,"message":"session expired"}. Changed in 1.9.3: an instance now checks that the key is still there before it serves a session from memory, one EXISTS per request; in 1.9.2 the instance that held the session kept serving it. Since 1.9.4 it waits at most transport.persistence.sessionCheckTimeoutMs for the answer (below).

When Redis can't be reached

WhenWhat happens
At startupThe server starts anyway, keeps sessions in memory, and logs [RedisSessionStore] Connection failed, [TransportService] Failed to connect to redis - session persistence disabled and [storage] Warning: Failed to connect to redis, falling back to memory. FrontMCP then retries with a growing delay (about 1, 2, 4 and 8 seconds apart in a run, and on). When Redis answers, it logs Session store connection validated successfully and Session store recovered after startup failure, and sessions go to Redis from then on. /readyz answers 503, with the probe session-store unhealthy and "error": "Session store ping returned false", until then.
While runningClients keep working, with sessions in the instance's memory. /readyz answers 503 until Redis is back, then 200 again. A Redis that stops answering without closing its connections, like a paused container, is waited for at most transport.persistence.sessionCheckTimeoutMs (500 ms by default) on a session the instance holds, which it then serves from memory; a request that has to load its session from Redis waits until Redis answers (High availability). The log has [RedisStorage] Redis connection error: connect ECONNREFUSED … once, then the same line about every 30 seconds with (N similar error(s) suppressed).
throttle.storage, at startupIn production, the server doesn't start: GuardStorageUnavailableError: throttle.storage (redis) is unavailable: Failed to connect to Redis: connect ECONNREFUSED …. Rate limits fail closed, so add fallback: "memory" to throttle.storage to start with per-instance counters instead. Outside production, memory is the default.
throttle.storage, while runningA call to a tool that has a rate limit or a concurrency limit fails, until Redis is back, with the tool error GUARD_STORAGE_UNAVAILABLE (HTTP 200, isError) and the text Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly.; other tools aren't affected. The log has the cause. With throttle.global, which is checked for every request, every request answers 503 with Retry-After: 1 and {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Service temporarily unavailable: the rate-limit store cannot be reached","data":{"code":"GUARD_STORAGE_UNAVAILABLE"}}}. With fallback: "memory" the calls are counted in the instance's memory instead; outside production that's the default, here too. It logs once, GuardManager: throttle.storage (redis) is unavailable: … Using per-instance in-memory counters until it answers again, and the first limit check 30 seconds or more after the last failure tries Redis again: when it answers, the log says GuardManager: throttle.storage (redis) is available again.

transport.persistence.sessionCheckTimeoutMs, a positive whole number of milliseconds, sets how long an instance waits for Redis before it serves a session it holds from memory:

redis: { url: process.env.REDIS_URL! },
transport: { persistence: { sessionCheckTimeoutMs: 2000 } },   // then serve from memory, and check again next time

With the Redis container paused, a call on such a session answered 200 after two seconds, and the log said [TransportService] Could not confirm the session is still stored — serving it here with The session store did not answer within 2000 ms. A value that isn't a positive whole number stops the server at startup with a ZodError naming sessionCheckTimeoutMs: Too small: expected number to be >0, or expected int, received number. New in 1.9.4: in 1.9.3 the check waited for Redis with no limit.

A missing password gives NOAUTH Authentication required. in place of the connection error, and a wrong one WRONGPASS invalid username-password pair or user is disabled. So Redis failing doesn't take the server down, but it stops sessions from being shared: point your orchestrator's readiness check at /readyz so an instance without Redis gets no traffic.

auth.tokenStorage is the exception: when its Redis can't be reached, the server doesn't start. See Local auth. The guard's side of an outage is on the guard reference.

throttle: { enabled: true, storage: { type: "redis", redis: { config: { host: "127.0.0.1" } }, fallback: "memory" } },

throttle.storage's redis.config takes a url too since 1.9.2: config: { url: "redis://127.0.0.1:6392" } started in production and connected.

With fallback: "memory", two instances count a limit of two a window separately while Redis is down: with one call each, then another to each, all four went through. When Redis is back they share one count again, in the same key.

Caveats

  • Sessions are stored as plain JSON. Keep Redis on a private network, with a password, and TLS where the network isn't yours.
  • With FRONTMCP_DEPLOYMENT_MODE=distributed, a session stays with the instance that made it, and the others relay its requests there through Redis. See High availability.
  • Redis can't be used from a Cloudflare Worker. The Cloudflare build refuses a redis block in the entry, and one spread in only when a variable is set gets past a build without that variable, then answers every request 503 SERVER_START_FAILED when the Worker has it: see Cloudflare Workers.
  • The Playground checks how redis and its url are read (Running Several Instances), but has no Redis to connect to. The rest was run against Valkey, which speaks Redis's protocol, in Docker, and the keys shown are what redis-cli --scan listed. With FrontMCP 1.9.4 and Valkey 8: sessions across two instances on one machine, and a Redis that stops answering (a paused container). With 1.9.3: jobs.store.redis with a url. With 1.9.2 and Valkey 8: url with fields beside it, a missing password (NOAUTH) and throttle.storage with a url. With 1.9.1 and Valkey 9: the keys and keyPrefix, sessions behind nginx, how long they last and the TTL refresh, a Redis that's down at startup and comes back, rate limits with throttle.storage, its Redis down at startup, and going away while the server runs, with and without throttle.global. The fallback while running and a session store's Redis going away while running were last run with 1.8.7, a wrong password with 1.8.6, and TLS wasn't tried.

Usage

Storing sessions in Redis

Run a Redis, and give the server its address:

docker run -d --name desk-redis -p 127.0.0.1:6379:6379 redis:7-alpine
export MCP_SESSION_SECRET="$(openssl rand -hex 32)"
REDIS_URL=redis://127.0.0.1:6379 node dist/node/help-desk.bundle.js

With the configuration at the top of the page, the server logs:

[TransportService] redis session store will be initialized for transport persistence
[TaskStoreFactory] Created task store { type: 'redis', keyPrefix: 'mcp:task:', supportsPubSub: true }
[TransportService] Session store connection validated successfully

A client on MCP 2025-06-18 that sends initialize gets an Mcp-Session-Id, and the session appears in Redis:

docker exec desk-redis redis-cli --scan
mcp:session:<session id>

Restart the server, and the client's next tools/call with the same Mcp-Session-Id works: the server logs Recreating transport from stored session. Without redis, the same request after a restart gets 404 {"jsonrpc":"2.0","error":{"code":-32000,"message":"session not initialized"}}: the id is still one the server can read, and there's no session behind it.

The secret is what makes the id readable after a restart. Outside production, without MCP_SESSION_SECRET, FrontMCP encrypts ids with a key made from the instance's machine id, which is new on every start, so the old id gets 404 invalid session id even with redis. Setting MACHINE_ID fixes that in development; production needs the secret anyway.

Sharing one Redis between servers

Give each server its own keyPrefix, so their sessions don't mix:

redis: { url: process.env.REDIS_URL!, keyPrefix: "desk:" },

Sessions are then desk:session:<id>. Background tasks still use mcp:task: on every server; use a separate db if two servers must not see each other's tasks.

Connecting to managed Redis

Managed services give a URL with the password in it, often rediss:// for TLS. Pass it as it is, from the environment:

redis: { url: process.env.REDIS_URL! }, // rediss://default:<password>@cache.example.com:6380

FrontMCP reads it as host: "cache.example.com", port: 6380, the password, and tls: true. A service that gives the parts separately takes them as fields: { host, port, password, tls: true }. TLS wasn't tried for this page.

Changed in 1.9: in 1.8.7 redis had no url, and a managed Redis's URL had to be split into fields.

Making readiness depend on Redis

Nothing to configure: with redis set, /readyz includes the session-store probe, which pings Redis:

{"status":"ready","totalLatencyMs":3,"catalog":{"toolsHash":"<hash of the tool names>","toolCount":1,"resourceCount":0,"promptCount":0,"skillCount":0,"agentCount":0},"probes":{"session-store":{"status":"healthy","latencyMs":1}}}

In production, probes is left out of the answer, and only the status tells you. See Health checks and metrics.


Troubleshooting

[RedisStorage] Redis connection error: connect ECONNREFUSED 127.0.0.1:6379

A server that had Redis lost it, or Redis isn't where host and port point. The line appears once, then about every 30 seconds with a count of the errors it left out, while FrontMCP reconnects; /readyz answers 503, and clients keep working with sessions in memory. When Redis is back, the check passes again. Inside Docker Compose, the host is the service's name, like redis, not localhost. (In 1.8.6 this was [ioredis] Unhandled error event: …, over and over.)

Could not confirm the session is still stored — serving it here

A warning, from an instance that holds a session in memory and couldn't check that Redis still has it: Redis refused the connection, or didn't answer within transport.persistence.sessionCheckTimeoutMs. The error beside it says which: the Redis client's own error, or The session store did not answer within 500 ms. The request was served from memory, and the next one checks again. Look at Redis; raise sessionCheckTimeoutMs only if Redis is slow rather than gone. See when Redis can't be reached.

Failed to connect to redis - session persistence disabled

The first connection failed, so this instance keeps sessions in memory until FrontMCP's next try works: it retries with a growing delay and logs Session store recovered after startup failure when it does, with no restart. If it never recovers, NOAUTH Authentication required. in the same message means the password is missing, and WRONGPASS invalid username-password pair or user is disabled. that it's wrong.

GuardStorageUnavailableError: throttle.storage (redis) is unavailable

The server has throttle.storage on Redis, runs in production, and couldn't reach that Redis as it started. It exits, because rate limits fail closed. Fix the connection, or add fallback: "memory" to throttle.storage to start with per-instance counters. See when Redis can't be reached.

A tool fails with GUARD_STORAGE_UNAVAILABLE

The tool has a rate limit or a concurrency limit, and throttle.storage's Redis stopped answering while the server runs. The call is refused, because a limit that can't be counted isn't applied. It works again when Redis does. Add fallback: "memory" to keep serving with per-instance counters meanwhile.

404 invalid session id from an instance with Redis

The session id wasn't made under this instance's MCP_SESSION_SECRET: the instances have different secrets, or the secret was rotated. The key is still in Redis until it expires, and the instance ignores it. The client starts a new session. See High availability.

ZodError invalid_union for redis

redis got a shape it doesn't accept, like one with neither url nor host, or a url FrontMCP 1.8.7 and earlier don't read. The process exits with Invalid input: expected "redis", expected "vercel-kv" and expected string, received undefined. Give redis a url or a host. The messages of a url it can't use are above.

NOAUTH Authentication required. with redis: { url }

Redis wants a password, and neither the URL nor a password beside it gives one. Add it either way: redis://:<password>@host:6379, or { url, password }. Before 1.9.2 a password beside url was ignored, so only the URL worked.

redis host contradicts redis.url

A field beside url says something the URL already says differently: host always, port or db when the URL has them too. Put the value in the URL, or drop the field. See options.

Older clients get 404 session not initialized after a restart or a deploy

Their sessions were in memory. Set redis, and check the startup log for redis session store will be initialized. If the answer is 404 invalid session id instead, the id was made under another MCP_SESSION_SECRET: see above.

Sessions expire after an hour, though redis.defaultTtlMs is set

Before 1.8.7, redis.defaultTtlMs didn't apply to sessions; transport.persistence.defaultTtlMs did, and still wins when both are set. See how long sessions last.

/readyz answers 503 with Session store ping returned false

The server can't reach Redis. It keeps serving; once Redis is back the check passes again, with no restart.

REDIS_URL or REDIS_HOST is set but sessions aren't in Redis

The environment variable alone only moves background tasks and pending questions. Read it into @FrontMcp({ redis }). See from the environment.