# this.auth

> Who is calling a tool, resource, prompt, agent or job, with their scopes, roles and claims, and what each auth mode and entry point fills in.

Source: https://frontmcp.dev/reference/sdk/auth

`this.auth` describes the caller that your server's [authentication](https://frontmcp.dev/learn/authenticating-clients) established for this request: who they are (`user.sub`), whether they signed in (`isAnonymous`), what they were granted (`scopes`, `roles`, `permissions`), and what their token said (`claims`), with checks like `hasScope()`. The model writes a tool's arguments, but it can't touch any of this, so `this.auth` is what access decisions should be based on. It's there in tools, resources, prompts, agents and jobs.

```ts
const { user, isAnonymous, scopes, roles, permissions, claims } = this.auth
this.auth.hasScope(scope)
```

---

## Reference

### `this.auth`

Read `this.auth` inside `execute()`.

```ts close-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket. 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("Only signed-in support agents can close tickets. Ask the user to sign in.", "FORBIDDEN"));
    }
    return { id, status: "closed", closedBy: this.auth.user.sub };
  }
}
```

[See more examples below.](#usage)

#### Properties

| Property | Type | Description |
| --- | --- | --- |
| `user` | `{ sub, name?, email?, picture? }` | The caller. `sub` is their id: the token's `sub` claim (or, in a token without one, its `client_id` or `azp`; a token with none of them is refused), or the id FrontMCP gives callers without a token. `name`, `email` and `picture` are the claims of the same name, when there are any. |
| `isAnonymous` | `boolean` | `true` when `user.sub` is empty or starts with `anon:`, that is, when the caller didn't sign in. `isAnonymousSubject(sub)`, exported by `@frontmcp/sdk`, applies the same rule to a `sub` you hold. |
| `scopes` | `readonly string[]` | The scopes the caller was granted: a token's `scope` and `scp` claims, or the scopes your auth mode gives. |
| `roles` | `readonly string[]` | The token's `roles` claim, or the claim [`claimsMapping.roles`](#options-that-change-thisauth) points to. `[]` when there's none. |
| `permissions` | `readonly string[]` | The token's `permissions` claim, or the claim `claimsMapping.permissions` points to. A string is split on spaces. |
| `claims` | `Readonly<Record<string, unknown>>` | Every claim of the caller's token, like `tenant` or `realm_access`. Callers without a token get a few made-up claims: see [what each mode fills in](#what-each-auth-mode-fills-in). |
| `sessionId` | `string` | The same as [`this.context.sessionId`](https://frontmcp.dev/reference/sdk/context): under MCP 2026-07-28, a new `anon:` id on every request, even for a caller with a static key, and `direct:` and a UUID in-process. Don't use it to identify anyone. (Changed in 1.8.5: before, the auth layer could record an id of its own here.) |
| `mode` | `string` | Meant to name the auth mode. In FrontMCP 1.9.3 it's `"public"` for a caller holding an [anonymous token](#anonymous-tokens), and `"authenticated"` for every other caller, in every mode, `public` included, so don't rely on it. |

#### Methods

| Method | Returns `true` when |
| --- | --- |
| `hasScope(scope)` | `scopes` includes `scope`. |
| `hasAllScopes(scopes)`, `hasAnyScope(scopes)` | `scopes` includes every one, or at least one, of them. |
| `hasRole(role)` | `roles` includes `role`. |
| `hasAllRoles(roles)`, `hasAnyRole(roles)` | `roles` includes every one, or at least one, of them. |
| `hasPermission(permission)` | `permissions` includes `permission`. |
| `hasAllPermissions(permissions)`, `hasAnyPermission(permissions)` | `permissions` includes every one, or at least one, of them. |

Each is an exact, case-sensitive match: `hasScope("tickets")` is `false` for a caller with `tickets:read`, and nothing expands wildcards.

#### What each auth mode fills in

| | `public` (the default) | `static` | `transparent`, with a token | `transparent` with `allowAnonymous`, no token |
| --- | --- | --- | --- | --- |
| `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 |
| `user.name`, `user.email` | `"Anonymous"`, none | `"Static token"`, none | The token's `name` and `email` | `"Anonymous"`, none |
| `isAnonymous` | `true` | `false` | `false` | `true` |
| `scopes` | `anonymousScopes`, `["anonymous"]` by default | The mode's `scopes`, `["static"]` by default | The token's `scope` and `scp` | `anonymousScopes` |
| `roles`, `permissions` | `[]` | `[]` | From the token's claims | `[]` |
| `claims` | `{ iss: "public", sub, name, scope }` | `{ iss: "static", sub, name, scope }` | Every claim in the token | `{ iss: "transparent-anon", sub, name, scope }` |

[Seeing what each mode gives](#seeing-what-each-mode-gives) shows all four, and an anonymous token. `local` and `remote`, the modes where FrontMCP runs the sign-in itself, need a browser sign-in, so they aren't shown on this page: [Local auth](https://frontmcp.dev/reference/auth/local) and [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote#what-tools-see) show what their tools see.

#### Anonymous tokens

In `public` mode, and in `local` mode with `allowDefaultPublic`, a client can also ask `/oauth/token` for a token with `grant_type=anonymous` and its `client_id`. Whoever holds that token is still anonymous: `user.sub` is `anon:` and an id that stays the same for as long as the token lasts, `isAnonymous` is `true`, and `mode` is `"public"`. Its `scopes` are the mode's `anonymousScopes`, as are those of a caller without a token.

#### What each entry point fills in

The table of modes above is for requests over HTTP: the Node server, [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), and the Playground. In-process entry points have no token to check, so the calling code says who the user is:

| Entry point | `this.auth` |
| --- | --- |
| [`create()` or `createDirect()`](https://frontmcp.dev/reference/sdk/create), without `authContext` | `user.sub` is `"direct"`, and `isAnonymous` is `false`: a signed-in user with no scopes and no roles. |
| `create()` or `createDirect()`, with `authContext: { user, scopes }` | `user.sub`, `name` and `email`, `roles` and `permissions` come from `user`, and `claims` holds all of it. `scopes` is `authContext.scopes`, or `[]` without it: a `scope` claim in `user` isn't read. |
| [`connect()`](https://frontmcp.dev/reference/sdk/connect), without `session.user` | `user.sub` is `""`, and `isAnonymous` is `true`. `authToken` isn't checked or read. |
| `connect()`, with `session: { user, scopes }` | As for `authContext.user`. `scopes` is `session.scopes`, or `[]` without it, with or without a `user`. |

Nothing checks the user you pass in-process: your code is the authentication, so pass only a user your code has verified.

Clients on MCP versions before 2026-07-28, which keep a session over HTTP, get the token's scopes in `this.auth.scopes` like any other caller. `createFetchHandler()` and the Playground keep no sessions, so this page can't show it: it was checked against a server listening over HTTP, with a client that opened a session with `initialize`.

Changed in 1.8.7: before, `this.auth.scopes` was empty for those clients, although `this.context.authInfo.scopes` had the scopes, so a tool that checked `hasScope()` refused them. `this.auth` now merges what the request's context knows about the caller, so the two agree.

#### Where it's available

| In | `this.auth` |
| --- | --- |
| Tools, resources, agents | The caller of the request. |
| Jobs | The caller who started the run. |
| Channels | An empty caller: `user.sub` is `""` and `isAnonymous` is `true`. See [Context classes](https://frontmcp.dev/reference/sdk/contexts#channelcontext). |
| Prompts | The caller of the request. See [Reading the caller in a prompt](#reading-the-caller-in-a-prompt). |

#### Options that change `this.auth`

`@FrontMcp({ authorities })` holds the server's [authorization rules](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool). Its `claimsMapping` also tells `this.auth` where the claims are:

| Option | What it changes |
| --- | --- |
| `claimsMapping.roles` | A dot path to the roles claim, like `"realm_access.roles"`. `this.auth.roles` reads it instead of `roles`. |
| `claimsMapping.permissions` | A dot path to the permissions claim. `"scope"` turns the token's scopes into permissions. |
| `claimsMapping.userId` | A dot path to the claim to use as `user.sub`, instead of `sub`. The token must still have a `sub`, `client_id` or `azp`: one with none of them is refused before the mapping applies. |
| `pipes` | Functions that add fields of your own to `this.auth`, from the caller's claims. See [Adding fields with pipes](#adding-fields-with-pipes). (Changed in 1.9: before, FrontMCP never ran them.) |

To work roles out in code instead, see [`claimsResolver`](https://frontmcp.dev/reference/auth/authorities#working-out-roles-in-code). With one, `claimsMapping` isn't read: `this.auth.roles`, `permissions` and `claims` are what the resolver returns, the same as rules see, and `user.sub` is the token's `sub`. (Changed in 1.9.2: before, the resolver changed only what rules saw.)

#### Caveats

- `this.auth` is built from the raw result of authentication, which is also [`this.context.authInfo`](https://frontmcp.dev/reference/sdk/context#authinfo). Use `authInfo` for what `this.auth` leaves out, like `token`, the caller's token itself.
- FrontMCP checked the token's signature, issuer, expiry and audience. What a claim means is up to your identity provider: a `roles` claim holds whatever it put there.
- Everything else about a request, like its arguments, `clientInfo` and headers, is written by the client. [Reading the Request](https://frontmcp.dev/learn/reading-the-request#arguments-client-info-and-headers-are-claims) explains why only `this.auth` should decide access.
- How the caller is established depends on the server's mode, in [Auth modes](https://frontmcp.dev/reference/auth/modes), and on how its token is checked, in [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens).

---

## Usage

### Checking a scope before acting

This server accepts tokens from an identity provider in `transparent` mode, and lets callers without a token in with the scope `tickets:read`. `close_ticket` needs `tickets:write`. The Playground has no token, so its call is refused; the tests call as two signed-in users, with tokens a test key signed ahead of time.

```ts close-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket. 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("Only signed-in support agents can close tickets. Ask the user to sign in.", "FORBIDDEN"));
    }
    return { id, status: "closed", closedBy: this.auth.user.sub };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] })
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,
    anonymousScopes: ["tickets:read"],
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```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 (await response.json()).result;
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: scope "tickets:read tickets:write", roles ["agent"], realm_access.roles ["lead"], tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: scope "tickets:read"
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts close-ticket.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { NOUR, SAM } from "./tokens";

test("a caller without a token is refused", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError("FORBIDDEN");
});

test("a user with tickets:write can close", async () => {
  const result = await callAs(NOUR, "close_ticket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", closedBy: "nour" });
});

test("a user with only tickets:read can't", async () => {
  const result = await callAs(SAM, "close_ticket", { id: "T-1" });
  expect(result).toMatchObject({ isError: true, _meta: { code: "FORBIDDEN" } });
});
```

