Auth in production

A server that behaves in development can be open, or broken, once it's deployed. This page is the checklist for an authenticated FrontMCP server: the secrets production requires, who can reach the server, how strictly tokens are checked, what NODE_ENV=production hides from callers and what it still tells them, rate limits, running several instances, and the places where FrontMCP 1.9.3 leaves a server more open than its settings suggest. Each item says what FrontMCP does, and links to the page that covers it in full.

NODE_ENV=production
MCP_SESSION_SECRET=…        # every server with clients before MCP 2026-07-28; openssl rand -hex 32
JWT_SECRET=…                # local and remote modes; at least 32 bytes
VAULT_SECRET=…              # optional; the same on every instance
FRONTMCP_PUBLIC_URL=https://desk.example.com
main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", requireAudience: true },
  authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } },
  http: {
    cors: { origin: ["https://app.example.com"] },
    security: { dnsRebindingProtection: { enabled: true, allowedHosts: ["desk.example.com"], allowedOrigins: ["https://app.example.com"] } },
  },
  throttle: { enabled: true, global: { maxRequests: 600, windowMs: 60_000, partitionBy: "ip" } },
})
export default class Server {}

Reference

The checklist

CheckWhy
1Run with NODE_ENV=productionInternal error messages and stacks stop reaching callers, and FrontMCP stops making up secrets.
2Set MCP_SESSION_SECRET, and JWT_SECRET in local and remote modesWithout them a production server refuses older clients' sessions with 500, or doesn't start.
3Give every instance the same secretsTokens, sessions and questions to the user must verify on any instance.
4Serve over HTTPS, and set FRONTMCP_PUBLIC_URLFrontMCP serves plain HTTP, and builds its URLs, including the audience it expects, from the request.
5Allow only your hosts and originsBlocks DNS rebinding and requests meant for another site.
6List CORS originsorigin: true lets every web page read responses, with credentials.
7Check the token's audience strictlyA token without aud, meant for another service, is accepted by default.
8Rate-limit requestsNothing slows down key guessing by default.
9Know what production still tells callersSome messages describe your rules and your runtime, in production too.
10Work around the 1.9.3 gapsA few behaviours leave a server more open than you'd expect, with no error.

Production mode

FrontMCP is in production when the environment variable NODE_ENV is production. Then:

  • A tool's plain Error, an internal error, a resource or prompt that throws, and a guard that throws reach the caller as Internal FrontMCP error. Please contact support with error ID: err_…. The log has the message and stack, next to the same ID.
  • No _meta.stack is sent.
  • FrontMCP refuses to make up the secrets it would otherwise derive from the machine or generate per process: see Secrets.
  • local mode turns off dynamic client registration: POST /oauth/register answers 404 {"error":"access_denied","error_description":"Dynamic Client Registration is disabled."}, and the server's metadata has no registration_endpoint. Since unknown clients are refused by default (requireRegisteredClients), only the clients in dcr.clients and CIMD clients can then sign in, unless you set dcr.enabled.
  • /readyz leaves out each probe's details.

The Playground always runs in development. What this page says about production was checked in Node with NODE_ENV=production, and Seeing what production sends formats errors the way production does.

Secrets

VariableUsed forWithout it, in production
MCP_SESSION_SECRETEncrypting the session ids of clients before MCP 2026-07-28.Their initialize gets 500 with the code SESSION_SECRET_REQUIRED, whoever they are. Clients on MCP 2026-07-28 have no session and are served, and /healthz still answers 200, so a health check won't notice. Set it on every server that older clients may reach. (Changed in 1.8.5: before, anonymous and static-key callers on 2026-07-28 got the 500 too.)
JWT_SECRETSigning the tokens FrontMCP issues in local and remote modes (HS256). At least 32 bytes. local.signKey and local.jwks don't replace it: they're accepted and not used yet.The server doesn't start: createFetchHandler() and the Node server reject with JwtSecretRequiredError, or JwtSecretWeakError for a shorter one. On an edge isolate, every request, /healthz included, gets 500 JWT_SECRET_REQUIRED or JWT_SECRET_INVALID.
VAULT_SECRETSigning the requestState of a 2026-07-28 call that asks the user something. Falls back to JWT_SECRET.No error: FrontMCP signs with a random key per process. That's fine for one instance; see several instances. Since 1.8.6, a production server that sets redis, or transport.persistence as an object, logs a warning at startup, once: 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.

