# Running Several Instances

> What several copies of a FrontMCP server behind a load balancer must share. Per-instance memory, what clients on MCP 2026-07-28 need and what older clients need, the secrets every instance shares, sharing sessions and rate limits through Redis, and the traps to avoid.

Source: https://frontmcp.dev/learn/running-several-instances

One server is one process. When it restarts, it's gone for a moment, and when it's busy, every client waits. Running several copies of it, **instances**, behind a load balancer fixes both, and the load balancer sends each request to whichever instance it likes. That's only safe for what every instance can answer the same way. This lesson shows what instances must share, why clients on MCP 2026-07-28 need less of it than older clients, and how Redis carries the rest.

**You will learn**
- Why an instance's memory is its own, and what that breaks
- What clients on MCP 2026-07-28 need from several instances, and what older clients need
- Which secrets every instance must share
- How to share sessions and rate limits through Redis, with a load balancer in front
- The traps in FrontMCP 1.9.4: `REDIS_URL` alone, and rate limits that need Redis of their own

## Each instance has its own memory

Support agents add notes to tickets. `add_note` keeps them in a provider, which lives in the server's memory. That's fine for one instance. The tests below start two instances from the same configuration, as two processes behind a load balancer would be, and send calls to each. Open the **Tests** tab:

```ts instances.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

// One tools/call, as a 2026-07-28 client sends it
function call(name: string, args: Record<string, unknown>) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

async function send(instance: (request: Request) => Promise<Response>, name: string, args: Record<string, unknown>) {
  return (await (await instance(call(name, args))).json()).result.structuredContent;
}

test("either instance answers a call that carries what it needs", async () => {
  const a = await FrontMcpInstance.createFetchHandler(config);
  const b = await FrontMcpInstance.createFetchHandler(config);
  expect(await send(a, "get_ticket", { id: "T-1" })).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
  expect(await send(b, "get_ticket", { id: "T-1" })).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});

test("🚩 a note added on one instance is missing on the other", async () => {
  const a = await FrontMcpInstance.createFetchHandler(config);
  const b = await FrontMcpInstance.createFetchHandler(config);
  await send(a, "add_note", { id: "T-1", note: "Asked for a screenshot" });
  expect(await send(a, "get_notes", { id: "T-1" })).toEqual({ id: "T-1", notes: ["Asked for a screenshot"] });
  expect(await send(b, "get_notes", { id: "T-1" })).toEqual({ id: "T-1", notes: [] });
});
```

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { NoteStore } from "./note-store";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@Tool({ name: "add_note", description: "Add an internal note to a ticket.", inputSchema: { id: z.string(), note: z.string() } })
export class AddNote extends ToolContext {
  async execute({ id, note }: { id: string; note: string }) {
    const notes = this.get(NoteStore);
    notes.add(id, note);
    return { id, notes: notes.list(id) };
  }
}

@Tool({ name: "get_notes", description: "Get a ticket's internal notes.", inputSchema: { id: z.string() } })
export class GetNotes extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, notes: this.get(NoteStore).list(id) };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, AddNote, GetNotes], providers: [NoteStore] })
export class HelpDesk {}
```

```ts note-store.ts
import { Provider } from "@frontmcp/sdk";

// 🚩 One Map per server: each instance has its own
@Provider({ name: "NoteStore" })
export class NoteStore {
  private notes = new Map<string, string[]>();

  add(id: string, note: string) {
    this.notes.set(id, [...this.list(id), note]);
  }

  list(id: string) {
    return this.notes.get(id) ?? [];
  }
}
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
};
```

Each `createFetchHandler()` builds a server of its own, with its own providers, the way each process does. `get_ticket` works on both, because everything it needs is in the code and the request. The note only exists on the instance that took it, so an agent whose next request lands on the other instance sees no notes at all.

The fix is to keep the notes somewhere every instance reaches, and give each instance a store that talks to it. A provider can be handed to the server from outside, with `useValue`:

```ts note-store.ts active
import { Provider } from "@frontmcp/sdk";

// ✅ The notes live wherever `db` is, and every instance is given the same one
@Provider({ name: "NoteStore" })
export class NoteStore {
  constructor(private readonly db: Map<string, string[]>) {}

  add(id: string, note: string) {
    this.db.set(id, [...this.list(id), note]);
  }

  list(id: string) {
    return this.db.get(id) ?? [];
  }
}
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";
import { NoteStore } from "./note-store";