Check before you change anything, so a refused call has no effect, and say in the tool's description who may use it. To refuse the call before `execute()` runs, and hide the tool from callers who can't use it, declare [`authorities`](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool) instead.

### Using who the caller is

`user` and `claims` carry what the token says about the caller. Here a note records its author, and is filed under the caller's tenant, a claim this provider adds. The tests use the same `callAs()` and tokens as above.

```ts add-note.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "add_note", description: "Add a note to a support ticket", inputSchema: { ticket: z.string(), text: z.string() } })
export class AddNote extends ToolContext {
  async execute({ ticket, text }: { ticket: string; text: string }) {
    const { user, isAnonymous, claims } = this.auth;
    return {
      ticket,
      text,
      author: isAnonymous ? "a guest" : `${user.name} <${user.email}>`,
      tenant: typeof claims.tenant === "string" ? claims.tenant : null,
    };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { AddNote } from "./add-note.tool";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts call-as.ts hidden
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 (await response.json()).result;
}
```

```ts tokens.ts hidden
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: scope "tickets:read tickets:write", roles ["agent"], realm_access.roles ["lead"], tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: scope "tickets:read"
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts add-note.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { NOUR } from "./tokens";

test("the author and tenant come from the token", async () => {
  const result = await callAs(NOUR, "add_note", { ticket: "T-1", text: "Called back." });
  expect(result.structuredContent).toEqual({ ticket: "T-1", text: "Called back.", author: "Nour Haddad <nour@example.com>", tenant: "acme" });
});

test("a guest has neither", async ({ mcp }) => {
  const result = await mcp.tools.call("add_note", { ticket: "T-1", text: "Called back." });
  expect(result.json()).toMatchObject({ author: "a guest", tenant: null });
});
```

