Auth modes

auth.mode decides who may connect to a FrontMCP server. FrontMCP checks it on every request to the MCP endpoint, before any tool, resource or prompt runs. There are five modes: public (the default) lets everyone in, static checks a shared key, transparent checks JWTs your identity provider signed, and local and remote make FrontMCP an OAuth authorization server that signs users in. This page explains the modes (the Authentication overview maps the whole section): what each mode is for, what it advertises, what a request without credentials gets back, and who your code sees as the caller.

@FrontMcp({
  info, apps,
  auth: { mode: "public" | "static" | "transparent" | "local" | "remote", ...options },
})

Reference

auth

Set auth on @FrontMcp. It covers every app on the server's MCP endpoint. Without it, the server is public.

main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "transparent",
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
  },
})
export default class Server {}

See more examples below.

Modes

modeWho gets inWho issues the credentialWho your code seesOptions
"public"Everyone. The default.None is neededanon: and a new id on every request, with the scopes in anonymousScopes, by default anonymouspublic options
"static"Requests that send one of your keysYoustatic: and 12 hex characters, one id per keyStatic keys
"transparent"Requests with a valid JWT from your identity providerYour identity providerThe token's sub, scopes and claimsTokens and sessions
"local"Users who sign in on FrontMCP's own pageFrontMCPThe user who signed inLocal auth
"remote"Users who sign in with an upstream provider, through FrontMCPFrontMCP, after the upstream sign-inThe user who signed inRemote and proxied auth

Each mode takes its own options, listed on the page in the last column. Options that belong to another mode, and misspelled ones, are dropped without an error: see Caveats.

Choosing a mode

You needUse
A server on your laptop, or one that only serves public datapublic
To let in only clients you configure yourself, like an internal agent or a host's "API key" settingstatic
To know which person is calling, when your users already sign in with a provider that issues JWTs (Auth0, Okta, Keycloak, Entra ID)transparent
A sign-in and an identity per user, with no identity provider behind themlocal
A provider that MCP clients can't register with by themselves, or FrontMCP's consent screen between users and your toolsremote
Signed-in users to get more, and everyone else a littletransparent with allowAnonymous, plus a check per tool with this.auth or authorities

Start with the simplest mode that tells your tools what they need to know. A shared key tells a tool "one of ours"; only a token tells it which person.

The rest of this section

PageWhat it covers
Tokens and sessionsHow tokens and keys are checked, what your code gets from them, sessions, expiry, and every refusal a client can get
Local authFrontMCP's own sign-in page and OAuth endpoints
Custom login UIChanging or replacing the pages users sign in on
Progressive authLetting users authorize one app at a time
Remote and proxied authSigning users in with an upstream provider, through FrontMCP
Client ID metadata (CIMD)Clients that identify themselves with a URL instead of registering
AuthoritiesDeclaring who may call each tool, resource and prompt
Auth in productionSecrets, the public address, and what to check before you deploy

What a request without credentials gets