export function createServer(notes: NoteStore) {
  return {
    info: { name: "help-desk", version: "1.0.0" },
    apps: [HelpDesk],
    providers: [{ provide: NoteStore, name: "NoteStore", useValue: notes }],
  };
}
```

```ts instances.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { createServer } from "./config";
import { NoteStore } from "./note-store";

function call(name: string, args: Record<string, unknown>) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

async function send(instance: (request: Request) => Promise<Response>, name: string, args: Record<string, unknown>) {
  return (await (await instance(call(name, args))).json()).result.structuredContent;
}

test("✅ a note added on one instance is there on the other", async () => {
  const db = new Map<string, string[]>(); // stands in for the database both instances connect to
  const a = await FrontMcpInstance.createFetchHandler(createServer(new NoteStore(db)));
  const b = await FrontMcpInstance.createFetchHandler(createServer(new NoteStore(db)));
  await send(a, "add_note", { id: "T-1", note: "Asked for a screenshot" });
  expect(await send(b, "get_notes", { id: "T-1" })).toEqual({ id: "T-1", notes: ["Asked for a screenshot"] });
});
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { NoteStore } from "./note-store";

@Tool({ name: "add_note", description: "Add an internal note to a ticket.", inputSchema: { id: z.string(), note: z.string() } })
export class AddNote extends ToolContext {
  async execute({ id, note }: { id: string; note: string }) {
    const notes = this.get(NoteStore);
    notes.add(id, note);
    return { id, notes: notes.list(id) };
  }
}

@Tool({ name: "get_notes", description: "Get a ticket's internal notes.", inputSchema: { id: z.string() } })
export class GetNotes extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, notes: this.get(NoteStore).list(id) };
  }
}