`claims` is typed `unknown` per claim, because a token can hold anything. Check a claim's type before you use it.

### Reading roles from another claim

Identity providers put roles in different places. Keycloak, for one, puts them in `realm_access.roles`. Point `claimsMapping.roles` at them, and `this.auth.roles` and `hasRole()` read them from there. `permissions: "scope"` turns the token's scopes into permissions too, and `userId` picks the claim that becomes `user.sub`:

```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, roles, permissions } = this.auth;
    return { sub: user.sub, roles, permissions, isLead: this.auth.hasRole("lead") };
  }
}
```

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

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
  authorities: {
    claimsMapping: { roles: "realm_access.roles", permissions: "scope", userId: "email" },
    profiles: {},
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts call-as.ts hidden
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 (await response.json()).result;
}
```

```ts tokens.ts hidden
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: scope "tickets:read tickets:write", roles ["agent"], realm_access.roles ["lead"], tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: scope "tickets:read"
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts roles.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { config } from "./main";
import { NOUR } from "./tokens";

test("roles, permissions and the id come from the mapped claims", async () => {
  // nour's token has roles: ["agent"] and realm_access: { roles: ["lead"] }
  const result = await callAs(NOUR, "whoami");
  expect(result.structuredContent).toEqual({
    sub: "nour@example.com",
    roles: ["lead"],
    permissions: ["tickets:read", "tickets:write"],
    isLead: true,
  });
});