FrontMCP's Node server and createFetchHandler() both answer with a JSON body that names the code and the fix, {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}, and the Node server logs the code too. (Before 1.8.7 the Node server answered a plain Internal Server Error.) Production secrets shows the answer, and Configuration files lists every secret FrontMCP reads. Generate each with openssl rand -hex 32, and keep them in your platform's secret store, not in the image or the repository. Give each server its own JWT_SECRET anyway. The tokens FrontMCP issues name their server in iss and aud, so since 1.8.3 another server that shares the secret refuses them (Tokens), but whoever holds the secret can sign any token they like for any of those servers.

Rotating secrets and keys

WhatEffect of changing itHow to rotate
MCP_SESSION_SECRETExisting session ids stop decrypting, and a request that carries one gets 404 invalid session id. The client starts a new session with initialize, as MCP clients do on a 404. Clients on MCP 2026-07-28 have no session and notice nothing.Change it and restart. Sessions in memory end with the restart anyway; with transport.persistence on Redis, the new secret is what ends them. (Changed in 1.8.6: before, a server with Redis served an id minted under another secret.)
JWT_SECRETEvery token FrontMCP issued fails with 401 error_description="signature verification failed", so every user signs in again. It takes one key; there's no list of old ones.Change it at a quiet time, and expect the sign-ins.
VAULT_SECRETA tool that's in the middle of asking the user several questions asks the first one again.Change it and restart.
Static keysA client with the old key is refused.Deploy with both keys in tokens, move clients over, then remove the old one.
Your identity provider's signing key (transparent)FrontMCP caches the provider's keys for 6 hours, and fetches them again when a token names a kid they don't have, at most once a minute. A key the provider publishes is accepted on its first token. (Changed in 1.9.3: before, tokens signed with a new key failed until the cache expired.)Publish the new key before the provider signs with it, as providers do. With keys in providerConfig.jwks, deploy the new one first. See Rotating signing keys.

HTTPS and the public URL

FrontMCP's Node server speaks plain HTTP. Terminate TLS in front of it: a load balancer, a reverse proxy, or your platform's edge. Then tell FrontMCP its public address:

FRONTMCP_PUBLIC_URL=https://desk.example.com

FrontMCP builds the URLs it hands out from the request: the resource_metadata link in a 401, its OAuth metadata and, without expectedAudience, the audience it expects, in transparent mode and for the tokens it issues itself in local and remote mode. Behind a proxy that terminates TLS, the request says http://, so without this variable FrontMCP sends clients to http://desk.example.com/.well-known/oauth-protected-resource, and refuses tokens issued for https://desk.example.com with Token audience does not match expected audiences. Got: https://desk.example.com. Expected one of: http://desk.example.com. It ignores X-Forwarded-Proto unless FRONTMCP_TRUST_PROXY is set, and that variable also makes it trust X-Forwarded-For for the client's address. Set expectedAudience as well, so the audience doesn't depend on the URL.

FrontMCP adds X-Content-Type-Options: nosniff and X-Frame-Options: DENY to every response and removes X-Powered-By. It sends no Strict-Transport-Security or Content-Security-Policy until you ask: set them with http.securityHeaders, or the response-header variables, or at the proxy. The sign-in and consent pages send their own (Custom login UI), and every response of /oauth/token and /oauth/register, errors included, has Cache-Control: no-store and Pragma: no-cache (changed in 1.9.3: before, they had neither).

Hosts, origins and CORS

FrontMCP's Node server listens on 127.0.0.1 unless you set http.security.bindAddress or FRONTMCP_BIND_ADDRESS (http). Once it listens on a public address, check which hosts and pages requests are meant for:

http: {
  security: {
    dnsRebindingProtection: { enabled: true, allowedHosts: ["desk.example.com"], allowedOrigins: ["https://app.example.com"] },
  },
}
RequestAnswer
Host not in allowedHosts403 {"error":"Forbidden","message":"Invalid Host header"}
X-Forwarded-Host not in allowedHosts403 …"Invalid X-Forwarded-Host header"
Origin not in allowedOrigins403 …"Invalid Origin header"
No Origin headerAllowed. MCP clients that aren't browsers don't send one.

The lists apply without an enabled key; enabled: false switches the check off, and the lists with it. http.security.strict: true doesn't add a host list of its own under createFetchHandler(), so list your hosts. The environment variable FRONTMCP_ALLOWED_HOSTS sets allowedHosts for the Node server (Environment variables). A browser won't let a script set Origin, so the Origin check was verified in Node; Allowing only your hosts shows the host checks.

CORS decides which web pages may read the server's responses, and is off unless you set http.cors (CORS and host checks). List the origins of your web clients. origin: true reflects every origin back, and with credentials: true it also sends Access-Control-Allow-Credentials: true, so any web page can make credentialed requests and read the answers. CORS isn't access control either: anything that isn't a browser ignores it.

Token checks

In transparent mode, FrontMCP checks a token's signature, that its issuer is provider (or one of providerConfig.additionalIssuers), its expiry, and its audience. What's checked has every rule; these are the ones to tighten for production:

TokenBy defaultTo refuse it
No aud claimAccepted, even with expectedAudience set. A token your provider issued for another service is accepted if it has no audience.requireAudience: true: 401 error_description="Token is missing audience claim".
An aud that isn't expectedAudience401, Token audience does not match expected audiences. Got: … Expected one of: …Already refused. Set expectedAudience; without it the expected audience is the request's URL.
No exp claimRefused since 1.8.3, with 401 missing required "exp" claim (before 1.8.4, the generic no_provider_verified (kid=…)).Already refused. Have your provider always set exp.
No sub, client_id or azp claimRefused since 1.9.3, with 401 Token has no subject: the sub, client_id and azp claims are all missing. Before, its caller looked anonymous.Already refused. Have your provider always set sub.
Missing a scope in requiredScopes403 error="insufficient_scope". Anonymous callers (allowAnonymous) aren't checked.Already refused.

Keep verifyIssuer at its default. false accepts tokens from any issuer whose keys FrontMCP has.

In local and remote mode, FrontMCP checks the tokens it issued itself: signature, expiry, its own iss, and an aud that is the server's URL, or one of expectedAudience. Local auth has the details.

Rate limits

Unless you configure it, FrontMCP limits nothing. throttle.global limits every request the server gets. With partitionBy: "global" or "ip" it's counted before authentication, so requests with a wrong key count too: that's what slows down someone guessing keys. With "global", one guesser also locks out everyone else, so partition by "ip", behind a proxy with FRONTMCP_TRUST_PROXY set so the address is the client's. Under "userId", all anonymous callers share one count.

The discovery documents under /.well-known/ and the OAuth endpoints, /oauth/authorize, /oauth/token and the rest, count toward throttle.global too, so the same limit slows down someone guessing at the token endpoint. (Changed in 1.9.2: before, they never ran the throttle.) publicAccess.rateLimit limits anonymous tool and prompt calls separately, even with the throttle off.

The counts live in memory unless throttle.storage points at Redis, { type: "redis", redis: { url } }, so each instance counts on its own. See storage.

Rate limits fail closed, since they're a security control. When that Redis can't be reached at startup, the server doesn't start: createFetchHandler() and the Node server reject with a GuardStorageUnavailableError that names throttle.storage and the reason. throttle.storage.fallback: "memory" starts the server anyway, with each instance counting on its own, and logs [storage] Warning: Failed to connect to redis, falling back to memory. (Changed in 1.8.6: the error was StorageConnectionError: Failed to connect to Redis: Reached the max retries per request limit, which names neither the setting nor the fix, and the failed client kept logging [ioredis] Unhandled error event.) A Redis that stops answering while the server runs gets the same choice: calls that need a check are refused with a 503 GuardStorageUnavailableError, or, with fallback: "memory", counted per instance until it's back: see the guard's page.

