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.
@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 {}Modes
mode | Who gets in | Who issues the credential | Who your code sees | Options |
|---|---|---|---|---|
"public" | Everyone. The default. | None is needed | anon: and a new id on every request, with the scopes in anonymousScopes, by default anonymous | public options |
"static" | Requests that send one of your keys | You | static: and 12 hex characters, one id per key | Static keys |
"transparent" | Requests with a valid JWT from your identity provider | Your identity provider | The token's sub, scopes and claims | Tokens and sessions |
"local" | Users who sign in on FrontMCP's own page | FrontMCP | The user who signed in | Local auth |
"remote" | Users who sign in with an upstream provider, through FrontMCP | FrontMCP, after the upstream sign-in | The user who signed in | Remote 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 need | Use |
|---|---|
| A server on your laptop, or one that only serves public data | public |
| To let in only clients you configure yourself, like an internal agent or a host's "API key" setting | static |
| 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 them | local |
| A provider that MCP clients can't register with by themselves, or FrontMCP's consent screen between users and your tools | remote |
| Signed-in users to get more, and everyone else a little | transparent 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
| Page | What it covers |
|---|---|
| Tokens and sessions | How tokens and keys are checked, what your code gets from them, sessions, expiry, and every refusal a client can get |
| Local auth | FrontMCP's own sign-in page and OAuth endpoints |
| Custom login UI | Changing or replacing the pages users sign in on |
| Progressive auth | Letting users authorize one app at a time |
| Remote and proxied auth | Signing users in with an upstream provider, through FrontMCP |
| Client ID metadata (CIMD) | Clients that identify themselves with a URL instead of registering |
| Authorities | Declaring who may call each tool, resource and prompt |
| Auth in production | Secrets, 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.
| Mode | No credentials | Credentials that don't pass |
|---|---|---|
public | Let in, as an anonymous caller | A JWT that FrontMCP didn't sign: 401 (why). Any other Authorization header is ignored. |
static | 401, WWW-Authenticate: Bearer realm="mcp" | 401, WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid" |
transparent | 401, WWW-Authenticate: Bearer resource_metadata="…", or let in as anonymous with allowAnonymous | 401 with error="invalid_token" and the reason, or 403 with error="insufficient_scope". See the errors a client sees. |
local, remote | 401, 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:
| Path | public, static | transparent | local, remote |
|---|---|---|---|
/.well-known/oauth-protected-resource | The document below | The document below | The document below |
/.well-known/oauth-authorization-server | 404: these modes have no authorization server | 302 to your provider's /.well-known/oauth-authorization-server | FrontMCP's own authorization server metadata: /oauth/authorize, /oauth/token, /oauth/userinfo, PKCE with S256 |
/.well-known/jwks.json | A public RSA key FrontMCP generates | The keys in providerConfig.jwks, when you set it | A 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 *:
| Mode | scopes_supported |
|---|---|
public | anonymousScopes, by default ["anonymous"] |
static | scopes, by default ["static"] |
transparent | requiredScopes, then scopes. With neither, the field is left out. |
local, remote | allowedScopes, 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
public | static | transparent, with a token | transparent, anonymous | |
|---|---|---|---|---|
this.auth.user.sub | anon: and a new id per request | static: and 12 hex characters | The token's sub | anon: and a new id per request |
this.auth.isAnonymous | true | false | false | true |
this.auth.scopes | anonymousScopes, ["anonymous"] by default | The mode's scopes, ["static"] by default | The token's scope, split on spaces, or its scp | anonymousScopes |
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
| Mode | Requests without credentials | Options |
|---|---|---|
public | Always let in, with the scopes in anonymousScopes | See public options. |
static | Always refused | None |
transparent | Refused, unless allowAnonymous is true | allowAnonymous (default false) and anonymousScopes (default ["anonymous"]) |
local, remote | Always refused | allowDefaultPublic (default false) doesn't change that. It lets a client ask /oauth/token for an anonymous token. |
- With
allowAnonymous, a request with noAuthorizationheader, or one that isn'tBearer(likeBasic …), is anonymous. A bearer token that fails is still refused: an expired token doesn't quietly become an anonymous caller. requiredScopesapplies only to tokens. Anonymous callers getanonymousScopes, whateverrequiredScopesasks 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/tokenwithgrant_type=anonymousand aclient_idreturns an anonymous token, valid for a day: itssubisanon:and a random id, itsscopeisanonymousScopes, and it has the claimanonymous: true. Without the option, that grant answers400unsupported_grant_type. Apublicserver answers it too, with no option, and its tokens lastsessionTtl, an hour by default. Astaticortransparentserver doesn't answer it. - A caller with an anonymous token is anonymous:
isAnonymousistrueandscopesisanonymousScopes, as a test in Seeing what each mode answers shows. Unlike a caller without a token, it keeps the samesubfor as long as the token lasts. (Before 1.8.3, the token had a plain randomsuband no scopes, and its holder hadisAnonymous: false, so it looked signed in.)
public options
| Option | Type | Default | What it does |
|---|---|---|---|
issuer | string | see description | The 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. |
sessionTtl | number, seconds | 3600 (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. |
anonymousScopes | string[] | ["anonymous"] | The scopes of every caller, with or without an anonymous token. |
publicAccess | { tools?, prompts?, rateLimit? } | none | What anonymous callers may use: see publicAccess. |
jwks, signKey | Accepted 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.
| Option | Type | Default | What 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. |
rateLimit | number | 60 | Anonymous 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 auth | An app on the main endpoint with its own auth |
|---|---|
None, or public | Doesn'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. |
static | The 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. |
transparent | Doesn't start: Parent uses transparent mode but apps have their own auth providers. |
local, remote | Starts. 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: trueleaves anonymous callers refused;allowAnonymousin any mode buttransparentdoes nothing. Check the spelling of anything that seems to have no effect. - A bad
authstops the server. An unknownmode, or a mode without what it requires (tokensforstatic,providerfortransparentandremote), fails when the server starts, andcreateFetchHandler()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()andconnect(), 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.
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:
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:
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:
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:
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 withsplitByApp: true, where itsauthapplies. Clients then connect to/billingfor it. Giving an app its own endpoint shows both, and which entry points serve them. - Put the
authon@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, withthis.author 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 instaticmode, and the client has to be configured with a key. resource_metadatawithhttp://or an internal host name: the server builds its address from the request. SetFRONTMCP_PUBLIC_URL, orFRONTMCP_TRUST_PROXYbehind a proxy you control: see The server's public address.- The client finds no token endpoint: in
transparentmode,/.well-known/oauth-authorization-serveron your server redirects to the same path on yourprovider. If your provider doesn't serve that path, the client has nowhere to go.remotemode 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.