test("without claimsMapping, the top-level roles claim is used", async () => {
  const { authorities, ...unmapped } = config;
  const result = await callAs(NOUR, "whoami", {}, unmapped);
  expect(result.structuredContent).toEqual({ sub: "nour", roles: ["agent"], permissions: [], isLead: false });
});
```

Without `claimsMapping`, the same token gives `roles: ["agent"]`, from its `roles` claim, and `permissions: []`, as the second test shows. The same mapping is used by `authorities` rules, so a rule and a check in `execute()` agree on what a role is.

### Seeing what each mode gives

`whoami` returns the whole of `this.auth` except the session id. The Playground's server is in `public` mode; the tests start one server in each of the other modes and call it over HTTP.

```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, roles, permissions, claims, mode } = this.auth;
    const matches = { exact: this.auth.hasScope("tickets:read"), prefix: this.auth.hasScope("tickets"), upperCase: this.auth.hasScope("TICKETS:READ") };
    return { user, isAnonymous, scopes, roles, permissions, claims, mode, matches };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

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

```ts modes.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";
import { SAM } from "./tokens";

const jwks = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] };

async function whoami(auth: object, token?: string, server?: (request: Request) => Promise<Response>) {
  const handler = server ?? (await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth }));
  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",
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result.structuredContent;
}

test("public", async ({ mcp }) => {
  const me = (await mcp.tools.call("whoami", {})).json();
  expect(me).toEqual({
    user: { sub: expect.stringMatching(/^anon:/), name: "Anonymous" },
    isAnonymous: true,
    scopes: ["anonymous"],
    roles: [],
    permissions: [],
    claims: { iss: "public", sub: me.user.sub, name: "Anonymous", scope: "anonymous" },
    mode: "authenticated",
    matches: { exact: false, prefix: false, upperCase: false },
  });
  // a new anonymous id on every request
  expect((await mcp.tools.call("whoami", {})).json().user.sub).not.toBe(me.user.sub);
});

test("static", async () => {
  const me = await whoami({ mode: "static", tokens: ["desk-key-1"], scopes: ["tickets:read"] }, "desk-key-1");
  expect(me).toEqual({
    user: { sub: expect.stringMatching(/^static:[0-9a-f]{12}$/), name: "Static token" },
    isAnonymous: false,
    scopes: ["tickets:read"],
    roles: [],
    permissions: [],
    claims: { iss: "static", sub: me.user.sub, name: "Static token", scope: "tickets:read" },
    mode: "authenticated",
    matches: { exact: true, prefix: false, upperCase: false },
  });
});

test("transparent, with a token", async () => {
  const me = await whoami({ mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", providerConfig: { jwks } }, SAM);
  expect(me).toMatchObject({ user: { sub: "sam", name: "Sam Ortiz" }, isAnonymous: false, scopes: ["tickets:read"], roles: [], mode: "authenticated" });
  expect(me.claims).toMatchObject({ iss: "https://auth.example.com", sub: "sam", aud: "https://desk.example.com", scope: "tickets:read" });
});

test("transparent, anonymous", async () => {
  const me = await whoami({ mode: "transparent", provider: "https://auth.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], providerConfig: { jwks } });
  expect(me).toMatchObject({ user: { sub: expect.stringMatching(/^anon:/), name: "Anonymous" }, isAnonymous: true, scopes: ["tickets:read"], mode: "authenticated" });
  expect(me.claims).toMatchObject({ iss: "transparent-anon", scope: "tickets:read" });
});

test("public, with an anonymous token", async () => {
  const auth = { mode: "public", anonymousScopes: ["tickets:read"] };
  const server = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth });
  const grant = 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=desk-console",
    }),
  );
  const { access_token } = await grant.json();
  const me = await whoami(auth, access_token, server);
  expect(me).toMatchObject({ user: { sub: expect.stringMatching(/^anon:/) }, isAnonymous: true, scopes: ["tickets:read"], roles: [], mode: "public" });
  // the same anonymous id for as long as the token lasts
  expect((await whoami(auth, access_token, server)).user.sub).toBe(me.user.sub);
  // without the token, a new anonymous caller each time, with the same scopes
  expect(await whoami(auth, undefined, server)).toMatchObject({ isAnonymous: true, scopes: ["tickets:read"], mode: "authenticated" });
});

test("local, with allowDefaultPublic and an anonymous token", async () => {
  const auth = { mode: "local", allowDefaultPublic: true, anonymousScopes: ["tickets:read"] };
  const server = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth });
  const grant = 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=desk-console",
    }),
  );
  const { access_token } = await grant.json();
  expect(await whoami(auth, access_token, server)).toMatchObject({ user: { sub: expect.stringMatching(/^anon:/) }, isAnonymous: true, scopes: ["tickets:read"], mode: "public" });
});
```