Running several instances

Shared stateWhat to doIf you don't
MCP_SESSION_SECRETThe same value everywhere.Clients on MCP 2026-07-28 are fine, since they have no session. An older client's session id from one instance doesn't decrypt on another, and gets 404 invalid session id.
JWT_SECRET (local, remote)The same value everywhere.A token issued by one instance fails on another with signature verification failed.
VAULT_SECRET, or JWT_SECRETThe same value everywhere.A tool that asks the user two questions, answered on different instances, asks the first one again. The mismatch is only logged, as rejected requestState { reason: 'bad-signature' }, and since 1.8.6 the log also has a hint that names VAULT_SECRET.
Sign-in state in local and remote modestokenStorage: { redis: … }.Pending sign-ins, codes and refresh tokens are in memory: a sign-in that starts on one instance and finishes on another fails with Authorization request has expired, and after a restart no client can refresh, so users sign in again when their access token expires. See Local auth.
Sessions of clients before MCP 2026-07-28transport.persistence with Redis, or sticky sessions.A session lives on the instance that created it; elsewhere the client gets 404 session not initialized and must start again.
Rate-limit countsthrottle.storage with Redis.Each instance allows the full limit.
Client metadata documents (CIMD)cimd.cache: { type: "redis", redis }.Each instance fetches and caches each client's document on its own, so a changed document can be in use on one instance and not yet on another.

Clients on MCP 2026-07-28 keep no sessions, and each of their requests is authenticated on its own, so they need only the secrets. Redis storage needs a real Redis, so it isn't shown on this site; Tokens and sessions and transport list the options. MACHINE_ID is only used outside production, in place of MCP_SESSION_SECRET, so sharing it between instances changes nothing in production.

Only the rate limits fail closed. A production server whose redis option can't be reached still starts, keeps sessions and pending questions in memory, and logs [storage] Warning: Failed to connect to redis, falling back to memory. once (before 1.8.6 the failed client also logged [ioredis] Unhandled error event over and over), so an instance that started without Redis answers 404 session not initialized to a session another instance opened. FrontMCP tries the session store again, after a second and then at doubling intervals up to 30 seconds, and logs Session store recovered after startup failure when Redis answers. Until then /readyz is 503 not_ready, with the session-store check unhealthy, so a load balancer that uses it keeps traffic away. (Before 1.8.7 a Redis that was down at startup stayed unused until the server restarted.)

What production hides, and what it doesn't

Hidden in productionStill sent word for word
A tool's plain Error or InternalMcpError (TOOL_EXECUTION_ERROR, SERVER_ERROR, INTERNAL_ERROR)A PublicMcpError and its subclasses, as intended
A resource or prompt that throws (RESOURCE_READ_ERROR, …)An authorities refusal, which names the roles or claims that would have let the caller in
A guard that throws, in a call or in tools/listENTRY_UNAVAILABLE, which lists the server's OS, runtime, deployment, provider and environment
@frontmcp/auth's internal errorsA 401's WWW-Authenticate description, which names the audience the server expects
INVALID_OUTPUT detailsRate-limit, concurrency and timeout errors
Stacks, in _meta.stackTool "…" not found, and other errors about the request

None of the right-hand column is a secret by itself, but together it describes your rules and your deployment to anyone who can call the server. Error classes marks what each class keeps.

Gaps to work around in 1.9.3

Each of these behaves differently from what its settings suggest, with no error:

GapWhat happensWhat to do
local mode's built-in sign-in pageAsks for an email and checks nothing: anyone can sign in as anyone.Check credentials in your own authenticate or sign-in page, or use remote or transparent. Local auth
this.context.sessionId and this.auth.sessionIdBoth are the same new anon:… id on every 2026-07-28 request, signed-in callers included.Identify callers by this.auth.user.sub.
local.signKey, local.jwks, and public mode's signKey and jwksAccepted and not used: tokens are HS256, signed with JWT_SECRET.Set JWT_SECRET, the same on every instance. Secrets

FrontMCP 1.8.3 closed the other gaps this table listed. @Agent({ authorities }), rules on resource templates, rules that check nothing and the skills HTTP endpoints are enforced, or stop the server (Authorities). local and remote mode refuse unknown clients by default, grant only the scopes in allowedScopes, tie each sign-in to the browser that started it and each token to its server, and a sign-in declined at the provider ends without a token (Local auth, Remote auth). A transparent token without exp is refused.

FrontMCP 1.8.4 closed more. An authorities profile name the server doesn't know stops it at startup, and createFetchHandler() rejects a misconfigured server when it's called instead of on its first request. Under createFetchHandler(), the tokens local and remote mode issue name the address the request came to, and FRONTMCP_PUBLIC_URL sets their iss (Local auth). A request on MCP 2026-07-28 is never in a session, so this.secureStore with scope: "session" follows the signed-in user instead of an id the caller sends. Check the servers you upgrade for what these changes now refuse.

FrontMCP 1.8.5 closed more. Every entry point, FrontMCP's Node server included, derives the tokens' iss the same way, from local.issuer, FRONTMCP_PUBLIC_URL or the request's address, and the discovery documents and every redirect to the client, errors included, name it. A client on MCP 2026-07-28 gets no session, so a server without MCP_SESSION_SECRET serves its anonymous and static-key callers.

FrontMCP 1.8.6 closed more. A client before MCP 2026-07-28 can use a session only with an id this server minted for this caller. An id minted under another MCP_SESSION_SECRET, a session that another token or another static key opened, and a made-up id all get 404 invalid session id, and the client sends initialize again. Before, a server with Redis served an id minted under another secret, and a static server let a second key carry on the first key's session. Two problems now say what's wrong: a production server that signs requestState with a per-process key while it shares state warns at startup, and a throttle.storage on a Redis that can't be reached stops the server with an error that names it (Secrets, Rate limits).

FrontMCP 1.8.7 closed more. this.auth.scopes and this.auth.hasScope() have the token's scopes for clients before MCP 2026-07-28 too, where they were empty and only this.context.authInfo.scopes had them, so a tool that checks scopes works for every client. A missing MCP_SESSION_SECRET is the structured server_misconfigured answer on FrontMCP's Node server, as on every other entry point. Every response carries X-Content-Type-Options: nosniff and X-Frame-Options: DENY, and createFetchHandler() refuses a body over http.bodyLimit with 413 and serves health, which it used to ignore.

FrontMCP 1.9.0 closed more. authorities.pipes run, so fields they add to this.auth are there for a tool to check (Authorities), and an auth.ui sign-in page builds in a project that uses ES modules, where it used to fall back to the built-in page (Custom login UI).

FrontMCP 1.9.2 closed more. publicAccess limits what anonymous callers may use, and how often (Auth modes), and a public server gives its callers its anonymousScopes. An app with its own auth on the shared endpoint of a public or static server stops the server from starting, where the server used to serve the app's tools to anyone it let in (Auth modes). The OAuth endpoints and discovery documents count toward throttle.global, and throttle.ipFilter.trustProxy is read. this.fetch() sends no empty Authorization: Bearer for an anonymous caller, env. paths in authorities rules read the server's environment, claimsResolver changes this.auth as well as rules, and a token without sub names its client. A server that relied on any of the old behaviour may now refuse a call, or not start: check it after the upgrade.