// No NoteStore in the app: the server provides it
@App({ id: "help-desk", name: "Help Desk", tools: [AddNote, GetNotes] })
export class HelpDesk {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { createServer } from "./config";
import { NoteStore } from "./note-store";

// The server the Call tab talks to
@FrontMcp(createServer(new NoteStore(new Map())))
export default class Server {}
```

Both instances run in this page, so handing them one `Map` is the Playground's stand-in for a database they both connect to. Two real processes share nothing, not even a module variable: in production, `NoteStore` reads and writes your database, or Redis, and each instance is started with one connected to the same place. The tools don't change, which is the point of [putting state in a provider](https://frontmcp.dev/learn/sharing-state-with-providers#swapping-a-provider-in-tests).

## What clients need from several instances

The same question, "does another instance know about this?", decides what FrontMCP's own state needs, and the answer depends on the client.

A client on **MCP 2026-07-28** keeps no session. Every request carries what the server needs to answer it, the way `get_ticket` did above. A client on an **older protocol version** starts with `initialize`, gets an `Mcp-Session-Id`, and sends it with every request after that. The session lives in the memory of the instance that created it.

To see the difference, the help desk gets two more tools next to `get_ticket`: one that [asks the user](https://frontmcp.dev/learn/asking-the-user) two questions, and one with a [rate limit](https://frontmcp.dev/learn/limiting-calls):

```ts src/help-desk.app.ts
@Tool({
  name: "assign_ticket",
  description: "Assign a ticket to a support agent. Asks the user who, then asks to confirm.",
  inputSchema: { id: z.string() },
})
export class AssignTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const who = await this.elicit(`Who should take ${id}?`, z.object({ agent: z.enum(["Nour", "Sam", "Priya"]) }));
    const agent = who.status === "accept" ? who.content?.agent : undefined;
    if (!agent) return { id, assigned: null };
    const sure = await this.elicit(`Assign ${id} to ${agent}?`, z.object({ confirm: z.boolean() }));
    return { id, assigned: sure.status === "accept" && sure.content?.confirm === true ? agent : null };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV. At most 2 exports an hour.",
  inputSchema: {},
  rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") };
  }
}
```

Built with the `main.ts` from [the next section](#sharing-sessions-and-limits-through-redis), and started as two instances on one machine with no shared state and nothing set but the port:

```bash
PORT=4001 node dist/node/help-desk.bundle.js &
PORT=4002 node dist/node/help-desk.bundle.js &
```

What a client sees when its requests reach both:

| The client | Sends | Gets |
| --- | --- | --- |
| On MCP 2026-07-28 | `get_ticket` to 4001, then to 4002 | The ticket, from both. |
| On MCP 2025-06-18 | `initialize` to 4001, then `get_ticket` with its session id to 4002 | `404` `{"code":-32000,"message":"invalid session id"}` from 4002, which can't read an id that 4001 encrypted with its own secret. |
| On MCP 2026-07-28 | `assign_ticket` to 4001, the answer to its first question to 4002, the answer to its second to 4001 | The first question again, `Who should take T-1?`, and 4001 logs `rejected requestState` with `reason: 'bad-signature'` and a hint that names `VAULT_SECRET`. |
| Either | `export_tickets` five times, alternating | Four exports: each instance counts its own two. |

The second row shows what a session id is: it's encrypted, and an instance serves only an id it can decrypt. Give both instances the same `MCP_SESSION_SECRET` and 4002 reads the id, but it still has no such session, so it answers `404` `session not initialized`. Keeping the session where 4002 can find it is Redis's job, [below](#sharing-sessions-and-limits-through-redis).

The third row is the surprising one. Between questions, a 2026-07-28 client carries the earlier answers in a `requestState` the server signed, and each instance made up its own signing key. Nothing is stored, so Redis doesn't help; a shared secret does ([`elicit()`](https://frontmcp.dev/reference/sdk/elicit) says which one signs it). With the same `VAULT_SECRET` on both instances, the same client gets `{"id":"T-1","assigned":"Priya"}`. Without it, the rejected state is logged with the fix:

```text
WARN mcp-20260728: rejected requestState {
  method: 'tools/call',
  reason: 'bad-signature',
  hint: 'requestState is signed with a per-process key; set VAULT_SECRET (or JWT_SECRET) to the same value on every instance so a round can land on any of them'
}
```

So a 2026-07-28 client needs every instance to run the same build with the same secrets. An older client also needs its session to be found wherever its request lands.

### The secrets every instance shares

| Variable | Every instance needs the same one when | Otherwise |
| --- | --- | --- |
| `MCP_SESSION_SECRET` | Clients before MCP 2026-07-28 connect. Production requires it anyway ([previous lesson](https://frontmcp.dev/learn/deploying-your-server#the-secrets-it-needs)). | It encrypts their session ids. An instance answers `404` `invalid session id` to an id made under another secret ([High availability](https://frontmcp.dev/reference/deployment/high-availability#caveats)). |
| `VAULT_SECRET`, or `JWT_SECRET` | A tool asks the user more than one question. | The first question is asked again, as above. |
| `JWT_SECRET` | The server uses `local` or `remote` auth. | Tokens issued by one instance fail on another with `signature verification failed`. |

Generate each once, keep it in your platform's secret store, and give every instance the same value. [Auth in production](https://frontmcp.dev/reference/auth/production#running-several-instances) lists the rest of what authenticated servers share.

A production server that shares state through `redis`, or through a `transport.persistence` object, and has neither `VAULT_SECRET` nor `JWT_SECRET`, warns once as it starts:

```text
WARN requestState is signed with a per-process key (neither VAULT_SECRET nor JWT_SECRET is set). Multi-round tools (elicit/sample) restart when a round lands on another instance; set VAULT_SECRET (or JWT_SECRET) to the same value on every instance.
```

A server without either option gets no warning at startup, and the `hint` above is the only sign, when a round lands on the wrong instance. A blank value counts as unset.

> **Note**
In 1.8.5, a public server served a session id that another instance had made under a different `MCP_SESSION_SECRET`. Now the id has to decrypt under this instance's secret: any other id gets `404` `invalid session id`, and the log says `mcp-session-id is not a session this server verified`. So when you rotate `MCP_SESSION_SECRET`, each client that still holds an old id gets that `404` once, and starts a new session with `initialize`. Clients on MCP 2026-07-28 have no session id and don't notice.

## Sharing sessions and limits through Redis

`@FrontMcp({ redis })` moves the state FrontMCP keeps between requests into Redis: older clients' sessions, background tasks, and questions waiting for an answer. Rate-limit counts have an option of their own, `throttle.storage`. Read the address from the environment, so the same build runs with or without Redis:

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

const url = process.env.REDIS_URL; // like redis://redis:6379, or rediss://default:…@cache.example.com:6380

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
  elicitation: { enabled: true },
  ...(url ? { redis: { url }, throttle: { enabled: true, storage: { type: "redis", redis: { url } } } } : {}),
})
export default class Server {}
```

Both options take the URL: `redis` as `{ url }`, and `throttle.storage` under its `redis` key. (`redis` also takes `host`, `port`, `password`, `db` and `tls` as fields.)