```ts tokens.ts hidden
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

### Calling as a user from tests and scripts

In-process, the code that calls the server says who the user is. `create()` and `createDirect()` take an `authContext` per call, and `connect()` a `session` per client:

```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, roles, permissions } = this.auth;
    return { sub: user.sub, isAnonymous, scopes, roles, permissions };
  }
}
```

```ts in-process.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, connect, create } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

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

test("create(): authContext.user is the caller, with authContext.scopes", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [WhoAmI] });
  const nour = { user: { sub: "nour", roles: ["agent"], permissions: ["tickets:export"] }, scopes: ["tickets:write"] };
  const asNour = await server.callTool("whoami", {}, { authContext: nour });
  // a `scope` claim in user isn't read
  const fromClaim = await server.callTool("whoami", {}, { authContext: { user: { sub: "sam", scope: "tickets:write" } } });
  const nobody = await server.callTool("whoami", {});
  await server.dispose();
  expect(asNour.structuredContent).toEqual({ sub: "nour", isAnonymous: false, scopes: ["tickets:write"], roles: ["agent"], permissions: ["tickets:export"] });
  expect(fromClaim.structuredContent).toMatchObject({ sub: "sam", scopes: [] });
  expect(nobody.structuredContent).toEqual({ sub: "direct", isAnonymous: false, scopes: [], roles: [], permissions: [] });
});