Every request to the MCP endpoint is checked: tools/call, and also server/discover and tools/list, so a client that can't authenticate can't even see your tools. /healthz, /readyz and the /.well-known/* documents answer without credentials.

ModeNo credentialsCredentials that don't pass
publicLet in, as an anonymous callerA JWT that FrontMCP didn't sign: 401 (why). Any other Authorization header is ignored.
static401, WWW-Authenticate: Bearer realm="mcp"401, WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid"
transparent401, WWW-Authenticate: Bearer resource_metadata="…", or let in as anonymous with allowAnonymous401 with error="invalid_token" and the reason, or 403 with error="insufficient_scope". See the errors a client sees.
local, remote401, WWW-Authenticate: Bearer resource_metadata="…"401 with error="invalid_token"

The body is {"error":"Unauthorized"}, or {"error":"Forbidden"} with a 403. It's an HTTP response, not a JSON-RPC error or a tool result: the client sees it, the model never does, and none of your code runs. resource_metadata is what lets a client that has never seen your server start a sign-in: it fetches that document and learns where to get a token. static mode doesn't send it, because there's no way for a client to get a key by itself.

What the server advertises

The server answers these without credentials, in every mode:

Pathpublic, statictransparentlocal, remote
/.well-known/oauth-protected-resourceThe document belowThe document belowThe document below
/.well-known/oauth-authorization-server404: these modes have no authorization server302 to your provider's /.well-known/oauth-authorization-serverFrontMCP's own authorization server metadata: /oauth/authorize, /oauth/token, /oauth/userinfo, PKCE with S256
/.well-known/jwks.jsonA public RSA key FrontMCP generatesThe keys in providerConfig.jwks, when you set itA public RSA key FrontMCP generates

Generating that RSA key needs Node, so the Playground only shows the transparent case; the others were checked in Node.

The protected resource metadata (RFC 9728), here for a transparent server with scopes: ["tickets:read"], is:

{
  "resource": "https://desk.example.com",
  "authorization_servers": ["https://desk.example.com"],
  "scopes_supported": ["tickets:read"],
  "bearer_methods_supported": ["header"]
}

It names the server itself as the authorization server, in transparent mode too: a client that follows it asks your server for /.well-known/oauth-authorization-server, and is redirected to your provider's. A public or static server has no authorization server, so its document leaves authorization_servers out. (Changed in 1.9.3: before, they named the server too, and its /.well-known/oauth-authorization-server redirected to a leftover http://localhost:<port>.) scopes_supported lists what the mode grants, leaving out entries with a *:

Modescopes_supported
publicanonymousScopes, by default ["anonymous"]
staticscopes, by default ["static"]
transparentrequiredScopes, then scopes. With neither, the field is left out.
local, remoteallowedScopes, by default ["openid", "profile", "email", "offline_access"], as their authorization server metadata does

Changed in 1.8.5: before, public, static and transparent mode always listed ["email", "openid", "profile"]. Only local and remote serve the OAuth endpoints themselves: see Local auth. A public server also answers grant_type=anonymous at /oauth/token, for anonymous tokens.

With http.entryPath set to /mcp, the challenge points at /.well-known/oauth-protected-resource/mcp, which is served along with /mcp/.well-known/oauth-protected-resource, and resource ends in /mcp. /.well-known/oauth-protected-resource itself is then a 404.

The server's public address

FrontMCP builds the URLs in the challenge and in these documents from the request. Under createFetchHandler(), that's the URL the request was sent to, scheme included, as the Playgrounds on this page show. FrontMCP's Node server uses http:// and the Host header: it doesn't use the scheme the client connected with. So behind a proxy that terminates TLS, clients are sent to http:// and whatever host the proxy forwarded, and in transparent mode without expectedAudience, tokens issued for your real address fail the audience check. In local and remote mode, the same address is the aud of the tokens FrontMCP issues, and what they're checked against: see Local auth.

Set the FRONTMCP_PUBLIC_URL environment variable to the address clients use, like https://desk.example.com, or set FRONTMCP_TRUST_PROXY=1 so FrontMCP reads X-Forwarded-Proto and X-Forwarded-Host. A browser won't let a script set Host, so the Node server's side of this was checked in Node. See environment variables and Auth in production.

Who your code sees

publicstatictransparent, with a tokentransparent, anonymous
this.auth.user.subanon: and a new id per requeststatic: and 12 hex charactersThe token's subanon: and a new id per request
this.auth.isAnonymoustruefalsefalsetrue
this.auth.scopesanonymousScopes, ["anonymous"] by defaultThe mode's scopes, ["static"] by defaultThe token's scope, split on spaces, or its scpanonymousScopes

In local and remote mode, the caller is the user who signed in, read from the token FrontMCP issued them: see Local auth. this.auth lists every field, and Tokens and sessions what comes from a token. this.auth.mode doesn't name the mode: in FrontMCP 1.9.3 it's "authenticated" in every mode, except "public" for a caller with an anonymous token, so tell callers apart with isAnonymous.

Anonymous access

ModeRequests without credentialsOptions
publicAlways let in, with the scopes in anonymousScopesSee public options.
staticAlways refusedNone
transparentRefused, unless allowAnonymous is trueallowAnonymous (default false) and anonymousScopes (default ["anonymous"])
local, remoteAlways refusedallowDefaultPublic (default false) doesn't change that. It lets a client ask /oauth/token for an anonymous token.
  • With allowAnonymous, a request with no Authorization header, or one that isn't Bearer (like Basic …), is anonymous. A bearer token that fails is still refused: an expired token doesn't quietly become an anonymous caller.
  • requiredScopes applies only to tokens. Anonymous callers get anonymousScopes, whatever requiredScopes asks for.
  • Under MCP 2026-07-28, each anonymous request is a new caller. Nothing can be kept per anonymous user, and rate limits count them all together.
  • With allowDefaultPublic, POST /oauth/token with grant_type=anonymous and a client_id returns an anonymous token, valid for a day: its sub is anon: and a random id, its scope is anonymousScopes, and it has the claim anonymous: true. Without the option, that grant answers 400 unsupported_grant_type. A public server answers it too, with no option, and its tokens last sessionTtl, an hour by default. A static or transparent server doesn't answer it.
  • A caller with an anonymous token is anonymous: isAnonymous is true and scopes is anonymousScopes, as a test in Seeing what each mode answers shows. Unlike a caller without a token, it keeps the same sub for as long as the token lasts. (Before 1.8.3, the token had a plain random sub and no scopes, and its holder had isAnonymous: false, so it looked signed in.)

public options

OptionTypeDefaultWhat it does
issuerstringsee descriptionThe iss of the anonymous tokens a public server issues, without a trailing slash: one is dropped. Without it, the tokens name FRONTMCP_PUBLIC_URL, or else the address the request came to.
sessionTtlnumber, seconds3600 (an hour)How long an anonymous token lasts: its exp, and expires_in in the token response. A whole number above 0, or the server doesn't start. Changed in 1.9.3: before, it was never read and the tokens lasted a day.
anonymousScopesstring[]["anonymous"]The scopes of every caller, with or without an anonymous token.
publicAccess{ tools?, prompts?, rateLimit? }noneWhat anonymous callers may use: see publicAccess.
jwks, signKeyAccepted and not used yet: the tokens are signed with JWT_SECRET, as in local mode.

Changed in 1.9.2: before, callers without a token had the scope public, whatever anonymousScopes said, and only anonymous tokens got anonymousScopes. A tool that checks hasScope("public") now refuses them.

publicAccess

Every mode accepts publicAccess, which limits what anonymous callers may use. Signed-in callers, and callers with a static key, aren't affected. It doesn't let anyone in either: a static server still refuses a request without a key, and a transparent server one without a token unless allowAnonymous is set.

OptionTypeDefaultWhat it does
tools"all" or string[]"all"The tools an anonymous caller may use, by name or by full name (help-desk:search_tickets). The others are left out of their tools/list, and a call to one gets the tool error PUBLIC_ACCESS_DENIED: The tool "help-desk:close_ticket" is not available to anonymous callers.
prompts"all" or string[]"all"The same for prompts. A refused prompts/get gets the JSON-RPC error -32003.
rateLimitnumber60Anonymous tool and prompt calls a minute, per IP address. A tool call over the limit gets the tool error RATE_LIMIT_EXCEEDED, with how many seconds to wait. It applies once publicAccess is set, even with throttle turned off.

Limiting what anonymous callers can use shows each rule. (Changed in 1.9.2: before, publicAccess was accepted and never read.)

Auth for one app

@App({ auth }) takes the same options as @FrontMcp({ auth }), but only applies where the app has an endpoint of its own: with standalone: true on the app, or splitByApp: true on the server. Endpoints covers both.

On the server's main endpoint, only @FrontMcp({ auth }) checks callers. Where that would leave an app's own auth unchecked, the server doesn't start, rather than serve the app's tools without it:

Server's authAn app on the main endpoint with its own auth
None, or publicDoesn't start: App-level auth is not enforced on the shared endpoint of a server in public mode, so the tools of billing would be served without it.
staticThe same, in static mode, unless the app is static too, with the same header and scheme, and lists every one of the server's tokens.
transparentDoesn't start: Parent uses transparent mode but apps have their own auth providers.
local, remoteStarts. A call is checked against the apps the caller authorized only with incrementalAuth.

Protecting one app's tools shows the first error, and the fixes.

Caveats

  • Unknown and misspelled options are dropped without an error. allowAnonymus: true leaves anonymous callers refused; allowAnonymous in any mode but transparent does nothing. Check the spelling of anything that seems to have no effect.
  • A bad auth stops the server. An unknown mode, or a mode without what it requires (tokens for static, provider for transparent and remote), fails when the server starts, and createFetchHandler() rejects when it's called. (Changed in 1.8.4: before, createFetchHandler() returned a handler whose first request failed.)
  • Every request is authenticated on its own. MCP 2026-07-28 has no sessions. Older clients keep one, but they send their credentials, and FrontMCP checks them, on every request. See Sessions.
  • In-process calls aren't authenticated. With create() and connect(), the calling code says who the user is: see what each entry point fills in.
  • The Playground's client never sends credentials. The examples on these pages run a server that lets anonymous callers in, and their tests build a server of their own to send credentials to.

Usage

Seeing what each mode answers

The Playground's server is public, so the call runs as an anonymous caller. The tests start a server in each of the other modes and call it without credentials, then with some.

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

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Every refusal comes before the tool runs. The last two tests surprise people. A public server issues anonymous tokens to anyone who asks, though their holders stay anonymous. And it treats every JWT as one it should have issued itself, so it refuses a request with a JWT it can't verify: see Every request to a public server gets 401.

Letting anonymous callers in with less

transparent with allowAnonymous lets requests without a token in, with the scopes in anonymousScopes, and still checks every token it's given. Here anonymous callers can search tickets, and closing one needs tickets:write, which only a signed-in agent's token carries:

Open
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, SearchTickets } from "./tickets.tools";
import { JWKS } from "./keys";

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] })
export 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",
    requiredScopes: ["tickets:read"],
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
    providerConfig: { jwks: JWKS },
  },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

requiredScopes: ["tickets:read"] applies to tokens only: kim's token, without that scope, gets a 403, while anonymous callers get in with anonymousScopes. Checking the scope in the tool, as close_ticket does, is what keeps them from closing tickets. A public server, which accepts no provider's tokens, gives its callers anonymousScopes in the same way, as the last test shows.

Limiting what anonymous callers can use

publicAccess names the tools and prompts anonymous callers may use, and how often. Here they may search tickets, three times a minute, and nothing else; a signed-in agent may use every tool. The Playground's caller is anonymous, so its call to close_ticket is refused, and the Capabilities tab lists only search_tickets:

Open
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, SearchTickets } from "./tickets.tools";
import { JWKS } from "./keys";

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] })
export 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,
    publicAccess: { tools: ["search_tickets"], rateLimit: 3 },
    providerConfig: { jwks: JWKS },
  },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A refused call is an ordinary tool error, which the model reads like any other. In public mode every caller is anonymous, so there publicAccess applies to everyone: a tool it doesn't list can't be called over HTTP at all.

Reading the discovery documents

A client that gets a 401 with resource_metadata fetches that document, then the authorization server metadata it names. These are plain GET requests, so the tests send them straight to a handler:

Open
import { test, expect } from "@frontmcp/testing";
import { get } from "./get";
import { JWKS } from "./keys";

const transparent = { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", providerConfig: { jwks: JWKS } };

test("the protected resource metadata names the server as its authorization server", async () => {
  for (const auth of [transparent, { mode: "local" }]) {
    const metadata = await (await get(auth, "/.well-known/oauth-protected-resource")).json();
    expect(metadata).toMatchObject({ resource: "https://desk.example.com", authorization_servers: ["https://desk.example.com"], bearer_methods_supported: ["header"] });
  }
});

test("public and static: no authorization server to name", async () => {
  for (const auth of [{ mode: "public" }, { mode: "static", tokens: ["desk-key-1"] }]) {
    const metadata = await (await get(auth, "/.well-known/oauth-protected-resource")).json();
    expect(metadata).toMatchObject({ resource: "https://desk.example.com", bearer_methods_supported: ["header"] });
    expect(metadata).not.toHaveProperty("authorization_servers");
  }
});

test("scopes_supported lists what the mode grants", async () => {
  const scopesOf = async (auth: object) => (await (await get(auth, "/.well-known/oauth-protected-resource")).json()).scopes_supported;
  expect(await scopesOf({ mode: "public" })).toEqual(["anonymous"]);
  expect(await scopesOf({ mode: "public", anonymousScopes: ["tickets:read"] })).toEqual(["tickets:read"]);
  expect(await scopesOf({ mode: "static", tokens: ["desk-key-1"] })).toEqual(["static"]);
  expect(await scopesOf(transparent)).toBeUndefined();
  expect(await scopesOf({ ...transparent, requiredScopes: ["tickets:read"], scopes: ["tickets:write"] })).toEqual(["tickets:read", "tickets:write"]);
  expect(await scopesOf({ mode: "local", allowedScopes: ["openid", "tickets:*"] })).toEqual(["openid"]);
});

test("transparent: the authorization server metadata redirects to the provider's", async () => {
  const response = await get(transparent, "/.well-known/oauth-authorization-server");
  expect(response.status).toBe(302);
  expect(response.headers.get("location")).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
  const withSlash = await get({ ...transparent, provider: "https://auth.example.com/" }, "/.well-known/oauth-authorization-server");
  expect(withSlash.headers.get("location")).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
});

test("public and static: no authorization server metadata", async () => {
  for (const auth of [{ mode: "public" }, { mode: "public", issuer: "https://login.example.com" }, { mode: "static", tokens: ["desk-key-1"] }]) {
    expect((await get(auth, "/.well-known/oauth-authorization-server")).status).toBe(404);
  }
});

test("with http.entryPath, the resource metadata moves under it", async () => {
  const http = { entryPath: "/mcp" };
  expect((await get(transparent, "/.well-known/oauth-protected-resource/mcp", http)).status).toBe(200);
  expect((await get(transparent, "/mcp/.well-known/oauth-protected-resource", http)).status).toBe(200);
  expect((await get(transparent, "/.well-known/oauth-protected-resource", http)).status).toBe(404);
  const metadata = await (await get(transparent, "/.well-known/oauth-protected-resource/mcp", http)).json();
  expect(metadata.resource).toMatch(/\/mcp$/);
});

test("transparent: jwks.json serves the keys you configured", async () => {
  const jwks = await (await get(transparent, "/.well-known/jwks.json")).json();
  expect(jwks.keys.map((k: { kid: string }) => k.kid)).toEqual(["desk-2"]);
});

test("local: FrontMCP is the authorization server", async () => {
  const response = await get({ mode: "local" }, "/.well-known/oauth-authorization-server");
  expect(response.status).toBe(200);
  expect(await response.json()).toMatchObject({
    authorization_endpoint: expect.stringMatching(/\/oauth\/authorize$/),
    token_endpoint: expect.stringMatching(/\/oauth\/token$/),
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256"],
  });
});

test("there's no OpenID configuration document", async () => {
  expect((await get(transparent, "/.well-known/openid-configuration")).status).toBe(404);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Under createFetchHandler(), resource and the URLs FrontMCP builds start with the address the request was sent to, here https://desk.example.com. On the Node server they start with http:// and the request's Host, unless you pin the public address.

Protecting one app's tools

An app's own auth only applies on an endpoint of its own. Here billing has a static key and standalone: true, so it's served at /billing, behind its key, while the help desk's tools stay public at /. The Playground's client talks to /, so it sees search_tickets only:

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

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } })
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true, by: this.auth.user.sub };
  }
}

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

// ✅ Its own endpoint, where its auth applies
@App({ id: "billing", name: "Billing", tools: [RefundInvoice], standalone: true, auth: { mode: "static", tokens: ["billing-key"] } })
export class BillingApp {}

@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] })
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Three ways to protect the app:

  • Give the app its own endpoint with standalone: true, as here, or give every app one with splitByApp: true, where its auth applies. Clients then connect to /billing for it. Giving an app its own endpoint shows both, and which entry points serve them.
  • Put the auth on @FrontMcp, as the last test does. It then covers every app on the endpoint.
  • Keep one endpoint and one auth, and decide per tool who may call it, with this.auth or authorities.

Changed in 1.9.2: before, a server with an app's own auth on its shared endpoint started, and served the app's tools to anyone the server let in.


Troubleshooting

Every request to a public server gets 401

WWW-Authenticate: Bearer resource_metadata="…", error="invalid_token", error_description="\"alg\" (Algorithm) Header Parameter value not allowed"

The client sends a JWT, for example one it was configured with for another server. A public server doesn't ignore JWTs: it verifies them as tokens it issued itself, signed with JWT_SECRET, and refuses the ones it can't verify. Tokens that aren't JWTs, and headers that aren't Bearer, are ignored. Remove the header from the client's configuration, or, if the token comes from your identity provider, use transparent mode so FrontMCP checks it against the provider's keys.

The server doesn't start: Invalid input: expected "public"

The error is a ZodError with invalid_union, listing what each mode expected. auth doesn't match any mode: mode is misspelled (there's no "oauth" or "jwt" mode), or a required option is missing: tokens for static (with at least one key: tokens: [] fails with Too small: expected array to have >=1 items), provider for transparent and remote.

App-level auth is not enforced on the shared endpoint

An app has an auth of its own, but it's on the server's main endpoint, where a public or static server can't check it, so the server doesn't start. Give the app its own endpoint, or move the auth to @FrontMcp: see Protecting one app's tools. A transparent server says Parent uses transparent mode but apps have their own auth providers instead, for the same reason.

hasScope("public") is false for anonymous callers

Callers without a token get the server's anonymousScopes, which are ["anonymous"] unless you set them, in public mode as in transparent mode. Check for the scope you set, or for isAnonymous. (Before 1.9.2, a public server gave them ["public"].)

An anonymous caller gets PUBLIC_ACCESS_DENIED

The server sets publicAccess, and its tools (or prompts) don't name the one called. Add it, by name or full name, or have the caller sign in. RATE_LIMIT_EXCEEDED for anonymous callers can come from its rateLimit, 60 calls a minute unless you set it.

The client gets 401 but never starts a sign-in

Check the WWW-Authenticate header:

  • No resource_metadata: the server is in static mode, and the client has to be configured with a key.
  • resource_metadata with http:// or an internal host name: the server builds its address from the request. Set FRONTMCP_PUBLIC_URL, or FRONTMCP_TRUST_PROXY behind a proxy you control: see The server's public address.
  • The client finds no token endpoint: in transparent mode, /.well-known/oauth-authorization-server on your server redirects to the same path on your provider. If your provider doesn't serve that path, the client has nowhere to go. remote mode serves the metadata from FrontMCP itself.

The Playground can't call a server that requires credentials

The Playground's client never sends credentials, so a server that requires them refuses everything with 401, including the tools/list the Playground needs to start, and the example fails. Let anonymous callers in for the example (transparent with allowAnonymous), and send tokens from a test, as the examples on this page do.