FrontMCP 1.9.3 closed more. A valid token that names no caller, with no sub, client_id or azp, is refused rather than served as an anonymous caller, and /oauth/token and /oauth/register answer with Cache-Control: no-store (Token checks). A transparent server fetches its provider's keys again for a kid it doesn't know, so a rotated key works at once. A public or static server no longer points clients at a made-up authorization server, and its anonymous tokens last sessionTtl, an hour by default, instead of a day (Auth modes). cimd.enabled: false refuses metadata URLs, and cimd.cache.type: "redis" shares the cache (Client ID metadata). A remote provider's token is renewed with its refresh token, and survives the client refreshing FrontMCP's (Remote auth). Check that your clients get a new anonymous token when theirs expires.

Two things need nothing from you: the caller's token and x-frontmcp-* headers are sent nowhere unless you list the origin in fetch.forwardCallerTokenTo, and every request has its own this.context, so concurrent callers don't see each other's identity.


Usage

Checking tokens strictly

This server accepts tokens from auth.example.com for https://desk.example.com. The tests send three tokens for the same user: one issued for another service, one without an audience, and one without an expiry. The Playground's own client has no token, so the server also lets anonymous callers in:

Open
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { JWKS } from "./tokens";

@Tool({ name: "whoami", description: "Say who the server thinks is calling.", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { sub: this.auth.user.sub };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    providerConfig: { jwks: JWKS },
  },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The refusal is an HTTP 401 with the reason in WWW-Authenticate, before any tool runs; in production too, and it tells the caller which audience the server expects.

Allowing only your hosts

allowedHosts checks the host a request was sent to, and the host a proxy says it was sent to:

Open
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server answers.", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: {
    security: {
      dnsRebindingProtection: { allowedHosts: ["desk.example.com", "playground.local"], allowedOrigins: ["https://app.example.com"] },
    },
  },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

playground.local is the host the Playground's own client uses. A real server lists only its public host names, with the port if clients use one other than 80 or 443.

Response headers and body size

Every response carries X-Content-Type-Options: nosniff and X-Frame-Options: DENY, with no X-Powered-By. http.securityHeaders turns one off with false or adds the ones FrontMCP leaves to you, and http.bodyLimit caps what a request may send:

Open
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server answers.", inputSchema: { note: z.string().optional() } })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: {
    bodyLimit: "1kb",
    securityHeaders: {
      hsts: "max-age=63072000; includeSubDomains",
      frameOptions: false as const,
      csp: { enabled: true, directives: { "default-src": ["'none'"] } },
    },
  },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A frontmcp.config can set the headers per deployment, and FRONTMCP_HSTS and its siblings set them from the environment: Configuration files lists them.

Seeing what production sends