test("connect(): session.user is the caller, with session.scopes", async () => {
  const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };

  const asSam = await connect(config, { session: { user: { sub: "sam", roles: ["agent"] }, scopes: ["tickets:read"] } });
  const anonymous = await connect(config, { authToken: "anything" }); // not checked, not read
  const sam = await asSam.callTool("whoami", {});
  const nobody = await anonymous.callTool("whoami", {});
  await asSam.close();
  await anonymous.close();
  expect(sam).toMatchObject({ structuredContent: { sub: "sam", isAnonymous: false, scopes: ["tickets:read"], roles: ["agent"] } });
  expect(nobody).toMatchObject({ structuredContent: { sub: "", isAnonymous: true, scopes: [] } });
});
```

`create()` and `createDirect()` give a caller the scopes in `authContext.scopes`, and `connect()` the scopes in `session.scopes`, so a test can check a tool's `hasScope()` in-process. A `scope` claim in `user` isn't read by either. (Changed in 1.9.2: before, in-process callers never had scopes. Changed in 1.9.3: `connect()` takes `session.scopes`; before, its callers always had `[]`.) [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) covers `create()`, `createDirect()` and `connect()`.

### The caller in resources, agents and jobs

`ResourceContext`, `AgentContext` and `JobContext` have the same `this.auth`. In a job, it's the caller who started the run.

<Examples title="Other entries">

#### Example: Resource
```ts my-tickets.resource.ts active
import { Resource, ResourceContext } from "@frontmcp/sdk";

const assigned: Record<string, string[]> = { nour: ["T-1", "T-3"] };

@Resource({ name: "my_tickets", uri: "me://tickets", mimeType: "application/json", description: "Tickets assigned to the caller" })
export class MyTickets extends ResourceContext {
  async execute(uri: string) {
    const { user, isAnonymous } = this.auth;
    const tickets = isAnonymous ? [] : (assigned[user.sub] ?? []);
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ caller: user.sub, tickets }) }] };
  }
}
```

```ts my-tickets.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { MyTickets } from "./my-tickets.resource";

test("an anonymous caller has no tickets", async ({ mcp }) => {
  const body = JSON.parse((await mcp.resources.read("me://tickets")).text()!);
  expect(body).toEqual({ caller: expect.stringMatching(/^anon:/), tickets: [] });
});

test("nour gets her own", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, resources: [MyTickets] });
  const result = await server.readResource("me://tickets", { authContext: { user: { sub: "nour" } } });
  await server.dispose();
  expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ caller: "nour", tickets: ["T-1", "T-3"] });
});
```

#### Example: Job
```ts export.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";