Docker Compose can run the whole setup on one machine: Redis, two instances of the image from [the last lesson](https://frontmcp.dev/learn/deploying-your-server#running-it-in-a-container), and nginx in front of them, sending requests to each in turn:

```yaml ci/compose.yml
services:
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 5s
      retries: 3
  desk:
    build:
      context: ..
      dockerfile: ci/Dockerfile
    init: true
    deploy:
      replicas: 2
    environment:
      MCP_SESSION_SECRET: ${MCP_SESSION_SECRET:?set MCP_SESSION_SECRET}
      VAULT_SECRET: ${VAULT_SECRET:?set VAULT_SECRET}
      REDIS_URL: redis://redis:6379
    depends_on:
      redis:
        condition: service_healthy
  lb:
    image: nginx:1.27-alpine
    ports:
      - "127.0.0.1:3000:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - desk
```

```text title="ci/nginx.conf"
upstream desk {
  server desk:3000;
}

server {
  listen 80;

  location / {
    proxy_pass http://desk;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 1h;
  }
}
```

```bash
export MCP_SESSION_SECRET="$(openssl rand -hex 32)" VAULT_SECRET="$(openssl rand -hex 32)"
docker compose -f ci/compose.yml up -d --build
```

Each instance logs that it found Redis:

```text
[TransportService] redis session store will be initialized for transport persistence
[TransportService] Session store connection validated successfully
```

Now every client in the table above is served through `http://127.0.0.1:3000/mcp`. The older client's `initialize` and its next four calls all succeed, though nginx sent some of them to the other instance, which logs `Recreating transport from stored session` as it picks the session up from Redis. `assign_ticket` finishes with `{"id":"T-1","assigned":"Priya"}`, and the third export in the hour is refused, whichever instance gets it. Redis holds a key for each:

```bash
docker compose -f ci/compose.yml exec redis redis-cli --scan
```

```text
mcp:session:<session id>
mcp:guard:export_tickets:global:rl:<window>
```

`/readyz` now pings Redis too, so a load balancer that checks it stops sending traffic to an instance that can't reach it. [Redis](https://frontmcp.dev/reference/deployment/redis) has every option, `tls` for managed Redis among them, and [High availability](https://frontmcp.dev/reference/deployment/high-availability) the rest of what instances share.

> **Pitfall: REDIS_URL or REDIS_HOST alone doesn't share sessions**
The `ci/docker-compose.yml` that `frontmcp create` writes sets `REDIS_HOST=redis` for the app, and the `main.ts` it writes doesn't read it. FrontMCP picks up `REDIS_HOST`, or `REDIS_URL`, for background tasks and pending questions, not for sessions or rate limits (`Created task store { type: 'redis', … }`), so the log looks as if Redis is in use. Behind the load balancer above, with that `main.ts`, the older client's calls fail whenever they reach the other instance, with `404` `session not initialized`, and four exports get through where two should. Read the variable into `redis` and `throttle.storage`, as the `main.ts` above does.

### What else lives in one instance's memory

| State | Shared through |
| --- | --- |
| Sessions of clients before MCP 2026-07-28 | `redis` |
| Background tasks, and questions waiting for an answer | `redis` |
| Rate-limit and concurrency counts | `throttle.storage` ([Guard options](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix)) |
| Plugin stores, like the [Cache plugin's](https://frontmcp.dev/reference/plugins/cache#stores) | The plugin's `type: "global-store"`, which uses `redis` |
| Job runs | `jobs.store` ([Where runs are kept](https://frontmcp.dev/learn/running-jobs-in-the-background#where-runs-are-kept)) |
| Sign-ins and refresh tokens of `local` and `remote` auth | `auth.tokenStorage` ([Local auth](https://frontmcp.dev/reference/auth/local#storage)) |
| Your own state, like the notes | Your database, or Redis, given to every instance |

FrontMCP checks `redis` as the server is built, and refuses some of these at startup when they're wrong, which a test can catch before you deploy. The Playground can't reach Redis, but it can build the server:

```ts config.test.ts active
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, redisOptionsSchema } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetTicket, HelpDesk } from "./help-desk.app";

const info = { name: "help-desk", version: "1.0.0" };

test("`redis` takes the URL a managed Redis hands you", () => {
  expect(redisOptionsSchema.parse({ url: "rediss://default:s3cr3t@cache.example.com:6380/2" })).toMatchObject({
    host: "cache.example.com",
    port: 6380,
    password: "s3cr3t",
    db: 2,
    tls: true,
  });
});

test("a `redis` URL that names another user is refused", async () => {
  const server = FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], redis: { url: "rediss://support:s3cr3t@cache.example.com:6380" } });
  await expect(server).rejects.toThrow("only the default user is supported");
});

test("🚩 a plugin's global store needs `redis` on the server", async () => {
  @App({ id: "cached", name: "Cached", tools: [GetTicket], plugins: [CachePlugin.init({ type: "global-store" })] })
  class Cached {}
  await expect(FrontMcpInstance.createFetchHandler({ info, apps: [Cached] })).rejects.toThrow(
    'Plugin "CachePlugin" requires global "redis" configuration.',
  );
});
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

`redis` takes a `redis://` or `rediss://` URL, the form managed Redis services hand you, and splits it into `host`, `port`, `password`, `db` and `tls`. A URL that names a user other than `default` is refused: the error says `redis.url names the ACL user "support"; only the default user is supported`, and a deployed server exits with it at startup. The last test is about plugins: one whose store is `"global-store"` uses the server's `redis`, and a server without it doesn't start.

> **Note**
In 1.8.7, `redis: { url }` failed with a `ZodError` saying `host` was missing, and a deployed server exited with it, so a managed Redis's URL had to be split into fields by hand.

> **Note**
An instance whose `redis` can't be reached when it starts still starts, keeps sessions in its memory, logs `Failed to connect to redis - session persistence disabled`, and answers `/readyz` with `503`. FrontMCP keeps trying, with a growing delay, and when Redis answers it logs `Session store recovered after startup failure`: the instance uses Redis from then on, and `/readyz` is `200` again, with no restart. A Redis that goes away while the instance runs does the same: sessions stay in memory, `/readyz` answers `503`, and the log has one `[RedisStorage] Redis connection error` line every 30 seconds or so.

Rate limits are stricter, because they're a security control: in production, an instance whose `throttle.storage` can't reach its Redis doesn't start. It exits with:

```text
GuardStorageUnavailableError: throttle.storage (redis) is unavailable: Failed to connect to Redis: connect ECONNREFUSED 127.0.0.1:6379. Rate limits fail closed, so the server will not start without it. Set throttle.storage.fallback: 'memory' to start with per-instance counters instead.
```

If Redis goes away while the instance runs, a call to a tool with a limit fails with the tool error `GUARD_STORAGE_UNAVAILABLE`, and works again when Redis does; tools without a limit aren't affected. To start anyway, and to keep serving meanwhile, with each instance counting its own limits, add `fallback: "memory"` next to `type`: two instances then let through four exports an hour, not two, until Redis answers, and share one count again after that. Point the load balancer's readiness check at `/readyz` either way, so an instance without Redis gets no traffic. [When Redis can't be reached](https://frontmcp.dev/reference/deployment/redis#when-redis-cant-be-reached) has the details, and [the guard reference](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable) shows the errors in Playgrounds.

**Deep dive: What distributed mode adds**
The `node` build above is all several instances need: any of them loads an older client's session from Redis. `frontmcp build --target distributed` (or `FRONTMCP_DEPLOYMENT_MODE=distributed`) does it the other way round: an instance keeps serving the sessions it made, and another instance that gets one of their requests relays it to that instance over Redis and streams the answer back. Each instance writes a heartbeat to Redis. One stopped with `SIGTERM` deletes its heartbeat as it shuts down, so whichever instance gets its sessions' next requests takes them over at once; one that crashed has them taken over once its heartbeat expires, 30 seconds after it stopped, and until then those requests answer `503` with `Retry-After: 30`. Every answer names the instance that served it in an `X-FrontMCP-Machine-Id` header, for load balancers that route by it. [High availability](https://frontmcp.dev/reference/deployment/high-availability#distributed-mode) has the details.

Changed in 1.9: in 1.8.7 a request that carried a session another instance had made answered `500` (`RemoteTransporter.handleRequest() is not implemented`), so in distributed mode an older client had to reach the instance that made its session.

Vercel and Lambda functions are several instances by themselves: each can run many copies at once, and none keeps anything between invocations. They need the same things: shared secrets, and for older clients' sessions a store every copy reaches, like a Redis in the function's network or, on Vercel, [Vercel KV](https://frontmcp.dev/reference/deployment/vercel#storage), which can't serve background tasks and questions waiting for an answer: FrontMCP skips tasks with a warning, and refuses `elicitation` unless it has a Redis of its own.

**Deep dive: What about sticky sessions?**
A load balancer can pin each client to one instance, usually with a cookie, so an older client's session is always where its requests go. That avoids Redis for sessions, but an instance that stops or restarts takes its sessions with it, and the rest of the state in the table above still isn't shared. Redis doesn't make pinning useless either: a client that holds an event stream open, like one on the older SSE transport, holds it with one instance, and only that instance can write to it. Give such clients affinity at the load balancer.

## Recap

- Each instance has its own memory: its providers, and FrontMCP's own state. A request that lands on another instance doesn't see it.
- Clients on MCP 2026-07-28 carry everything in each request, so they need every instance to run the same build with the same secrets. Older clients also need their session found, through `redis` or a load balancer that pins them.
- Give every instance the same `MCP_SESSION_SECRET`, the same `VAULT_SECRET` for tools that ask more than one question, and the same `JWT_SECRET` for `local` and `remote` auth.
- `redis` shares sessions, tasks and pending questions, `throttle.storage` shares rate limits, and your own state belongs in a database or Redis that every instance is given. Both options take a `redis://` URL.
- `REDIS_URL` or `REDIS_HOST` alone moves tasks and pending questions, but not sessions or rate limits: read it into `redis` and `throttle.storage`.
- A Redis that's down at startup or goes away later is retried: the instance keeps working in memory, and rate-limited tools fail with `GUARD_STORAGE_UNAVAILABLE` unless `throttle.storage` has `fallback: "memory"`.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Give every instance the same on-call list
`set_on_call` records which agent is on call, and `who_is_on_call` tells the model. The checks start two instances with `createServer(db)`, handing both the same `db`, and `createServer` ignores it, so each instance keeps its own list. Make every instance keep the on-call agent in the `db` it's given.

```ts config.ts active
import { HelpDesk } from "./on-call.tools";

export function createServer(db: Map<string, string>) {
  return {
    info: { name: "help-desk", version: "1.0.0" },
    apps: [HelpDesk],
  };
}
```

```ts config.ts solution
import { HelpDesk } from "./on-call.tools";
import { OnCallStore } from "./on-call-store";

export function createServer(db: Map<string, string>) {
  return {
    info: { name: "help-desk", version: "1.0.0" },
    apps: [HelpDesk],
    providers: [{ provide: OnCallStore, name: "OnCallStore", useValue: new OnCallStore(db) }],
  };
}
```

```ts on-call-store.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "OnCallStore" })
export class OnCallStore {
  private db = new Map<string, string>();

  set(agent: string) {
    this.db.set("on-call", agent);
  }

  get() {
    return this.db.get("on-call") ?? "nobody";
  }
}
```

```ts on-call-store.ts solution
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "OnCallStore" })
export class OnCallStore {
  constructor(private readonly db: Map<string, string>) {}

  set(agent: string) {
    this.db.set("on-call", agent);
  }

  get() {
    return this.db.get("on-call") ?? "nobody";
  }
}
```

```ts on-call.tools.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { OnCallStore } from "./on-call-store";

@Tool({
  name: "set_on_call",
  description: "Record which support agent is on call.",
  inputSchema: { agent: z.enum(["Nour", "Sam", "Priya"]) },
})
export class SetOnCall extends ToolContext {
  async execute({ agent }: { agent: string }) {
    this.get(OnCallStore).set(agent);
    return { onCall: agent };
  }
}

@Tool({ name: "who_is_on_call", description: "Which support agent is on call right now.", inputSchema: {} })
export class WhoIsOnCall extends ToolContext {
  async execute() {
    return { onCall: this.get(OnCallStore).get() };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SetOnCall, WhoIsOnCall], providers: [OnCallStore] })
export class HelpDesk {}
```

```ts on-call.tools.ts solution
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { OnCallStore } from "./on-call-store";

@Tool({
  name: "set_on_call",
  description: "Record which support agent is on call.",
  inputSchema: { agent: z.enum(["Nour", "Sam", "Priya"]) },
})
export class SetOnCall extends ToolContext {
  async execute({ agent }: { agent: string }) {
    this.get(OnCallStore).set(agent);
    return { onCall: agent };
  }
}

@Tool({ name: "who_is_on_call", description: "Which support agent is on call right now.", inputSchema: {} })
export class WhoIsOnCall extends ToolContext {
  async execute() {
    return { onCall: this.get(OnCallStore).get() };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SetOnCall, WhoIsOnCall] })
export class HelpDesk {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { createServer } from "./config";

// The server the Call tab talks to
@FrontMcp(createServer(new Map()))
export default class Server {}
```

```ts on-call.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { createServer } from "./config";

function call(name: string, args: Record<string, unknown>) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

async function send(instance: (request: Request) => Promise<Response>, name: string, args: Record<string, unknown> = {}) {
  return (await (await instance(call(name, args))).json()).result.structuredContent;
}

test("an agent set on one instance is on call on the other", async () => {
  const db = new Map<string, string>();
  const a = await FrontMcpInstance.createFetchHandler(createServer(db));
  const b = await FrontMcpInstance.createFetchHandler(createServer(db));
  await send(a, "set_on_call", { agent: "Priya" });
  expect(await send(b, "who_is_on_call")).toEqual({ onCall: "Priya" });
});

test("a change on the second instance reaches the first", async () => {
  const db = new Map<string, string>();
  const a = await FrontMcpInstance.createFetchHandler(createServer(db));
  const b = await FrontMcpInstance.createFetchHandler(createServer(db));
  await send(a, "set_on_call", { agent: "Nour" });
  await send(b, "set_on_call", { agent: "Sam" });
  expect(await send(a, "who_is_on_call")).toEqual({ onCall: "Sam" });
});

test("the agent is kept in the `db` the server is given", async () => {
  const db = new Map<string, string>();
  const a = await FrontMcpInstance.createFetchHandler(createServer(db));
  await send(a, "set_on_call", { agent: "Nour" });
  expect([...db.values()]).toEqual(["Nour"]);
});
```

**Hint:**
The second example in [Each instance has its own memory](#each-instance-has-its-own-memory) does the same for notes. Three files change, and one of the changes is a removal.

**Solution:**
`OnCallStore` now keeps the agent in the `db` it's constructed with, and `createServer` hands every server a store built on the `db` it was given, with `useValue`. The app's own `providers: [OnCallStore]` had to go: an app's provider comes before the server's, so each instance kept building a store of its own. On a real server, `db` would be a connection to the same database or Redis in every process.

### Challenge: Page through tickets on any instance
`list_tickets` returns open tickets two at a time, and remembers in the server's memory where the last page ended. Behind a load balancer, the next page comes from whichever instance answers. Make each answer include a `nextCursor` (a string, or `null` on the last page) and make the tool take an optional `cursor`, so that the next page works on any instance.

```ts list-tickets.tool.ts active
import { App, Provider, Tool, ToolContext } from "@frontmcp/sdk";
import { tickets } from "./tickets";

// 🚩 Where the last page ended, in this instance's memory
@Provider({ name: "Paging" })
export class Paging {
  next = 0;
}

@Tool({
  name: "list_tickets",
  description: "List open tickets, two at a time. Call it again for the next two.",
  inputSchema: {},
})
export class ListTickets extends ToolContext {
  async execute() {
    const paging = this.get(Paging);
    const page = tickets.slice(paging.next, paging.next + 2);
    paging.next += 2;
    return { tickets: page };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], providers: [Paging] })
export class HelpDesk {}
```

```ts list-tickets.tool.ts solution
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";

@Tool({
  name: "list_tickets",
  description: "List open tickets, two at a time. For the next two, call it again with the nextCursor of the last answer.",
  inputSchema: { cursor: z.string().optional().describe("The nextCursor of the previous answer") },
})
export class ListTickets extends ToolContext {
  async execute({ cursor }: { cursor?: string }) {
    const start = cursor ? Number(cursor) : 0;
    const next = start + 2;
    return { tickets: tickets.slice(start, next), nextCursor: next < tickets.length ? String(next) : null };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ListTickets] })
export class HelpDesk {}
```

```ts tickets.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-3", title: "Login link expired" },
  { id: "T-4", title: "Export is empty" },
  { id: "T-6", title: "Wrong time zone in emails" },
  { id: "T-7", title: "Invoice PDF won't open" },
];
```

```ts config.ts
import { HelpDesk } from "./list-tickets.tool";

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };
```

```ts paging.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

function list(args: Record<string, unknown>) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "list_tickets" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "list_tickets", arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

async function page(instance: (request: Request) => Promise<Response>, args: Record<string, unknown>) {
  return (await (await instance(list(args))).json()).result.structuredContent;
}

const ids = (answer: { tickets: { id: string }[] }) => answer.tickets.map((t) => t.id);

test("the first page has two tickets and a `nextCursor`", async () => {
  const a = await FrontMcpInstance.createFetchHandler(config);
  const first = await page(a, {});
  expect(ids(first)).toEqual(["T-1", "T-3"]);
  expect(typeof first.nextCursor).toBe("string");
});

test("the `nextCursor` from one instance gets the next page from another", async () => {
  const a = await FrontMcpInstance.createFetchHandler(config);
  const b = await FrontMcpInstance.createFetchHandler(config);
  const second = await page(b, { cursor: (await page(a, {})).nextCursor });
  expect(ids(second)).toEqual(["T-4", "T-6"]);
});

test("the last page has `nextCursor: null`", async () => {
  const a = await FrontMcpInstance.createFetchHandler(config);
  const b = await FrontMcpInstance.createFetchHandler(config);
  const second = await page(b, { cursor: (await page(a, {})).nextCursor });
  const last = await page(a, { cursor: second.nextCursor });
  expect(ids(last)).toEqual(["T-7"]);
  expect(last.nextCursor).toBeNull();
});

test("a call without a cursor always starts at the first page", async ({ mcp }) => {
  await mcp.tools.call("list_tickets", {});
  expect(ids((await mcp.tools.call("list_tickets", {})).json())).toEqual(["T-1", "T-3"]);
});
```

**Hint:**
The instance can't remember where the page ended, so the model has to. What could each answer give it to send back?

**Solution:**
The position moves out of the server and into the conversation: each answer says where the next page starts, and the model sends that back. No instance has to remember anything, so the next page works wherever the request lands, and the `Paging` provider can go. The description says how to get the next page, so the model knows to pass the cursor. MCP's own lists, like `tools/list`, page the same way.

### Challenge: Count the exports in Redis too
The help desk reads `REDIS_URL` into `redis`, so older clients' sessions are shared, but each instance still counts `export_tickets` on its own: behind two instances, four exports an hour get through, not two. Change `createConfig()` so that, with `REDIS_URL` set, rate limits are counted in that Redis too. Without `REDIS_URL`, the server must still start without Redis.

```ts config.ts active
import { HelpDesk } from "./help-desk.app";

export function createConfig(env: Record<string, string | undefined>) {
  const url = env.REDIS_URL;
  return {
    info: { name: "help-desk", version: "1.0.0" },
    apps: [HelpDesk],
    ...(url ? { redis: { url } } : {}),
  };
}
```

```ts config.ts solution
import { HelpDesk } from "./help-desk.app";

export function createConfig(env: Record<string, string | undefined>) {
  const url = env.REDIS_URL;
  return {
    info: { name: "help-desk", version: "1.0.0" },
    apps: [HelpDesk],
    ...(url ? { redis: { url }, throttle: { enabled: true, storage: { type: "redis" as const, redis: { url } } } } : {}),
  };
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV. At most 2 exports an hour.",
  inputSchema: {},
  rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title,status\nT-1,Cannot log in,open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] })
export class HelpDesk {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { createConfig } from "./config";

// The server the Call tab talks to: no REDIS_URL here
@FrontMcp(createConfig({}))
export default class Server {}
```

```ts config.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, redisOptionsSchema } from "@frontmcp/sdk";
import { createConfig } from "./config";

const url = "rediss://default:s3cr3t@cache.example.com:6380";

function exportTickets() {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "export_tickets" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "export_tickets", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

test("with `REDIS_URL`, sessions still go to that Redis", () => {
  const config = createConfig({ REDIS_URL: url }) as { redis?: unknown };
  expect(redisOptionsSchema.parse(config.redis)).toMatchObject({ host: "cache.example.com", port: 6380, tls: true });
});

test("with `REDIS_URL`, rate limits are counted in that Redis", () => {
  const config = createConfig({ REDIS_URL: url }) as { throttle?: { enabled?: boolean; storage?: unknown } };
  expect(config.throttle?.enabled).not.toBe(false);
  expect(config.throttle?.storage).toEqual({ type: "redis", redis: { url } });
});

test("without `REDIS_URL`, the server starts with no Redis and still limits exports", async () => {
  const config = createConfig({}) as { redis?: unknown; throttle?: { storage?: unknown } };
  expect(config.redis).toBeUndefined();
  expect(config.throttle?.storage).toBeUndefined();
  const server = await FrontMcpInstance.createFetchHandler(createConfig({}));
  const codes = [];
  for (let i = 0; i < 3; i++) codes.push((await (await server(exportTickets())).json()).result._meta?.code ?? "ok");
  expect(codes).toEqual(["ok", "ok", "RATE_LIMIT_EXCEEDED"]);
});
```

**Hint:**
Rate-limit counts have an option of their own in `@FrontMcp`, under `throttle`. It takes the same URL as `redis`, in a slightly different place.

**Solution:**
`throttle.storage` is where the guard keeps its counts, and `{ type: "redis", redis: { url } }` puts them in the Redis that `redis` uses, so every instance adds to the same count. `throttle` needs `enabled: true` once it's set. Without `REDIS_URL` neither option is there, and the guard counts in memory, which is what one instance, or a test, wants.
