# Auth modes

> The five ways a FrontMCP server decides who may connect, what each one advertises and answers without credentials, who your code sees as the caller, how anonymous access works, and how to choose.

Source: https://frontmcp.dev/reference/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](https://frontmcp.dev/reference/auth) 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.

```ts
@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`.

```ts 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.](#usage)

#### 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](#public-options) |
| `"static"` | Requests that send one of your keys | You | `static:` and 12 hex characters, one id per key | [Static keys](https://frontmcp.dev/reference/auth/tokens#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](https://frontmcp.dev/reference/auth/tokens#verifying-jwts-from-your-identity-provider) |
| `"local"` | Users who sign in on FrontMCP's own page | FrontMCP | The user who signed in | [Local auth](https://frontmcp.dev/reference/auth/local) |
| `"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](https://frontmcp.dev/reference/auth/remote) |

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](#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`](#anonymous-access), plus a check per tool with [`this.auth`](https://frontmcp.dev/reference/sdk/auth) or [authorities](https://frontmcp.dev/reference/auth/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](https://frontmcp.dev/reference/auth/tokens) | How tokens and keys are checked, what your code gets from them, sessions, expiry, and every refusal a client can get |
| [Local auth](https://frontmcp.dev/reference/auth/local) | FrontMCP's own sign-in page and OAuth endpoints |
| [Custom login UI](https://frontmcp.dev/reference/auth/login-ui) | Changing or replacing the pages users sign in on |
| [Progressive auth](https://frontmcp.dev/reference/auth/progressive) | Letting users authorize one app at a time |
| [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote) | Signing users in with an upstream provider, through FrontMCP |
| [Client ID metadata (CIMD)](https://frontmcp.dev/reference/auth/cimd) | Clients that identify themselves with a URL instead of registering |
| [Authorities](https://frontmcp.dev/reference/auth/authorities) | Declaring who may call each tool, resource and prompt |
| [Auth in production](https://frontmcp.dev/reference/auth/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](#every-request-to-a-public-server-gets-401)). 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`](#anonymous-access) | `401` with `error="invalid_token"` and the reason, or `403` with `error="insufficient_scope"`. See [the errors a client sees](https://frontmcp.dev/reference/auth/tokens#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:

```json
{
  "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](https://frontmcp.dev/reference/auth/local). A `public` server also answers `grant_type=anonymous` at `/oauth/token`, for [anonymous tokens](#anonymous-access).

With [`http.entryPath`](https://frontmcp.dev/reference/sdk/frontmcp#http) 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), 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](https://frontmcp.dev/reference/auth/tokens#token-audience-does-not-match-expected-audiences). 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](https://frontmcp.dev/reference/auth/local#tokens).

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](https://frontmcp.dev/reference/server/config-files#starting-and-serving) and [Auth in production](https://frontmcp.dev/reference/auth/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](https://frontmcp.dev/reference/auth/local). [`this.auth`](https://frontmcp.dev/reference/sdk/auth#what-each-auth-mode-fills-in) lists every field, and [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens#what-your-code-gets-from-a-token) 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](#anonymous-access), 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](#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 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](https://frontmcp.dev/reference/sdk/guard) 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`](#public-options), 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](#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

| 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](#anonymous-access). |
| `publicAccess` | `{ tools?, prompts?, rateLimit? }` | none | What anonymous callers may use: see [`publicAccess`](#publicaccess). |
| `jwks`, `signKey` | | | Accepted and not used yet: the tokens are signed with `JWT_SECRET`, as in [local mode](https://frontmcp.dev/reference/auth/local#tokens). |

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`](https://frontmcp.dev/reference/sdk/guard) turned off. |

[Limiting what anonymous callers can use](#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](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp) 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`](https://frontmcp.dev/reference/auth/progressive). |

[Protecting one app's tools](#protecting-one-apps-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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) 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](https://frontmcp.dev/reference/auth/tokens#sessions).
- **In-process calls aren't authenticated.** With [`create()`](https://frontmcp.dev/reference/sdk/create) and [`connect()`](https://frontmcp.dev/reference/sdk/connect), the calling code says who the user is: see [what each entry point fills in](https://frontmcp.dev/reference/sdk/auth#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.

```ts whoami.tool.ts active
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 };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

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

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth: { mode: "public" } })
export default class Server {}
```

```ts send.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./main";

type Handler = Awaited<ReturnType<typeof FrontMcpInstance.createFetchHandler>>;

/** A server with this auth, reached over HTTP. */
export function serve(auth: object): Promise<Handler> {
  return FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth });
}

/** Calls whoami, on a new server with this auth or on this one, sending these headers. */
export async function send(server: object | Handler, headers: Record<string, string> = {}) {
  const handler = typeof server === "function" ? (server as Handler) : await serve(server);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "whoami",
        ...headers,
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  const body = await response.json();
  return { status: response.status, challenge: response.headers.get("www-authenticate"), body, caller: body.result?.structuredContent };
}
```

```ts keys.ts
// The identity provider's public key, so the example runs offline. Usually FrontMCP fetches it.
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };

// An access token for sam (scope "tickets:read"), signed ahead of time with that key.
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.q6QO61dYLhbeoVC1iwpVvcz22D468jtd9iH741yQt6DAB420nCHB2bETwns1TuZ6Z5tZ3A0f7t0oyzhvTW2TkA";
```

```ts modes.test.ts
import { test, expect } from "@frontmcp/testing";
import { send, serve } from "./send";
import { JWKS, SAM } from "./keys";

// The challenge names the address the request was sent to.
const pointsAtMetadata = 'Bearer resource_metadata="https://desk.example.com/.well-known/oauth-protected-resource"';

test("public: everyone gets in, as a new anonymous caller", async ({ mcp }) => {
  const first = (await mcp.tools.call("whoami", {})).json();
  const second = (await mcp.tools.call("whoami", {})).json();
  expect(first).toEqual({ sub: expect.stringMatching(/^anon:/), isAnonymous: true, scopes: ["anonymous"], mode: "authenticated" });
  expect(second.sub).not.toBe(first.sub);
});

test("static: a 401 with a realm, and the reason when the key is wrong", async () => {
  const auth = { mode: "static", tokens: ["desk-key-1"] };
  expect(await send(auth)).toMatchObject({ status: 401, challenge: 'Bearer realm="mcp"', body: { error: "Unauthorized" } });
  expect((await send(auth, { authorization: "Bearer nope" })).challenge).toBe(
    'Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid"',
  );
  const caller = (await send(auth, { authorization: "Bearer desk-key-1" })).caller;
  expect(caller).toEqual({ sub: expect.stringMatching(/^static:[0-9a-f]{12}$/), isAnonymous: false, scopes: ["static"], mode: "authenticated" });
});

test("transparent: a 401 that points at the resource metadata", async () => {
  const auth = { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", providerConfig: { jwks: JWKS } };
  const refused = await send(auth);
  expect(refused).toMatchObject({ status: 401, body: { error: "Unauthorized" } });
  expect(refused.challenge).toBe(pointsAtMetadata);
  expect((await send(auth, { authorization: `Bearer ${SAM}` })).caller).toEqual({ sub: "sam", isAnonymous: false, scopes: ["tickets:read"], mode: "authenticated" });
});

test("local and remote: the same 401", async () => {
  for (const auth of [{ mode: "local" }, { mode: "remote", provider: "https://auth.example.com", clientId: "help-desk" }]) {
    const refused = await send(auth);
    expect(refused).toMatchObject({ status: 401, body: { error: "Unauthorized" } });
    expect(refused.challenge).toBe(pointsAtMetadata);
  }
});

test("every MCP request is checked, server/discover too; health checks aren't", async () => {
  const server = await serve({ mode: "static", tokens: ["desk-key-1"] });
  const discover = await server(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "server/discover" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "server/discover", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  expect(discover.status).toBe(401);
  expect((await server(new Request("https://desk.example.com/healthz"))).status).toBe(200);
  expect((await server(new Request("https://desk.example.com/readyz"))).status).toBe(200);
});

test("a misspelled option is dropped: allowAnonymus lets no one in", async () => {
  expect((await send({ mode: "transparent", provider: "https://auth.example.com", allowAnonymus: true })).status).toBe(401);
});

test("an unknown mode, or a missing option, stops the server", async () => {
  await expect(serve({ mode: "oauth" })).rejects.toThrow(/invalid_union/);
  await expect(serve({ mode: "static", tokens: [] })).rejects.toThrow("Too small: expected array to have >=1 items");
});

test("public: anyone can get an anonymous token, and stays anonymous with it", async () => {
  const server = await serve({ mode: "public" });
  const issued = await server(
    new Request("https://desk.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: "grant_type=anonymous&client_id=anything",
    }),
  );
  expect(issued.headers.get("cache-control")).toBe("no-store");
  const { access_token, expires_in } = await issued.json();
  const claims = JSON.parse(atob(access_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
  expect(claims).toMatchObject({ iss: "https://desk.example.com", aud: "https://desk.example.com", anonymous: true });
  expect([expires_in, claims.exp - claims.iat]).toEqual([3600, 3600]); // sessionTtl, an hour by default
  const caller = (await send(server, { authorization: `Bearer ${access_token}` })).caller;
  expect(caller).toEqual({ sub: expect.stringMatching(/^anon:/), isAnonymous: true, scopes: ["anonymous"], mode: "public" });
  expect((await send(server, { authorization: `Bearer ${access_token}` })).caller.sub).toBe(caller.sub); // the same caller while the token lasts

  // anonymousScopes sets the scopes of the token, and of callers without one
  const withScopes = await serve({ mode: "public", anonymousScopes: ["tickets:read"] });
  const scoped = await withScopes(
    new Request("https://desk.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: "grant_type=anonymous&client_id=anything",
    }),
  );
  expect((await send(withScopes, { authorization: `Bearer ${(await scoped.json()).access_token}` })).caller.scopes).toEqual(["tickets:read"]);
  expect((await send(withScopes)).caller.scopes).toEqual(["tickets:read"]);

  // a static server has no such grant
  const staticServer = await serve({ mode: "static", tokens: ["desk-key-1"] });
  const refused = await staticServer(
    new Request("https://desk.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: "grant_type=anonymous&client_id=anything",
    }),
  );
  expect(await refused.json()).toEqual({ error: "unsupported_grant_type", error_description: "Anonymous access is not enabled on this server" });
});

test("public: sessionTtl sets how long anonymous tokens last", async () => {
  const server = await serve({ mode: "public", sessionTtl: 600 });
  const issued = await server(
    new Request("https://desk.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: "grant_type=anonymous&client_id=anything",
    }),
  );
  const { access_token, expires_in } = await issued.json();
  const { iat, exp } = JSON.parse(atob(access_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
  expect([expires_in, exp - iat]).toEqual([600, 600]);
  await expect(serve({ mode: "public", sessionTtl: 0 })).rejects.toThrow("Too small: expected number to be >0");
});

test("public refuses a JWT it didn't sign", async () => {
  const refused = await send({ mode: "public" }, { authorization: `Bearer ${SAM}` });
  expect(refused.status).toBe(401);
  expect(refused.challenge).toContain('error="invalid_token"');
  // any other header is ignored
  expect((await send({ mode: "public" }, { authorization: "Bearer desk-key-1" })).caller.isAnonymous).toBe(true);
});
```

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`](#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:

```ts main.ts active
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 {}
```

```ts tickets.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

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

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const found = tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase()));
    return { tickets: found, caller: this.auth.user.sub, scopes: this.auth.scopes };
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket. Signed-in support agents only.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (!this.auth.hasScope("tickets:write")) {
      this.fail(new PublicMcpError("Closing a ticket needs the tickets:write scope. Ask the user to sign in as an agent.", "FORBIDDEN"));
    }
    return { id, status: "closed", closedBy: this.auth.user.sub };
  }
}
```

```ts keys.ts hidden
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };
// nour: scope "tickets:read tickets:write"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJhZ2VudCJdfQ.qXeGjz4x1p8iXrWs-ehsvOk_wlOK1s5gyZivhBcLcwbPkcuykeEv9VdocYXzpDuB76qWEcBHnkG81OkimP3uzQ";
// nour, but expired on 1 January 2026
export const EXPIRED = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3NjcyMjIwMDAsImV4cCI6MTc2NzIyNTYwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.ddxE2nUgXGmLbPTzSAAApH5Z9DmjBSM7LtwSt6t9cM_hhYj8wjlcRDDytd02BU3HGzZz2hHp5c4mV2QvOY4qpw";
// kim: scope "openid profile", no tickets scope
export const KIM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoia2ltIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSIsIm5hbWUiOiJLaW0gUGFyayJ9.aKuZXKPSpqQSlAZNnshxvPsbj6UJ6abK64wQ4GYWeBV_loKsFKC6k8EvZo9HmVwJBuLTwa7mXcdLsDMM0DpHGA";
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

/** Sends one tools/call over HTTP, as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown>, server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0] = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return { status: response.status, body: await response.json() };
}
```

```ts anonymous.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { config } from "./main";
import { EXPIRED, KIM, NOUR } from "./keys";

test("an anonymous caller can search, with anonymousScopes", async ({ mcp }) => {
  const result = (await mcp.tools.call("search_tickets", { query: "log" })).json();
  expect(result).toMatchObject({ caller: expect.stringMatching(/^anon:/), scopes: ["tickets:read"] });
});

test("an anonymous caller can't close a ticket", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError("FORBIDDEN");
});

test("a signed-in agent gets the token's scopes", async () => {
  const { body } = await callAs(NOUR, "close_ticket", { id: "T-1" });
  expect(body.result.structuredContent).toEqual({ id: "T-1", status: "closed", closedBy: "nour" });
});

test("a token without tickets:read gets a 403, though anonymous callers get in", async () => {
  expect(await callAs(KIM, "search_tickets", { query: "log" })).toMatchObject({ status: 403, body: { error: "Forbidden" } });
});

test("an expired token is refused, not treated as anonymous", async () => {
  expect(await callAs(EXPIRED, "search_tickets", { query: "log" })).toMatchObject({ status: 401, body: { error: "Unauthorized" } });
});

test("without allowAnonymous, a request with no token is refused", async () => {
  const strict = { ...config, auth: { ...config.auth, allowAnonymous: false } };
  expect(await callAs(undefined, "search_tickets", { query: "log" }, strict)).toMatchObject({ status: 401 });
});

test("public mode gives callers without a token its anonymousScopes too", async () => {
  const pub = { ...config, auth: { mode: "public" as const, anonymousScopes: ["tickets:read"] } };
  const { body } = await callAs(undefined, "search_tickets", { query: "log" }, pub);
  expect(body.result.structuredContent.scopes).toEqual(["tickets:read"]);
});
```

`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`](#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`:

```ts main.ts active
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 {}
```

```ts tickets.tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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

@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: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket. Signed-in support agents only.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed", closedBy: this.auth.user.sub };
  }
}
```

```ts keys.ts hidden
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };
// nour: scope "tickets:read tickets:write"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJhZ2VudCJdfQ.qXeGjz4x1p8iXrWs-ehsvOk_wlOK1s5gyZivhBcLcwbPkcuykeEv9VdocYXzpDuB76qWEcBHnkG81OkimP3uzQ";
```

```ts client.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

type Handler = Awaited<ReturnType<typeof FrontMcpInstance.createFetchHandler>>;

/** A server with this configuration, reached over HTTP. */
export function serve(server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0] = config): Promise<Handler> {
  return FrontMcpInstance.createFetchHandler(server);
}

/** Sends one request to this server, as a client with this token, or none, would. */
export async function send(handler: Handler, token: string | undefined, method: string, params: Record<string, unknown> = {}) {
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": method,
        ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return { status: response.status, body: await response.json() };
}
```

```ts public-access.test.ts
import { test, expect } from "@frontmcp/testing";
import { send, serve } from "./client";
import { config } from "./main";
import { NOUR } from "./keys";

test("an anonymous caller only sees the tools in publicAccess.tools", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["search_tickets"]);
});

test("calling another tool by name is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError("PUBLIC_ACCESS_DENIED");
  expect(result.text()).toBe('The tool "help-desk:close_ticket" is not available to anonymous callers');
});

test("a signed-in caller isn't limited", async () => {
  const server = await serve();
  const { body } = await send(server, NOUR, "tools/call", { name: "close_ticket", arguments: { id: "T-1" } });
  expect(body.result.structuredContent).toEqual({ id: "T-1", status: "closed", closedBy: "nour" });
  expect((await send(server, NOUR, "tools/list")).body.result.tools).toHaveLength(2);
});

test("an anonymous caller gets rateLimit calls a minute", async () => {
  const server = await serve();
  for (let i = 0; i < 3; i++) {
    const { body } = await send(server, undefined, "tools/call", { name: "search_tickets", arguments: { query: "log" } });
    expect(body.result.isError).toBeUndefined();
  }
  const fourth = (await send(server, undefined, "tools/call", { name: "search_tickets", arguments: { query: "log" } })).body.result;
  expect(fourth).toMatchObject({ isError: true, _meta: { code: "RATE_LIMIT_EXCEEDED" } });
  expect(fourth.content[0].text).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/);
});