@Job({ name: "export-my-tickets", description: "Export the caller's tickets", inputSchema: {}, outputSchema: { owner: z.string(), anonymous: z.boolean() } })
export class ExportMyTickets extends JobContext {
  async execute() {
    this.log(`Exporting for ${this.auth.user.sub}`);
    return { owner: this.auth.user.sub, anonymous: this.auth.isAnonymous };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { ExportMyTickets } from "./export.job";

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

```ts export.test.ts
import { test, expect } from "@frontmcp/testing";

test("the job runs as the caller who started it", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "export-my-tickets", input: {} });
  expect(result.json().result).toEqual({ owner: expect.stringMatching(/^anon:/), anonymous: true });
});
```

#### Example: Agent
```ts triage.agent.ts active
import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk";
import { model } from "./model.example";

@Agent({
  name: "triage",
  description: "Suggest a priority for a new support ticket. Signed-in agents only.",
  systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.",
  inputSchema: { query: z.string().describe("What the customer wrote") },
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { query: string }) {
    if (this.auth.isAnonymous) {
      // checked before the loop, so a refused call costs no model call
      this.fail(new PublicMcpError("Triage is for signed-in agents. Ask the user to sign in.", "SIGN_IN_REQUIRED"));
    }
    return super.execute(input);
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion() {
    return { content: "High: the customer can't work.", finishReason: "stop" };
  },
};
```

```ts triage.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

test("an anonymous caller is refused before the model runs", async ({ mcp }) => {
  expect(await mcp.tools.call("invoke_triage", { query: "Nobody can log in." })).toBeError("SIGN_IN_REQUIRED");
});

test("a signed-in agent gets an answer", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const result = await server.callTool("invoke_triage", { query: "Nobody can log in." }, { authContext: { user: { sub: "nour" } } });
  await server.dispose();
  expect(result.structuredContent).toEqual({ response: "High: the customer can't work." });
});
```

### Adding fields with pipes

`authorities.pipes` turns claims into fields of your own on `this.auth`. Each pipe gets the caller's claims and returns an object, at once or as a promise, and FrontMCP adds its fields before any hook or `execute()` reads `this.auth`, in tools, resources, prompts, agents and jobs. Declare the fields on `ExtendFrontMcpAuthContext` to give them types:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { planOf } from "./plans";

declare global {
  interface ExtendFrontMcpAuthContext {
    tenantId: string;
    plan: "enterprise" | "free";
  }
}

@Tool({ name: "whoami", description: "Say which tenant the caller belongs to, and its plan", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { sub: this.auth.user.sub, tenantId: this.auth.tenantId, plan: this.auth.plan };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: {
    profiles: {},
    pipes: [
      (claims: Readonly<Record<string, unknown>>) => ({ tenantId: String(claims.tenant ?? "none") }),
      async (claims: Readonly<Record<string, unknown>>) => ({ plan: await planOf(String(claims.tenant ?? "")) }),
    ],
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts plans.ts
// Stands in for a lookup in the billing database.
export async function planOf(tenant: string): Promise<"enterprise" | "free"> {
  return tenant === "acme" ? "enterprise" : "free";
}
```

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

test("a caller without the claim gets the defaults", async ({ mcp }) => {
  expect((await mcp.tools.call("whoami", {})).json()).toMatchObject({ tenantId: "none", plan: "free" });
});

test("a signed-in caller's claims fill the fields in", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  try {
    const result = await server.callTool("whoami", {}, { authContext: { user: { sub: "nour", tenant: "acme" } } });
    expect(result.structuredContent).toEqual({ sub: "nour", tenantId: "acme", plan: "enterprise" });
  } finally {
    await server.dispose();
  }
});
```

The Playground's caller has no token, so its claims have no `tenant`, and the pipes fall back. In-process, the claims are `authContext.user`, as the second test shows. A pipe that does a lookup runs on every request, so keep it fast, or cache what it finds. Changed in 1.9: before, FrontMCP accepted `pipes` and never ran them, so the fields were always `undefined`.

### Reading the caller in a prompt

A prompt has the same `this.auth` as a tool, so it can shape its messages for the caller:

```ts handover.prompt.ts active
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "handover_note",
  description: "Write a handover note for a ticket",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class HandoverNote extends PromptContext {
  async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
    const { user, isAnonymous, scopes } = this.auth;
    const from = isAnonymous ? "a colleague" : (user.name ?? user.sub);
    const text = `Write a handover note for ticket ${id}, from ${from}. Scopes: ${scopes.join(", ")}.`;
    return { messages: [{ role: "user", content: { type: "text", text } }] };
  }
}
```

```ts handover.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { HandoverNote } from "./handover.prompt";

test("an anonymous caller is a colleague", async ({ mcp }) => {
  const result = await mcp.prompts.get("handover_note", { id: "T-1" });
  expect(result.messages[0].content.text).toBe("Write a handover note for ticket T-1, from a colleague. Scopes: anonymous.");
});

test("a signed-in caller is named", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, prompts: [HandoverNote] });
  const result = await server.getPrompt("handover_note", { id: "T-1" }, { authContext: { user: { sub: "nour", name: "Nour Haddad" }, scopes: ["tickets:write"] } });
  await server.dispose();
  expect(result.messages[0].content).toMatchObject({ text: "Write a handover note for ticket T-1, from Nour Haddad. Scopes: tickets:write." });
});
```

`PromptContext` also still has `this.authInfo`, the raw result of authentication, which is deprecated: its shape [differs between entry points](https://frontmcp.dev/reference/sdk/context#authinfo), and `claimsMapping` doesn't apply to it. To apply the rule of `isAnonymous` to a `sub` you hold, use `isAnonymousSubject(sub)`, exported by `@frontmcp/sdk`: `true` for a missing or empty `sub`, and for one that starts with `anon:`. Changed in 1.9.2: before, prompts had no `this.auth`, and `this.authInfo` was the way to read the caller.

---

## Troubleshooting

### `hasScope()` is `false` though the caller has the scope

Check, in order:

1. **Is the call in-process?** `create()` and `createDirect()` give a caller only the scopes in `authContext.scopes`, and `connect()` none. See [Calling as a user](#calling-as-a-user-from-tests-and-scripts).
2. **Is it the exact string?** `hasScope()` doesn't match prefixes or wildcards: `tickets` isn't `tickets:read`.

### `this.auth.roles` is empty, though the token has roles

Without a mapping, `this.auth.roles` reads only a top-level `roles` claim. Point `authorities.claimsMapping.roles` at the claim your provider uses, as in [Reading roles from another claim](#reading-roles-from-another-claim). In-process, put `roles` in `authContext.user`.

### `this.auth.user.sub` is `"direct"`

The tool was called with `create()` or `createDirect()` without an `authContext`. FrontMCP treats that as a signed-in user called `direct`, with `isAnonymous: false`. Pass `authContext: { user: { sub } }` on each call, or check the scopes or roles you need rather than `isAnonymous`.

### `this.auth.user.sub` is `""`

The tool was called through `connect()` without a `session.user`, or the code runs in a channel's `onEvent()`, which has no caller. Both count as anonymous.

### `this.auth.user.sub` is different on every call

The caller is anonymous. Under MCP 2026-07-28 each request from a caller without a token gets a new `anon:` id, so it can't be used to recognize a returning caller, or to keep anything per user. Require a sign-in for tools that need to.

### `this.auth.mode` is `"authenticated"` in public mode

FrontMCP 1.9.3 sets `mode` to `"authenticated"` for callers without a token, in every auth mode, and for every signed-in caller. Only a caller holding an [anonymous token](#anonymous-tokens) gets `"public"`. Use `isAnonymous` to tell signed-in callers from others.

### A field from `authorities.pipes` is `undefined`

Check, in order:

1. **Did the pipe throw?** FrontMCP logs `[FrontMcpAuth] pipe failed: ` and the error's message, leaves that pipe's fields out, and goes on with the request. The other pipes' fields are there.
2. **Is the claim there?** A pipe gets the caller's claims: a token's, `authContext.user` in-process, and for callers without a token only `iss`, `sub`, `name` and `scope`. With a `claimsResolver`, it gets the `claims` the resolver returns instead. Give a field a default for a claim that's missing, as `tenantId` does in [Adding fields with pipes](#adding-fields-with-pipes).