Errors are formatted for production by the same code whatever threw them. The test takes three errors a server really throws, from an in-process server, and formats them with createErrorHandler({ isDevelopment: false }), which is what NODE_ENV=production means to tools/call:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM.", inputSchema: { id: z.string() } })
export class SyncTicket extends ToolContext {
  async execute({ id }: { id: string }): Promise<{ synced: string }> {
    throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`);
  }
}

@Tool({ name: "purge_tickets", description: "Delete closed tickets. Admins only.", inputSchema: {}, authorities: "admin" })
export class PurgeTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

@Tool({ name: "deno_report", description: "Build a report. Only on Deno.", inputSchema: {}, availableWhen: { runtime: ["deno"] } })
export class DenoReport extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The Call tab shows the development answer, with the database's address in it. Fail with a PublicMcpError for what the model should read. The other two go out as they are. A tool refused by authorities isn't in the caller's list, so its refusal mostly reaches someone probing the server by name; where the rule itself is sensitive, check in execute() with a message of your own. Where the server's runtime is private, leave a tool out of the server rather than hiding it with availableWhen.

Slowing down key guessing

throttle.global counts every request before authentication, including the ones refused for a wrong key, and the requests for the OAuth endpoints and discovery documents:

Open
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server answers.", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  // The Playground's client has no key, so the key is required only in the tests.
  throttle: { enabled: true, global: { maxRequests: 2, windowMs: 60_000 } },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

On a real server, partition by "ip", so one guesser doesn't lock everyone out. A request whose address FrontMCP doesn't know, like every request in the Playground, counts under one shared key, "ip:unresolved", which is why this example uses "global".


Troubleshooting

{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}

The server runs with NODE_ENV=production and no MCP_SESSION_SECRET, and a client before MCP 2026-07-28 sent initialize. Set one on every instance, the same everywhere. FrontMCP's Node server answers the same JSON, and logs SESSION_SECRET_REQUIRED. If older clients fail while clients on MCP 2026-07-28 work, it's this.

JwtSecretRequiredError, or JWT_SECRET_REQUIRED or JWT_SECRET_INVALID on every path

A local or remote server in production needs JWT_SECRET, at least 32 bytes. Until then createFetchHandler() and the Node server reject, and an edge isolate answers every request, /healthz included, with 500.

Everyone has to sign in again after a deploy

JWT_SECRET changed, or differs between instances: tokens fail with 401 error_description="signature verification failed". Keep it the same across deploys and instances. In development, where no JWT_SECRET is set, FrontMCP makes one up per process, so every restart does this. The upgrade to 1.8.3 does it once for local and remote servers: tokens issued before have no aud and fail with Token audience does not match this resource, so clients refresh them or sign in again (Tokens). The upgrade to 1.8.4 does it once for a local or remote server without local.issuer that sets FRONTMCP_PUBLIC_URL or runs on createFetchHandler(), and the upgrade to 1.8.5 for one on FrontMCP's Node server reached at another address than localhost:<port>, or with FRONTMCP_PUBLIC_HOST or expectedAudience set: its tokens' iss changed, so older ones fail with unexpected "iss" claim value.

Token audience does not match expected audiences. Got: https://… Expected one of: http://…

The server is behind a proxy that terminates TLS, has no expectedAudience, and doesn't know its public URL, so it expects an http:// audience. Set expectedAudience, and FRONTMCP_PUBLIC_URL for the URLs it hands out. See HTTPS and the public URL.

{"error":"Forbidden","message":"Invalid Host header"}

The request's Host isn't in dnsRebindingProtection.allowedHosts (or FRONTMCP_ALLOWED_HOSTS). Hosts are compared with their port, apart from 80 and 443, so a server clients reach at desk.example.com:8443 lists desk.example.com:8443. A health check that uses the instance's own address, like 10.0.3.7:3000, is refused too, /healthz included: list that address, or have the check send the public host name. Invalid X-Forwarded-Host header and Invalid Origin header are the same for the proxy's header and a browser's Origin.

Every caller gets 429 Rate limit exceeded

throttle.global with partitionBy: "global" shares one count between every caller, and wrong keys count too. Partition by "ip", and set FRONTMCP_TRUST_PROXY behind a proxy so each client has its own address.

A question to the user is asked again

The tool asks more than one question, and the answers reached instances with different VAULT_SECRETs (or JWT_SECRETs, or none, so each made up its own). The log has rejected requestState { reason: 'bad-signature' }, and, when the key is per-process, a hint that names VAULT_SECRET. A production server that sets redis, or transport.persistence as an object, without one of the secrets also warns at startup. Give every instance the same secret.

404 with invalid session id

A client before MCP 2026-07-28 sent an Mcp-Session-Id that this server can't verify for this caller: it was minted under another MCP_SESSION_SECRET (the secret was rotated, or the instances differ), the client's token isn't the one the session started with, or it isn't a session id at all. The client has to send initialize again, which MCP clients do on a 404. An id that does verify but that this instance has no session for, because sessions are in memory and another instance minted it, gets session not initialized instead. See Sessions.

throttle.storage (redis) is unavailable

The server rejects at startup with a GuardStorageUnavailableError, because throttle.storage points at a Redis that couldn't be reached. Fix the address, or set throttle.storage.fallback: "memory" to start with per-instance counts. See Rate limits.

App-level auth is not enforced on the shared endpoint

An app has its own auth, on the shared endpoint of a public or static server, which can't check it, so the server doesn't start. Give the app its own endpoint, or put the auth on @FrontMcp: see Protecting one app's tools.