test("publicAccess lets no one in by itself", async () => {
  const keyed = await serve({ ...config, auth: { mode: "static", tokens: ["desk-key-1"], publicAccess: { tools: ["search_tickets"] } } });
  expect((await send(keyed, undefined, "tools/list")).status).toBe(401);
});
```

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:

```ts whoami.tool.ts
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() {
    return { sub: this.auth.user.sub, scopes: this.auth.scopes };
  }
}
```

```ts get.ts
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

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

/** GETs a path from a server with this auth, and these http options. */
export async function get(auth: object, path: string, http?: object) {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth, ...(http ? { http } : {}) });
  return handler(new Request(`https://desk.example.com${path}`));
}
```

```ts keys.ts hidden
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };
```

```ts discovery.test.ts active
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);
});
```

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](#the-servers-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:

```ts main.ts active
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 {}
```

```ts protect.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp, RefundInvoice } from "./main";

const info = { name: "support", version: "1.0.0" };

async function refund(server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0], path: string, headers: Record<string, string> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const response = await handler(
    new Request(`https://support.example.com${path}`, {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "refund_invoice", ...headers },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "refund_invoice", arguments: { id: "INV-7" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.status;
}

test("standalone, the app's key guards its own endpoint", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["search_tickets"]);
  const server = { info, apps: [HelpDeskApp, BillingApp] };
  expect(await refund(server, "/billing")).toBe(401);
  expect(await refund(server, "/billing", { authorization: "Bearer billing-key" })).toBe(200);
});

// 🚩 The same app on the shared endpoint
@App({ id: "billing", name: "Billing", tools: [RefundInvoice], auth: { mode: "static", tokens: ["billing-key"] } })
class SharedBillingApp {}

test("on the shared endpoint of a public server, the app's auth stops the server from starting", async () => {
  await expect(FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, SharedBillingApp] })).rejects.toThrow(
    "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.",
  );
});

// The same app without an auth of its own
@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
class PlainBillingApp {}

test("auth on @FrontMcp protects every app on the endpoint", async () => {
  const server = { info, apps: [HelpDeskApp, PlainBillingApp], auth: { mode: "static" as const, tokens: ["billing-key"] } };
  expect(await refund(server, "/")).toBe(401);
  expect(await refund(server, "/", { authorization: "Bearer billing-key" })).toBe(200);
});
```

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](https://frontmcp.dev/reference/server/apps#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`](https://frontmcp.dev/reference/sdk/auth) or [authorities](https://frontmcp.dev/reference/auth/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`

```text
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](#protecting-one-apps-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`](#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-servers-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](https://frontmcp.dev/reference/auth/remote) 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.
