# Authorities

> Rules on tools, resources, prompts, skills and agents that decide who may call them, from roles, permissions, token claims, the call's arguments or your own code, and what a caller the rules refuse gets.

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

`authorities` puts an access rule on a tool, resource, resource template, prompt, skill or agent. Before the entry runs, FrontMCP checks the rule against the caller's token: a caller who fails it gets `AUTHORITY_DENIED` and never reaches your code, and the entry is left out of their `tools/list` (or `resources/list`, `prompts/list`, `skills/list`). The rules are named once, as profiles, in `@FrontMcp({ authorities })`, next to where the caller's roles and permissions are in their token. [Deciding Who Can Call What](https://frontmcp.dev/learn/authorizing-calls) introduces them; this page has every option.

```ts
@FrontMcp({ authorities: { profiles, claimsMapping, claimsResolver, evaluators, relationshipResolver, scopeMapping } })
@Tool({ authorities: "agent" })                  // a profile
@Tool({ authorities: ["agent", "acme"] })        // every profile
@Tool({ authorities: { roles, permissions, attributes, relationships, guards, custom, operator, allOf, anyOf, not } })
```

---

## Reference

### `@FrontMcp({ authorities })`

The server's authorization settings: the named rules, and where to find what they check in the caller's token.

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com" },
  authorities: {
    claimsMapping: { roles: "roles", permissions: "scope" },
    profiles: {
      agent: { roles: { any: ["agent", "admin"] } },
      admin: { roles: { any: ["admin"] } },
    },
  },
})
export default class Server {}
```

[See more examples below.](#usage)

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `profiles` | `Record<string, Rule>` | `{}` | Named [rules](#rules) that entries refer to by name. None are built in: a profile such as `authenticated` or `admin` exists only if you define it. |
| `claimsMapping` | `{ roles?, permissions?, userId?, [key]: string }` | top-level `roles` and `permissions` claims | Where the caller's roles and permissions are in their token's claims. Each value is a claim name or a dot path, like `"realm_access.roles"`; a claim whose own name has dots, like `"https://desk.example.com/roles"`, is found by its full name first. A string claim is split on spaces, so `permissions: "scope"` turns scopes into permissions. `userId` names the claim that becomes the caller's id instead of `sub`. Other keys, like `tenantId`, are accepted and not used. [`this.auth`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth) reads the same mapping. |
| `claimsResolver` | `(authInfo) => { roles, permissions, claims }` | none | Works out the caller's roles, permissions and claims in code, from [`authInfo`](https://frontmcp.dev/reference/sdk/context#authinfo). Replaces `claimsMapping`, for rules and for `this.auth`: see [Working out roles in code](#working-out-roles-in-code). |
| `evaluators` | `Record<string, { name, evaluate(policy, ctx) }>` | none | Checks of your own, which a rule names under [`custom`](#custom-checks-and-relationships). |
| `relationshipResolver` | `{ check(type, resource, resourceId, userSub, ctx) }` | none | Answers a rule's [`relationships`](#custom-checks-and-relationships), such as "is this user the assignee of ticket T-1?". Without one, every relationship check fails. |
| `scopeMapping` | `{ roles?, permissions?, profiles? }` | none | Maps a failed role, permission or profile to OAuth scopes, like `{ profiles: { admin: ["desk:admin"] } }`. A refused resource, prompt or skill then carries them as `requiredScopes` in its error's `data`. A refused tool call doesn't. |
| `pipes` | `((claims) => object \| Promise<object>)[]` | none | Add fields of your own to `this.auth`, from the caller's claims: see [Adding fields to `this.auth`](#adding-fields-to-thisauth). Since 1.9.0; before, they never ran. |

A server with any tool, resource, resource template, prompt, skill or agent that declares `authorities`, and no `authorities` option, doesn't start: [`Authorities configuration required`](#authorities-configuration-required-tool--declare-authorities-metadata). So do the tools declared inside an `@Agent`. FrontMCP refuses rather than leave the entry open. It also checks every rule, on entries and in `profiles`, when the server starts, and refuses to start on one that checks nothing or names a profile that isn't in `profiles`: see [Rules that check nothing](#rules-that-check-nothing).

### `authorities` on an entry

`@Tool`, `@Resource`, `@ResourceTemplate`, `@Prompt`, `@Skill` and `@Agent` accept `authorities` in one of three forms:

| Form | Example | Passes when |
| --- | --- | --- |
| A profile name | `authorities: "agent"` | The profile passes. |
| A list of profile names | `authorities: ["agent", "acme"]` | Every profile passes. They're checked in order, and the first that fails is the reason given. |
| A rule | `authorities: { roles: { any: ["admin"] } }` | The rule passes. |

For a tool, the rule is checked in `tools/call` after the tool is found and before its arguments are validated, and in `tools/list` for every tool. The same goes for `resources/read` and `resources/list` (templates too), `prompts/get` and `prompts/list`, and for skills in `skills/list`, `skills/search`, `skills/load`, `skill://index.json`, the skill's `SKILL.md` and the [HTTP endpoints](#skills-over-http). An agent's rule is checked on its `invoke_<agent>` tool, in `tools/list` and `tools/call`, like a tool's.

`@Job` and `@Workflow` don't take `authorities`; they have [`permissions`](https://frontmcp.dev/reference/sdk/job#permissions) of their own.

> **Note**
**Changed in 1.8.3.** `@Agent({ authorities })` is enforced; before, anyone could call the agent. The startup check now covers resource templates and the tools inside an agent, so a server that declares `authorities` on them without the `authorities` option no longer starts. A rule that checks nothing, like `{}`, `{ roles: {} }` or `allOf: []`, stops the server instead of letting everyone in, and a guard lets a caller in only by returning `true`. See [Rules that check nothing](#rules-that-check-nothing).

**Changed in 1.8.4.** An entry that names a profile missing from `profiles` stops the server at startup. Before, the server started, and the entry refused everyone.

### Rules

A rule is an object with any of these fields. With more than one, **all** must pass, unless `operator` is `"OR"`.

| Field | Type | Passes when |
| --- | --- | --- |
| `roles` | `{ all?: string[], any?: string[] }` | The caller has every role in `all` and at least one in `any`. |
| `permissions` | `{ all?: string[], any?: string[] }` | The same, for the caller's permissions. |
| `attributes` | `{ match?, conditions? }` | Every pair in `match` is equal, and every [condition](#attribute-conditions) holds. `match: { "claims.tenant": "acme" }` is the short form of an `eq` condition. |
| `relationships` | `{ type, resource, resourceId }` or a list of them | `relationshipResolver.check()` returns `true` for each. `resourceId` is a string, `{ fromInput: "id" }` or `{ fromClaims: "org" }`. Anonymous callers always fail, without a call to the resolver. |
| `guards` | `((ctx) => boolean \| string \| Promise<…>)[]` | Every function returns `true`. A string refuses with that string as the reason, and `false` or **anything else refuses**, `undefined` included. They run in order and stop at the first refusal. See [Checking with your own code](#checking-with-your-own-code). |
| `custom` | `Record<string, unknown>` | For each key, the evaluator of that name in `evaluators` grants the value it's given. |
| `operator` | `"AND"` \| `"OR"` | `"OR"`: at least one of the other fields passes. Default `"AND"`. |
| `allOf` | `Rule[]` | Every rule passes. |
| `anyOf` | `Rule[]` | At least one rule passes. |
| `not` | `Rule` | The rule fails. Anonymous callers pass most `not` rules, since they have no roles: combine it with a check that the caller signed in. |

Roles, permissions and claims come from the token, through `claimsMapping` or `claimsResolver`. Every comparison is exact and case-sensitive, and nothing expands wildcards.

FrontMCP checks the shape of every rule when the server starts, those in `profiles` too, and refuses to start on one that checks nothing: no field it knows, an unknown field, an empty list or object (`roles: {}`, `allOf: []`, `guards: []`), a profile name inside `allOf` or `anyOf`, which hold rule objects, or an `operator` other than `"AND"` and `"OR"`. So does an entry that names a profile missing from `profiles`, like `authorities: "admn"`. The error names the entry and the problem: see [Rules that check nothing](#rules-that-check-nothing).

#### Attribute conditions

A condition is `{ path, op, value }`. `path` is a dot path into what FrontMCP knows about the call:

| Path | Holds |
| --- | --- |
| `user.sub` | The caller's id. Missing for anonymous callers, so `{ path: "user.sub", op: "exists", value: true }` refuses them. |
| `user.roles`, `user.permissions` | The caller's roles and permissions, as rules see them. |
| `claims.…` | The token's claims, like `claims.tenant` or `claims.realm_access.roles`. An anonymous caller has a few made-up ones, [listed on `this.auth`](https://frontmcp.dev/reference/sdk/auth#what-each-auth-mode-fills-in). |
| `input.…` | The call's arguments, **as the client sent them**: before validation and before defaults are filled in. Only tools have them. Resources, prompts, skills and every list see `{}`. |
| `env.…` | The server's environment variables, like `env.NODE_ENV`, read when the rule is checked. A refusal shows `[redacted]` instead of the value. |

`value` is a literal, or `{ fromInput: "site" }` for an argument, or `{ fromClaims: "tenant" }` for a claim.

| `op` | Holds when the value at `path`… |
| --- | --- |
| `eq`, `neq` | …is, or isn't, strictly equal to `value`. `eq` never holds when `value` is missing; `neq` does, so compare against an argument the schema requires. |
| `in`, `notIn` | …is, or isn't, in the array `value`. |
| `gt`, `gte`, `lt`, `lte` | …compares so with `value`. Both must be numbers. |
| `contains` | …is a string that contains `value`, or an array with `value` in it. |
| `startsWith`, `endsWith` | …is a string that starts or ends with `value`. |
| `exists` | …is present and not `null` (`value: true`), or missing or `null` (`value: false`). |
| `matches` | …is a string that the regular expression `value` matches. A pattern over 256 characters, or with a nested quantifier like `++` or `**`, never matches. |

A condition with another `op`, or whose `value` is missing or can't be compared, like a string for `gt` or an empty list for `in`, stops the server when it starts: `Invalid authorities rule: Tool "list_tickets": authorities.attributes.conditions[0].value must be a number for "gt"`.

> **Note**
A rule that reads `input.`, `{ fromInput }` or `relationships` can only pass when there's a call. Lists have no arguments, so the rule fails there, and the entry disappears from the list **for every caller**, including those who may call it. [Rules on the call's arguments](#rules-on-the-calls-arguments) shows how to keep such a tool listed.

#### The context guards receive

Guards, evaluators and the relationship resolver get what the rule is checked against:

| Field | What it is |
| --- | --- |
| `ctx.user.sub` | The caller's id, `undefined` if they're anonymous. A caller with a [static key](https://frontmcp.dev/learn/authenticating-clients#requiring-a-shared-key-static-mode) (`static:…`) counts as signed in. |
| `ctx.user.roles`, `ctx.user.permissions` | As rules see them. |
| `ctx.user.claims` | The token's claims. |
| `ctx.input` | The call's arguments as sent, or `{}`. |
| `ctx.env` | The server's environment variables, read by name, like `ctx.env.NODE_ENV`. It can't be listed: `Object.keys(ctx.env)` is `[]`. |
| `ctx.relationships` | The `relationshipResolver`. |

### What a refused caller gets

A tool call gets an ordinary tool error, with HTTP `200`, so the model reads it like any other failure:

```json
{
  "content": [{ "type": "text", "text": "Access denied to Tool \"help-desk:close_ticket\": profile:agent: roles.any: user has none of 'agent', 'admin'" }],
  "isError": true,
  "_meta": { "code": "AUTHORITY_DENIED", "errorId": "err_…" }
}
```

`resources/read`, `prompts/get` and `skills/load` get a JSON-RPC error with code `-32003`, the same message, and the details in `data`:

```json
{
  "code": -32003,
  "message": "Access denied to Resource \"help-desk:escalation_policy\": profile:admin: roles.any: user has none of 'admin'",
  "data": {
    "entryType": "Resource",
    "entryName": "help-desk:escalation_policy",
    "deniedBy": "profile:admin: roles.any: user has none of 'admin'",
    "denial": { "kind": "roles", "path": "roles.any", "missing": ["admin"] },
    "errorId": "err_…"
  }
}
```

The message names the entry (`<app id>:<name>`) and the part of the rule that failed:

| Reason | When |
| --- | --- |
| `profile:<name>: …` | A profile failed; the rest is why. |
| `roles.all: missing 'lead'`, `roles.any: user has none of 'agent', 'admin'` | A `roles` rule. `permissions.all`, `permissions.any` read the same. |
| `attributes.match: 'claims.tenant' expected 'acme' but got 'globex'` | A `match` pair. For an `env.` path, the value is `[redacted]`. |
| `attributes.conditions: 'claims.sites' failed 'contains' check against 'lisbon'` | A condition. |
| `guards[0]: <your string>`, `guards[0]: guard[0] denied`, `guards[0]: guard[0] did not return true (returned undefined)` | A guard returned a string, `false`, or anything else but `true`. |
| `relationships: user 'sam' is not 'assignee' of ticket:T-1`, `relationships: an anonymous caller cannot be 'assignee' of ticket` | A relationship. |
| `<the evaluator's deniedBy>` | A `custom` evaluator. |
| `not: inner policy was granted (negated to denied)` | `not`. |
| `custom evaluator 'x' is not registered` | A `custom` key with no evaluator of that name. Checked only when a call is made. (A profile name the server doesn't know stops it at startup.) |

The text is kept word for word in production, which tells a caller which roles would have let them in. It's written for developers, not for the model: [Deciding Who Can Call What](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool) explains when a check in `execute()`, with a message for the model, serves callers better.

#### Caveats

- **Rules run before validation.** `input.` and `fromInput` see the arguments as the client sent them, so a default in the schema isn't there yet, and an argument can have any type. See [What a rule can't see](#what-a-rule-cant-see).
- **A guard that throws fails the call**, with `SERVER_ERROR` (its message is hidden in production). If it throws while `tools/list` is checked, the whole list fails, for every caller. Catch errors in guards that call other services, and decide what an error means.
- **Rules follow the caller.** A tool that calls another with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) is checked against the same caller, so it can't reach what the caller couldn't.
- **In-process servers:** [`createDirect()`](https://frontmcp.dev/reference/sdk/create) checks rules, against the `authContext.user` of each call (without one, the caller is `direct`, with no roles), and its `callTool()` throws the refusal. [`create()`](https://frontmcp.dev/reference/sdk/create) checks them the same way (before 1.9.3 it dropped the `authorities` option, so a server with rules didn't start under it).
- **`this.auth` and rules agree.** Both read `claimsMapping`, or, when there is one, what `claimsResolver` returns.
- **Scopes mean what their issuer checked.** In `local` and `remote` modes, a token's `scope` is what the client asked for, limited to the server's `allowedScopes`: any client can get any allowed scope, for any user, so a rule on scopes (through `permissions: "scope"`) can't tell users apart there. Use roles or other claims that [your sign-in](https://frontmcp.dev/reference/auth/local) puts in the token. Scopes from your own identity provider, in `transparent` mode, are as good as its checks.

---

## Usage

Most examples on this page use a help desk whose users sign in with an identity provider, in [`transparent` mode](https://frontmcp.dev/learn/authenticating-clients#accepting-tokens-from-your-identity-provider), which also lets callers without a token in, as anonymous callers. The Playground's own client is one of those. The tests call as four users, with tokens signed ahead of time: `nour` and `lin` are agents at two companies, `sam` is a customer, `ada` an admin. The `tokens.ts` tab holds their tokens, and `client.ts` a small helper that calls the server over HTTP with one.

### Naming rules as profiles

Profiles name each rule once, and tools refer to them. `search_tickets` has no rule, so everyone may search; closing needs an agent, deleting an admin, and exporting the `tickets:export` scope, which `claimsMapping` turns into a permission:

```ts tickets.tools.ts active
import { 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: [{ id: "T-1", title: `Cannot ${query}` }] };
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket. Agents only.", inputSchema: { id: z.string() }, authorities: "agent" })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed", by: this.auth.user.sub };
  }
}

@Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Admins only.", inputSchema: { id: z.string() }, authorities: "admin" })
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every ticket as CSV. Needs the tickets:export scope.",
  inputSchema: {},
  authorities: { permissions: { all: ["tickets:export"] } },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, DeleteTicket, ExportTickets, SearchTickets } from "./tickets.tools";
import { JWKS } from "./tokens";

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket, DeleteTicket, ExportTickets] })
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"],
    providerConfig: { jwks: JWKS },
  },
  authorities: {
    claimsMapping: { roles: "roles", permissions: "scope" },
    profiles: {
      agent: { roles: { any: ["agent", "admin"] } },
      admin: { roles: { any: ["admin"] } },
    },
  },
};

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

```ts tokens.ts
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts profiles.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs, toolsFor } from "./client";
import { ADA, NOUR, SAM } from "./tokens";

test("each caller's tools/list has only what they may call", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["search_tickets"]);
  expect(await toolsFor(SAM)).toEqual(["search_tickets"]);
  expect(await toolsFor(NOUR)).toEqual(["close_ticket", "search_tickets"]);
  expect(await toolsFor(ADA)).toEqual(["close_ticket", "delete_ticket", "export_tickets", "search_tickets"]);
});

test("calling by name is refused before execute() runs", async () => {
  const result = await callAs(SAM, "close_ticket", { id: "T-1" });
  expect(result).toMatchObject({ isError: true, _meta: { code: "AUTHORITY_DENIED" } });
  expect(result.content[0].text).toBe(`Access denied to Tool "help-desk:close_ticket": profile:agent: roles.any: user has none of 'agent', 'admin'`);
});

test("an agent closes, an admin exports", async () => {
  expect((await callAs(NOUR, "close_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", status: "closed", by: "nour" });
  expect((await callAs(NOUR, "export_tickets")).content[0].text).toContain("permissions.all: missing 'tickets:export'");
  expect((await callAs(ADA, "export_tickets")).isError).toBeUndefined();
});
```

The anonymous caller's `tools/list` has only `search_tickets` (open the **Capabilities** tab), and the call above is refused although the tool exists. `ada` passes `agent` too, because the profile accepts either role.

### Typing profile names

A profile name is a string, so `authorities: "admn"` compiles, and only the check when the server starts catches the typo. FrontMCP 1.9.4 declares a global interface for profile names, `FrontMcpAuthorityProfiles`, but with a string index signature, so adding your names to it narrows nothing: every string still type-checks, and an editor suggests no names. Type them yourself. Keep the profiles in one object, take a type from its keys, and check each name against it with `satisfies`:

```ts profiles.ts active
// The rules, once: the server gets this object, and tools get the type of its keys.
export const profiles = {
  agent: { roles: { any: ["agent", "admin"] } },
  admin: { roles: { any: ["admin"] } },
};

export type Profile = keyof typeof profiles; // "agent" | "admin"
```

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

@Tool({ name: "close_ticket", description: "Close a support ticket. Agents only.", inputSchema: { id: z.string() }, authorities: "agent" satisfies Profile })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed", by: this.auth.user.sub };
  }
}

@Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Admins only.", inputSchema: { id: z.string() }, authorities: ["agent", "admin"] satisfies Profile[] })
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { profiles } from "./profiles";
import { CloseTicket, DeleteTicket } from "./tickets.tools";
import { JWKS } from "./tokens";

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

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

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

```ts typed.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk";
import { callAs } from "./client";
import { config } from "./main";
import { ADA, NOUR } from "./tokens";

test("the profiles from the object apply", async () => {
  expect((await callAs(NOUR, "close_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", status: "closed", by: "nour" });
  expect((await callAs(NOUR, "delete_ticket", { id: "T-1" })).content[0].text).toContain("profile:admin: roles.any: user has none of 'admin'");
  expect((await callAs(ADA, "delete_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", deleted: true });
});

test("a name the compiler didn't check still stops the server", async () => {
  // The Playground compiles without type checks, so this typo gets as far as the server
  @Tool({ name: "purge_tickets", description: "Delete every closed ticket.", inputSchema: {}, authorities: "admn" })
  class PurgeTickets extends ToolContext {
    async execute() {
      return { purged: 0 };
    }
  }
  @App({ id: "maintenance", name: "Maintenance", tools: [PurgeTickets] })
  class Maintenance {}
  await expect(FrontMcpInstance.createFetchHandler({ ...config, apps: [Maintenance] })).rejects.toThrow('authorities names an unknown profile "admn"');
});
```

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

With `satisfies Profile`, `tsc` refuses the typo, `Type '"admn"' does not satisfy the expected type '"agent" | "admin"'`, and a list with one, `Type '"admim"' is not assignable to type '"agent" | "admin"'. Did you mean '"admin"'?`. An editor offers `agent` and `admin` inside the quotes. Because the server's `profiles` is the same object, a profile you add or rename changes the type too. The Playground compiles without type checks, so those errors were checked with `tsc` and TypeScript's language service against 1.9.4's types, as was the interface: with `declare global { interface FrontMcpAuthorityProfiles { agent: true; admin: true } }`, `authorities: "admn"` still compiles, and no names are offered.

### Combining rules

A list of profiles requires all of them. `anyOf`, `allOf`, `not` and `operator: "OR"` combine rules in place. Here `acme` checks a claim, so `lin`, an agent at another company, can't reassign acme's tickets; exporting takes the admin role or the export scope; and rating is for signed-in customers, not staff:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets, RateSupport, ReassignTicket } from "./tickets.tools";
import { JWKS } from "./tokens";

@App({ id: "help-desk", name: "Help Desk", tools: [ReassignTicket, ExportTickets, RateSupport] })
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"],
    providerConfig: { jwks: JWKS },
  },
  authorities: {
    claimsMapping: { roles: "roles", permissions: "scope" },
    profiles: {
      authenticated: { attributes: { conditions: [{ path: "user.sub", op: "exists" as const, value: true }] } },
      agent: { roles: { any: ["agent", "admin"] } },
      acme: { attributes: { match: { "claims.tenant": "acme" } } },
      customer: { not: { roles: { any: ["agent", "admin"] } } },
    },
  },
};

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

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

@Tool({ name: "reassign_ticket", description: "Give a ticket to another agent.", inputSchema: { id: z.string(), to: z.string() }, authorities: ["agent", "acme"] })
export class ReassignTicket extends ToolContext {
  async execute({ id, to }: { id: string; to: string }) {
    return { id, assignee: to };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every ticket as CSV.",
  inputSchema: {},
  authorities: { anyOf: [{ roles: { any: ["admin"] } }, { permissions: { all: ["tickets:export"] } }] },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}

@Tool({ name: "rate_support", description: "Rate the support you got, from 1 to 5 stars.", inputSchema: { stars: z.number().int().min(1).max(5) }, authorities: ["authenticated", "customer"] })
export class RateSupport extends ToolContext {
  async execute({ stars }: { stars: number }) {
    return { stars, from: this.auth.user.sub };
  }
}
```

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts combining.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs, toolsFor } from "./client";
import { ADA, LIN, NOUR, SAM } from "./tokens";

test("who sees what", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual([]);
  expect(await toolsFor(SAM)).toEqual(["rate_support"]);
  expect(await toolsFor(NOUR)).toEqual(["reassign_ticket"]);
  expect(await toolsFor(LIN)).toEqual([]);
  expect(await toolsFor(ADA)).toEqual(["export_tickets", "reassign_ticket"]);
});

test("the first profile that fails is the reason", async () => {
  const lin = await callAs(LIN, "reassign_ticket", { id: "T-1", to: "lin" });
  expect(lin.content[0].text).toContain("profile:acme: attributes.match: 'claims.tenant' expected 'acme' but got 'globex'");
  const sam = await callAs(SAM, "reassign_ticket", { id: "T-1", to: "sam" });
  expect(sam.content[0].text).toContain("profile:agent: roles.any");
});

test("not refuses staff, and authenticated refuses anonymous callers", async ({ mcp }) => {
  expect((await callAs(SAM, "rate_support", { stars: 5 })).structuredContent).toEqual({ stars: 5, from: "sam" });
  expect((await callAs(NOUR, "rate_support", { stars: 5 })).content[0].text).toContain("not: inner policy was granted");
  expect((await mcp.tools.call("rate_support", { stars: 5 })).text()).toContain("profile:authenticated: attributes.conditions: 'user.sub' failed 'exists' check");
});
```

Without `authenticated`, `customer` alone would let anonymous callers rate: they have no roles, so `not` passes for them.

### Refusing anonymous callers

No profile is built in. The one most servers need is `authenticated`, which passes for any caller who signed in:

```ts
profiles: {
  authenticated: { attributes: { conditions: [{ path: "user.sub", op: "exists", value: true }] } },
}
```

It works because an anonymous caller has no `user.sub` in a rule, though `this.auth.user.sub` is `anon:…` for them. A caller with a [static key](https://frontmcp.dev/learn/authenticating-clients#requiring-a-shared-key-static-mode) counts as signed in, as does an in-process call through `createDirect()` without an `authContext`, whose caller is `direct`:

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

@Tool({ name: "add_note", description: "Add a note to a ticket. Signed-in users only.", inputSchema: { ticket: z.string(), text: z.string() }, authorities: "authenticated" })
export class AddNote extends ToolContext {
  async execute({ ticket, text }: { ticket: string; text: string }) {
    return { ticket, text, author: this.auth.user.sub };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: {
    profiles: {
      authenticated: { attributes: { conditions: [{ path: "user.sub", op: "exists" as const, value: true }] } },
    },
  },
};

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

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

const note = { ticket: "T-1", text: "Called back." };

test("an anonymous caller neither sees nor calls it", async ({ mcp }) => {
  expect(await mcp.tools.list()).toHaveLength(0);
  expect(await mcp.tools.call("add_note", note)).toBeError("AUTHORITY_DENIED");
});

test("a static key counts as signed in", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, auth: { mode: "static", tokens: ["desk-key-1"] } });
  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": "add_note", authorization: "Bearer desk-key-1" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "add_note", arguments: note, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  expect((await response.json()).result.structuredContent.author).toMatch(/^static:/);
});

test("so does createDirect() without an authContext", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const result = await server.callTool("add_note", note);
  await server.dispose();
  expect(result.structuredContent).toEqual({ ...note, author: "direct" });
});

test("create() won't start a server with rules", async () => {
  await expect(create({ info: { name: "help-desk", version: "1.0.0" }, tools: [AddNote] })).rejects.toThrow("Authorities configuration required");
});
```

A server in `public` mode has only anonymous callers, so `authenticated` refuses everyone there.

### Rules on the call's arguments

A customer or an agent may only see tickets of their own sites, which their token lists in a `sites` claim. A condition can compare the claim with the argument:

```ts
authorities: { attributes: { conditions: [{ path: "claims.sites", op: "contains", value: { fromInput: "site" } }] } }
```

It refuses the right calls, but `tools/list` has no arguments to compare, so the tool is missing from every caller's list, and a model that doesn't see a tool won't call it. Keep in `authorities` what can be decided from the token, and check the argument in `execute()`, where a refusal can also tell the model which sites it may ask about:

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

// 🚩 Refuses the right calls, but no caller sees it in tools/list.
@Tool({
  name: "site_tickets_by_rule",
  description: "List the open tickets of one site.",
  inputSchema: { site: z.string() },
  authorities: { attributes: { conditions: [{ path: "claims.sites", op: "contains", value: { fromInput: "site" } }] } },
})
export class SiteTicketsByRule extends ToolContext {
  async execute({ site }: { site: string }) {
    return { site, tickets: ["T-1"] };
  }
}

// ✅ Listed for signed-in callers; the site is checked in execute().
@Tool({ name: "site_tickets", description: "List the open tickets of one of your sites.", inputSchema: { site: z.string() }, authorities: "authenticated" })
export class SiteTickets extends ToolContext {
  async execute({ site }: { site: string }) {
    const sites = Array.isArray(this.auth.claims.sites) ? this.auth.claims.sites : [];
    if (!sites.includes(site)) {
      this.fail(new PublicMcpError(`You can only see tickets for ${sites.join(", ")}. Ask about one of those sites.`, "FORBIDDEN"));
    }
    return { site, tickets: ["T-1"] };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { SiteTickets, SiteTicketsByRule } from "./tickets.tools";
import { JWKS } from "./tokens";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    providerConfig: { jwks: JWKS },
  },
  authorities: {
    profiles: {
      authenticated: { attributes: { conditions: [{ path: "user.sub", op: "exists" as const, value: true }] } },
    },
  },
};

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts sites.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs, toolsFor } from "./client";
import { NOUR, SAM } from "./tokens";

test("the rule on the argument refuses the right calls", async () => {
  expect((await callAs(NOUR, "site_tickets_by_rule", { site: "lisbon" })).structuredContent).toEqual({ site: "lisbon", tickets: ["T-1"] });
  const sam = await callAs(SAM, "site_tickets_by_rule", { site: "lisbon" });
  expect(sam.content[0].text).toContain("attributes.conditions: 'claims.sites' failed 'contains' check against 'lisbon'");
});

test("but hides the tool from everyone", async () => {
  expect(await toolsFor(NOUR)).toEqual(["site_tickets"]);
  expect(await toolsFor(SAM)).toEqual(["site_tickets"]);
});

test("✅ the check in execute() tells the model what it may ask", async () => {
  expect((await callAs(NOUR, "site_tickets", { site: "lisbon" })).structuredContent).toEqual({ site: "lisbon", tickets: ["T-1"] });
  const sam = await callAs(SAM, "site_tickets", { site: "lisbon" });
  expect(sam).toMatchObject({ isError: true, _meta: { code: "FORBIDDEN" } });
  expect(sam.content[0].text).toBe("You can only see tickets for berlin. Ask about one of those sites.");
});
```

The same split works for tenants: a profile like `acme` in [Combining rules](#combining-rules) decides from the token alone, so it can stay in `authorities`. A rule that compares a claim with an argument, like `{ path: "claims.tenant", op: "eq", value: { fromInput: "tenant" } }`, hides the tool the same way.

### What a rule can't see

A rule sees less than the tool it protects. Two things surprise people: `input.` holds the arguments as the client sent them, before the schema's defaults, and a missing argument passes `neq`. `env.` paths, by contrast, read the server's environment when the rule is checked, and a refusal hides their value:

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

// Only where NODE_ENV is "production". The Playground's isn't.
@Tool({ name: "export_report", description: "Export a report.", inputSchema: {}, authorities: { attributes: { match: { "env.NODE_ENV": "production" } } } })
export class ExportReport extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

// 🚩 A call without limit is refused: the rule sees no limit, not the default 20.
@Tool({
  name: "list_tickets",
  description: "List open tickets, at most 50.",
  inputSchema: { limit: z.number().max(50).default(20) },
  authorities: { attributes: { conditions: [{ path: "input.limit", op: "lte", value: 50 }] } },
})
export class ListTickets extends ToolContext {
  async execute({ limit }: { limit: number }) {
    return { limit };
  }
}

// 🚩 "Not to yourself" passes when `to` is left out.
@Tool({
  name: "reassign_ticket",
  description: "Give a ticket to someone else.",
  inputSchema: { id: z.string(), to: z.string().optional() },
  authorities: { attributes: { conditions: [{ path: "claims.name", op: "neq", value: { fromInput: "to" } }] } },
})
export class ReassignTicket extends ToolContext {
  async execute({ id, to }: { id: string; to?: string }) {
    return { id, to: to ?? null };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportReport, ListTickets, ReassignTicket } from "./tickets.tools";

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

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

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

test("an env. path reads the environment, and the refusal hides its value", async ({ mcp }) => {
  const result = await mcp.tools.call("export_report", {});
  expect(result.text()).toContain("attributes.match: 'env.NODE_ENV' expected 'production' but got '[redacted]'");
});

test("input. is what the client sent, before defaults", async ({ mcp }) => {
  expect((await mcp.tools.call("list_tickets", {})).text()).toContain("'input.limit' failed 'lte' check against '50'");
  expect((await mcp.tools.call("list_tickets", { limit: 10 })).json()).toEqual({ limit: 10 });
});

test("a missing argument passes neq", async ({ mcp }) => {
  // The Playground's anonymous caller has the claim name: "Anonymous".
  expect(await mcp.tools.call("reassign_ticket", { id: "T-1", to: "Anonymous" })).toBeError("AUTHORITY_DENIED");
  expect((await mcp.tools.call("reassign_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", to: null });
});
```

Compare arguments against values the schema requires, or check them in `execute()`, where they've been validated and defaulted. An `env.` rule reads the variable on every call, so a change to the server's environment applies at once. (Changed in 1.9.2: before, `env.` paths were always missing, and a rule on one refused everyone.)

### Checking with your own code

When the answer isn't in the token, a guard asks your code: a database, a feature flag, an on-call rota. It gets [the rule's context](#the-context-guards-receive), may be `async`, and returns `true`, or a string saying why not. Only `true` lets the caller in; `false`, a string, and [anything else](#rules-that-check-nothing) refuse:

```ts page.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { onCall } from "./rota";

@Tool({
  name: "page_engineering",
  description: "Page the engineer on call. Only the support agent on call may page.",
  inputSchema: { summary: z.string() },
  authorities: {
    roles: { any: ["agent"] },
    guards: [async ({ user }) => (await onCall(user.sub)) || "only the support agent on call can page engineering"],
  },
})
export class PageEngineering extends ToolContext {
  async execute({ summary }: { summary: string }) {
    return { paged: true, summary, by: this.auth.user.sub };
  }
}
```

```ts rota.ts
// Stands in for a lookup in your own database.
const rota = new Set(["nour"]);
export const lookups: (string | undefined)[] = [];

export async function onCall(sub: string | undefined): Promise<boolean> {
  lookups.push(sub);
  return sub !== undefined && rota.has(sub);
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { PageEngineering } from "./page.tool";
import { JWKS } from "./tokens";

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

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

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts guards.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { callAs, send, toolsFor } from "./client";
import { lookups } from "./rota";
import { LIN, NOUR } from "./tokens";

test("the agent on call can page", async () => {
  expect(await toolsFor(NOUR)).toEqual(["page_engineering"]);
  expect((await callAs(NOUR, "page_engineering", { summary: "Logins failing" })).structuredContent).toEqual({ paged: true, summary: "Logins failing", by: "nour" });
});

test("the guard runs even when roles already refused the caller", async ({ mcp }) => {
  lookups.length = 0;
  const result = await mcp.tools.call("page_engineering", { summary: "Logins failing" });
  expect(result.text()).toContain("roles.any: user has none of 'agent'");
  expect(lookups).toEqual([undefined]);
});

test("a guard that throws fails the call, and the whole tools/list", async () => {
  @Tool({ name: "flaky", description: "Uses a rota service that's down.", inputSchema: {}, authorities: { guards: [async () => { throw new Error("rota service unreachable"); }] } })
  class Flaky extends ToolContext {
    async execute() {
      return { ok: true };
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [Flaky] })
  class Desk {}
  const server = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], authorities: { profiles: {} } };
  expect(await callAs(undefined, "flaky", {}, server)).toMatchObject({ isError: true, _meta: { code: "SERVER_ERROR" } });
  expect((await send(undefined, "tools/list", {}, server)).error).toMatchObject({ code: -32603, message: "rota service unreachable" });
});

test("another agent gets the guard's reason", async () => {
  expect(await toolsFor(LIN)).toEqual([]);
  const result = await callAs(LIN, "page_engineering", { summary: "Logins failing" });
  expect(result.content[0].text).toBe(`Access denied to Tool "help-desk:page_engineering": guards[0]: only the support agent on call can page engineering`);
});
```

Every field of a rule is checked, even after one has failed, in this order: `roles`, `permissions`, `attributes`, `relationships`, `custom`, `guards`, then `allOf`, `anyOf` and `not`. The reason given is the first that failed. So the guard runs for callers that `roles` already refused, anonymous ones included, as the second test shows, and for every `tools/list`, once per tool. Keep guards fast, and don't let them throw: a guard that throws fails the call, and fails the whole `tools/list`.

### Custom checks and relationships

`custom` and `relationships` are guards with a name: the rule says what to check, and a function registered on the server does the checking. `relationshipResolver` answers "does this user have this relationship to this thing?", which is how access to one ticket, document or project is usually modeled; `evaluators` take any settings the rule gives them.

<Examples title="Named checks">

#### Example: Relationships
```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { JWKS } from "./tokens";

const assignees: Record<string, string> = { "T-1": "nour", "T-2": "lin" };

@Tool({
  name: "edit_ticket",
  description: "Change the title of a ticket assigned to you.",
  inputSchema: { id: z.string(), title: z.string() },
  authorities: { relationships: { type: "assignee", resource: "ticket", resourceId: { fromInput: "id" } } },
})
export class EditTicket extends ToolContext {
  async execute({ id, title }: { id: string; title: string }) {
    return { id, title };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS } },
  authorities: {
    profiles: {},
    relationshipResolver: {
      async check(type: string, resource: string, resourceId: string, userSub: string) {
        return type === "assignee" && resource === "ticket" && assignees[resourceId] === userSub;
      },
    },
  },
};

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts relationships.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./client";
import { LIN, NOUR } from "./tokens";

const edit = { id: "T-1", title: "Cannot log in on mobile" };

test("the assignee may edit", async () => {
  expect((await callAs(NOUR, "edit_ticket", edit)).structuredContent).toEqual(edit);
});

test("anyone else is refused", async ({ mcp }) => {
  expect((await callAs(LIN, "edit_ticket", edit)).content[0].text).toContain("relationships: user 'lin' is not 'assignee' of ticket:T-1");
  expect((await mcp.tools.call("edit_ticket", edit)).text()).toContain("relationships: an anonymous caller cannot be 'assignee' of ticket");
});
```

#### Example: Custom evaluator
```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { JWKS } from "./tokens";

// Which plan each customer company pays for. Stands in for your billing system.
const plans: Record<string, string> = { acme: "pro", globex: "free" };
const order = ["free", "pro", "enterprise"];

@Tool({
  name: "bulk_close",
  description: "Close many tickets at once. On the pro plan and above.",
  inputSchema: { ids: z.array(z.string()) },
  authorities: { custom: { plan: { atLeast: "pro" } } },
})
export class BulkClose extends ToolContext {
  async execute({ ids }: { ids: string[] }) {
    return { closed: ids };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS } },
  authorities: {
    profiles: {},
    evaluators: {
      plan: {
        name: "plan",
        async evaluate(policy: unknown, ctx: { user: { claims: Record<string, unknown> } }) {
          const { atLeast } = policy as { atLeast: string };
          const plan = plans[String(ctx.user.claims.tenant)] ?? "free";
          const granted = order.indexOf(plan) >= order.indexOf(atLeast);
          return { granted, deniedBy: granted ? undefined : `plan: needs ${atLeast}, this company has ${plan}`, evaluatedPolicies: ["custom.plan"] };
        },
      },
    },
  },
};

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts evaluator.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./client";
import { LIN, NOUR } from "./tokens";

test("a pro company may bulk close", async () => {
  expect((await callAs(NOUR, "bulk_close", { ids: ["T-1"] })).structuredContent).toEqual({ closed: ["T-1"] });
});

test("a free one gets the evaluator's reason", async () => {
  const result = await callAs(LIN, "bulk_close", { ids: ["T-1"] });
  expect(result.content[0].text).toBe(`Access denied to Tool "help-desk:bulk_close": plan: needs pro, this company has free`);
});
```

`check()` also receives the rule's context as a fifth argument. It isn't called for anonymous callers, or when `resourceId` can't be found, and a relationship rule on `{ fromInput }` hides the tool from lists, like any rule on the arguments. An evaluator gets the value under its name in `custom` (here `{ atLeast: "pro" }`) and the context, and returns `{ granted, deniedBy?, evaluatedPolicies }`; its `deniedBy` becomes the reason as is.

### Working out roles in code

`claimsMapping` finds roles that are in one claim; [`this.auth`](https://frontmcp.dev/reference/sdk/auth#reading-roles-from-another-claim) shows it with Keycloak's `realm_access.roles`. When roles need working out, like merging two claims or translating group names, `claimsResolver` gets the caller's [`authInfo`](https://frontmcp.dev/reference/sdk/context#authinfo) (the token's claims are in `authInfo.user`) and returns the roles, permissions and claims that rules and `this.auth` both see:

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

@Tool({ name: "whoami", description: "Say what the server knows about the caller.", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { sub: this.auth.user.sub, roles: this.auth.roles, tenant: this.auth.claims.tenant ?? null };
  }
}

@Tool({ name: "assign_leads", description: "Assign tickets to team leads.", inputSchema: {}, authorities: { roles: { any: ["lead"] } } })
export class AssignLeads extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Tool({ name: "my_queue", description: "List the tickets in your queue.", inputSchema: {}, authorities: { attributes: { match: { "user.sub": "nour" } } } })
export class MyQueue extends ToolContext {
  async execute() {
    return { tickets: ["T-1"] };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS } },
  authorities: {
    profiles: {},
    // The top-level roles and the namespaced ones together.
    claimsResolver: (authInfo: { user?: Record<string, unknown> }) => {
      const claims = authInfo.user ?? {};
      const list = (value: unknown) => (Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []);
      return { roles: [...list(claims.roles), ...list(claims["https://desk.example.com/roles"])], permissions: [], claims };
    },
  },
};

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Sends one request over HTTP, as a client signed in with `token` would. */
export async function send(token: string | undefined, method: string, params: Record<string, unknown> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const name = params.name ?? params.uri;
  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 name === "string" ? { "mcp-name": name } : {}),
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return response.json();
}

/** Calls a tool as the user whose token this is. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}, server?: ServerConfig) {
  return (await send(token, "tools/call", { name, arguments: args }, server)).result;
}

/** The tool names this user's tools/list shows. */
export async function toolsFor(token: string | undefined, server?: ServerConfig): Promise<string[]> {
  const { result } = await send(token, "tools/list", {}, server);
  return result.tools.map((tool: { name: string }) => tool.name).sort();
}
```

```ts resolver.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./client";
import { config } from "./main";
import { NOUR } from "./tokens";

test("rules see the resolver's roles", async () => {
  // nour's token: roles ["agent"], "https://desk.example.com/roles" ["lead"]
  expect((await callAs(NOUR, "assign_leads")).structuredContent).toEqual({ ok: true });
});

test("so does this.auth, with the claims the resolver returns", async () => {
  expect((await callAs(NOUR, "whoami")).structuredContent).toEqual({ sub: "nour", roles: ["agent", "lead"], tenant: "acme" });
});

test("with a resolver, claimsMapping isn't read: the id is the token's sub", async () => {
  const mapped = { ...config, authorities: { ...config.authorities, claimsMapping: { roles: "roles", userId: "name" } } };
  expect((await callAs(NOUR, "whoami", {}, mapped)).structuredContent).toEqual({ sub: "nour", roles: ["agent", "lead"], tenant: "acme" });
  expect((await callAs(NOUR, "my_queue", {}, mapped)).structuredContent).toEqual({ tickets: ["T-1"] });
});
```

Rules and `this.auth` both see `agent` and `lead`, so a tool that checks `this.auth.hasRole("lead")` agrees with the rule. `this.auth.claims` is the resolver's `claims`, which is why this one returns the token's claims along with the roles: leave a claim out, and neither rules nor tools see it. With a resolver, `claimsMapping` isn't read, `userId` included, so the caller's id is always the token's `sub`. (Changed in 1.9.2: before, the resolver changed only what rules saw, and `this.auth` followed `claimsMapping`.)

### Adding fields to `this.auth`

`pipes` turn claims into fields of your own on [`this.auth`](https://frontmcp.dev/reference/sdk/auth), so tools read `this.auth.tenant` instead of digging in `this.auth.claims`. Each pipe gets the token's claims and returns an object, or a promise of one, whose fields are added. They run once for each tool, resource, prompt or agent call, job run and workflow step, before its hooks and `execute()`, so a pipe can look something up. With a `claimsResolver`, they get the claims it returns. Declare the fields in the global `ExtendFrontMcpAuthContext` interface to type them. Since FrontMCP 1.9.0; before, pipes never ran, and the fields were `undefined`.

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

declare global {
  interface ExtendFrontMcpAuthContext {
    tenant: string;
    sites: string[];
    team: string;
  }
}

const teams: Record<string, string> = { nour: "escalations", sam: "customers" };

@Tool({ name: "my_sites", description: "List the sites the caller works for.", inputSchema: {} })
export class MySites extends ToolContext {
  async execute() {
    return { tenant: this.auth.tenant, sites: this.auth.sites, team: this.auth.team };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS } },
  authorities: {
    profiles: {},
    pipes: [
      (claims: Readonly<Record<string, unknown>>) => ({ tenant: String(claims.tenant), sites: (claims.sites as string[]) ?? [] }),
      // A pipe can be async, like a lookup in your user directory
      async (claims: Readonly<Record<string, unknown>>) => ({ team: teams[String(claims.sub)] ?? "none" }),
    ],
  },
};

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Calls a tool over HTTP, as a client signed in with `token` would. */
export async function callAs(token: string | undefined, name: string, server: ServerConfig = 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: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return (await response.json()).result;
}
```

```ts pipes.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./client";
import { config } from "./main";
import { LIN, NOUR } from "./tokens";

test("each caller's fields come from their own token", async () => {
  expect((await callAs(NOUR, "my_sites")).structuredContent).toEqual({ tenant: "acme", sites: ["berlin", "lisbon"], team: "escalations" });
  expect((await callAs(LIN, "my_sites")).structuredContent).toEqual({ tenant: "globex", sites: ["oslo"], team: "none" });
});

test("a pipe that throws is skipped: its fields stay undefined, and the call goes on", async () => {
  const failing = () => {
    throw new Error("directory is down");
  };
  const server = { ...config, authorities: { ...config.authorities, pipes: [config.authorities.pipes[0], failing] } };
  expect((await callAs(NOUR, "my_sites", server)).structuredContent).toEqual({ tenant: "acme", sites: ["berlin", "lisbon"] });
});
```

A pipe that throws is logged as `[FrontMcpAuth] pipe failed: …`, and the call goes on without its fields. Pipes add to `this.auth` only: rules check roles, permissions and claims, as above, so a rule can't refer to a pipe's fields. If something reads `this.auth` before the pipes have run, it gets none of their fields, and FrontMCP logs a warning saying so.

### Resources, prompts, skills and agents

Resources, templates, prompts and skills take the same `authorities` and are left out of their lists the same way. What changes is the refusal: a JSON-RPC error with code `-32003` rather than a tool result. An agent is called as its `invoke_<agent>` tool, so it's hidden and refused like a tool.

<Examples title="Other entries">

#### Example: Resource
```ts main.ts active
import { App, FrontMcp, Resource, ResourceContext, ResourceTemplate, Tool, ToolContext } from "@frontmcp/sdk";

@Resource({ name: "escalation_policy", uri: "desk://escalation-policy", mimeType: "text/plain", authorities: "admin" })
export class EscalationPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: "Page engineering after 30 minutes." }] };
  }
}

@ResourceTemplate({ name: "internal_notes", uriTemplate: "desk://tickets/{id}/internal-notes", mimeType: "text/plain", authorities: "admin" })
export class InternalNotes extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: "Customer is on a legacy plan." }] };
  }
}

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

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: {
    claimsMapping: { roles: "roles" },
    profiles: { admin: { roles: { any: ["admin"] } } },
    scopeMapping: { profiles: { admin: ["desk:admin"] } },
  },
})
export default class Server {}
```

```ts resource.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { InternalNotes } from "./main";

test("neither the resource nor the template is listed", async ({ mcp }) => {
  expect(await mcp.resources.list()).toHaveLength(0);
  const templates = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "resources/templates/list", params: {} });
  expect(templates.result.resourceTemplates).toHaveLength(0);
});

test("reading one is a -32003 error with the details in data", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "resources/read", params: { uri: "desk://escalation-policy" } });
  expect(response.error).toMatchObject({
    code: -32003,
    message: `Access denied to Resource "help-desk:escalation_policy": profile:admin: roles.any: user has none of 'admin'`,
    data: { entryType: "Resource", deniedBy: "profile:admin: roles.any: user has none of 'admin'", requiredScopes: ["desk:admin"] },
  });
});

test("a tool refusal carries no requiredScopes", async ({ mcp }) => {
  const result = await mcp.tools.call("purge_tickets", {});
  expect(result).toBeError("AUTHORITY_DENIED");
  expect(result.raw._meta).not.toHaveProperty("requiredScopes");
});

test("without the server's authorities option, a template with a rule doesn't start", async () => {
  @App({ id: "notes", name: "Notes", resources: [InternalNotes] })
  class Notes {}
  await expect(FrontMcpInstance.createDirect({ info: { name: "notes", version: "1.0.0" }, apps: [Notes] })).rejects.toThrow(
    `Authorities configuration required: Resource template "internal_notes" declare 'authorities' metadata`,
  );
});
```

`scopeMapping` adds `requiredScopes`, which a client could ask the user to grant. Tool refusals don't carry it. The last test is the startup check: a template with `authorities` stops a server without the option, like a resource, tool or prompt does.

#### Example: Prompt
```ts main.ts active
import { App, FrontMcp, Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "escalate",
  description: "Draft an escalation to engineering",
  arguments: [{ name: "id", description: "Ticket id", required: true }],
  authorities: "agent",
})
export class Escalate extends PromptContext {
  async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
    return { messages: [{ role: "user", content: { type: "text", text: `Draft an escalation for ticket ${id}.` } }] };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: { claimsMapping: { roles: "roles" }, profiles: { agent: { roles: { any: ["agent", "admin"] } } } },
})
export default class Server {}
```

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

test("the prompt isn't listed, and getting it is refused", async ({ mcp }) => {
  expect(await mcp.prompts.list()).toHaveLength(0);
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "prompts/get", params: { name: "escalate", arguments: { id: "T-1" } } });
  expect(response.error).toMatchObject({ code: -32003, data: { entryType: "Prompt", entryName: "help-desk:escalate" } });
});
```

A prompt's arguments aren't visible to rules: `input.` is always empty for a prompt.

#### Example: Skill
```ts main.ts active
import { App, FrontMcp, Skill } from "@frontmcp/sdk";

@Skill({ name: "triage-ticket", description: "How to triage a new ticket", instructions: "1. Read the ticket. 2. Set a priority." })
export class TriageTicket {}

@Skill({ name: "refund-runbook", description: "How to refund a customer", instructions: "1. Check the order. 2. Refund up to 500 EUR.", authorities: "admin" })
export class RefundRunbook {}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } },
})
export default class Server {}
```

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

test("the gated skill is left out of every list", async ({ mcp }) => {
  const list = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/list", params: {} });
  expect(list.result.skills.map((s: { id: string }) => s.id)).toEqual(["triage-ticket"]);
  expect((await mcp.resources.read("skill://index.json")).text()).not.toContain("refund-runbook");
  expect((await mcp.resources.list()).map((r) => r.uri)).not.toContain("skill://refund-runbook/SKILL.md");
});

test("loading or reading it is refused", async ({ mcp }) => {
  const load = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "skills/load", params: { skillIds: ["refund-runbook"] } });
  expect(load.error).toMatchObject({ code: -32003, message: `Access denied to Skill "help-desk:refund-runbook": profile:admin: roles.any: user has none of 'admin'` });
  const read = await mcp.raw.request({ jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri: "skill://refund-runbook/SKILL.md" } });
  expect(read.error).toMatchObject({ code: -32003, data: { entryType: "Resource", entryName: "root:refund-runbook" } });
});
```

Over MCP, a gated skill is as hidden as a gated tool, and so it is over its [HTTP endpoints](#skills-over-http).

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

@Tool({ name: "customer_contract", description: "Read a customer's support contract", inputSchema: { email: z.string() }, authorities: "admin" })
export class CustomerContract extends ToolContext {
  async execute({ email }: { email: string }) {
    return { email, plan: "enterprise", responseTime: "1h" };
  }
}

@Agent({
  name: "triage",
  description: "Suggest a priority for a ticket. Agents only.",
  systemInstructions: "Look up the customer's contract, then answer high, normal or low.",
  inputSchema: { query: z.string() },
  llm: { adapter: model },
  tools: [CustomerContract],
  authorities: "agent",
})
export class Triage extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: {
    claimsMapping: { roles: "roles" },
    profiles: { agent: { roles: { any: ["agent", "admin"] } }, admin: { roles: { any: ["admin"] } } },
  },
};

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach.
// It asks for the customer's contract, then answers with what the tool returned.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const lookup = prompt.messages.find((message) => message.role === "tool");
    if (!lookup) {
      return { content: null, toolCalls: [{ id: "call-1", name: "customer_contract", arguments: { email: "sam@example.com" } }], finishReason: "tool_calls" };
    }
    return { content: `High. Contract: ${lookup.content}`, finishReason: "stop" };
  },
};
```

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

async function askAs(user: { sub: string; roles: string[] }) {
  const server = await FrontMcpInstance.createDirect(config);
  const result = await server.callTool("invoke_triage", { query: "Nobody can log in." }, { authContext: { user } });
  await server.dispose();
  return result.structuredContent.response as string;
}

test("an anonymous caller neither sees nor calls the agent", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).not.toContain("invoke_triage");
  expect(await mcp.tools.call("invoke_triage", { query: "Nobody can log in." })).toBeError("AUTHORITY_DENIED");
});

test("an agent gets an answer, but the tool inside the agent refuses them", async () => {
  const answer = await askAs({ sub: "nour", roles: ["agent"] });
  expect(answer).toMatch(/^High\. Contract: /);
  expect(answer).toContain(`Access denied to Tool \\"agent:triage:customer_contract\\": profile:admin: roles.any: user has none of 'admin'`);
});

test("an admin passes both", async () => {
  expect(await askAs({ sub: "ada", roles: ["admin"] })).toBe(`High. Contract: {"email":"sam@example.com","plan":"enterprise","responseTime":"1h"}`);
});
```

The agent's rule decides who may call `invoke_triage`. The tools the agent uses keep their own rules, checked against the same caller: a refusal doesn't stop the agent, it comes back to its model as the tool's result.

### Skills over HTTP

With `skillsConfig: { enabled: true }`, FrontMCP also serves skills over plain HTTP, for planners and backends that don't speak MCP: `GET /skills`, `/skills?query=…`, `/skills/<id>`, `/llm.txt` and `/llm_full.txt` ([Serving skills over HTTP](https://frontmcp.dev/reference/sdk/skill#serving-skills-over-http) shows what they return). Who may read them is `skillsConfig.auth`:

| `auth` | Requests need |
| --- | --- |
| `"inherit"` (the default) | The same credential as the MCP endpoint, checked the same way: a request the server would refuse gets the same `401` and `WWW-Authenticate` challenge. A server in `public` mode, or without `auth`, lets everyone in. |
| `"public"` | Nothing. |
| `"api-key"` | One of `apiKeys`, as `X-API-Key: <key>` or `Authorization: ApiKey <key>`. Otherwise `401` `{"error":"Unauthorized","message":"Invalid or missing API key"}`. Without `apiKeys`, every request gets `500` `Server misconfiguration`. |
| `"bearer"` | A JWT from `jwt.issuer`, with an `exp`, checked with the keys at `<issuer>/.well-known/jwks.json` (or `jwt.jwksUrl`), and `jwt.audience` if set. Otherwise `401` `Missing Bearer token` or `Invalid JWT token`. |

With `"inherit"`, the endpoints know who's calling, so a skill's rule is checked as it is over MCP: a caller who fails it doesn't find the skill in `/skills`, a search, `/llm.txt` or `/llm_full.txt`, and gets `404` for `/skills/<id>`. The other settings don't read the caller's claims, so they leave gated skills out for everyone, admins included:

```ts main.ts active
import { App, FrontMcp, Skill } from "@frontmcp/sdk";
import { JWKS } from "./tokens";

@Skill({ name: "triage-ticket", description: "How to triage a new ticket", instructions: "1. Read the ticket. 2. Set a priority." })
export class TriageTicket {}

@Skill({ name: "refund-runbook", description: "How to refund a customer", instructions: "1. Check the order. 2. Refund up to 500 EUR.", authorities: "admin" })
export class RefundRunbook {}

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

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

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

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
const as = (token: string) => ({ authorization: `Bearer ${token}` });

async function get(path: string, headers: Record<string, string> = {}, server: ServerConfig = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  return handler(new Request(`https://desk.example.com${path}`, { headers }));
}

test("inherit: the same credentials as the MCP endpoint", async () => {
  const strict = { ...config, auth: { ...config.auth, allowAnonymous: false } };
  const refused = await get("/llm.txt", {}, strict);
  expect(refused.status).toBe(401);
  expect(refused.headers.get("www-authenticate")).toMatch(/^Bearer resource_metadata=".*\/\.well-known\/oauth-protected-resource"$/);
  expect((await get("/llm.txt", as(SAM), strict)).status).toBe(200);
  // "public" opens them whatever the server's auth
  expect((await get("/llm.txt", {}, { ...strict, skillsConfig: { enabled: true, auth: "public" as const } })).status).toBe(200);
});

test("without auth, the server is public, and so are the endpoints", async () => {
  const { auth, ...open } = config;
  expect((await get("/llm.txt", {}, open)).status).toBe(200);
});

test("each caller gets the skills their rules allow", async () => {
  expect(await (await get("/llm_full.txt", as(ADA))).text()).toContain("Refund up to 500 EUR.");
  expect(await (await get("/llm_full.txt", as(SAM))).text()).not.toContain("Refund up to 500 EUR.");
  expect(await (await get("/llm.txt", as(SAM))).text()).not.toContain("refund-runbook");
  const ids = async (path: string, token: string) => (await (await get(path, as(token))).json()).skills.map((s: { id: string }) => s.id);
  expect(await ids("/skills", SAM)).toEqual(["triage-ticket"]);
  expect(await ids("/skills?query=refund", SAM)).toEqual([]);
  expect(await ids("/skills?query=refund", ADA)).toEqual(["refund-runbook"]);
});

test("a skill the caller can't see is a 404", async () => {
  expect((await get("/skills/refund-runbook", as(SAM))).status).toBe(404);
  expect((await get("/skills/refund-runbook", as(ADA))).status).toBe(200);
});

test("api-key: no key, no skills; with one, no gated skills", async () => {
  const keyed = { ...config, skillsConfig: { enabled: true, auth: "api-key" as const, apiKeys: ["planner-key-1"] } };
  const refused = await get("/skills", {}, keyed);
  expect(refused.status).toBe(401);
  expect(await refused.json()).toEqual({ error: "Unauthorized", message: "Invalid or missing API key" });
  const text = await (await get("/llm_full.txt", { "x-api-key": "planner-key-1", ...as(ADA) }, keyed)).text();
  expect(text).toContain("1. Read the ticket.");
  expect(text).not.toContain("Refund up to 500 EUR.");
});

test("public: no gated skills either, even for an admin", async () => {
  const open = { ...config, skillsConfig: { enabled: true, auth: "public" as const } };
  expect(await (await get("/llm_full.txt", as(ADA), open)).text()).not.toContain("Refund up to 500 EUR.");
});
```

`skillsConfig.auth: "public"` opens the endpoints to anyone, on any server. Use it only when no skill is secret: set it explicitly, since `"inherit"` follows the server's `auth`.

### Checking skills credentials in your own code

Code that serves skills on a route of its own, like an [`http.routes`](https://frontmcp.dev/reference/sdk/frontmcp#http) handler or a [flow that answers HTTP requests](https://frontmcp.dev/reference/server/flows#answering-http-requests-with-a-flow), can check `skillsConfig.auth` the way the built-in endpoints do, with `authorizeSkillHttpRequest()` from `@frontmcp/sdk`:

```ts
const access = await authorizeSkillHttpRequest(scope, skillsConfig, request, logger?);
// { allowed: true, authInfo } or { allowed: false, status, error, headers? }
```

`scope` is the endpoint the request is for: `this.scope` in a flow, or `getPrimaryScope()` of the instance [`FrontMcpInstance.bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance) returns. `request` is FrontMCP's request object, `{ method, path, url, headers, query }` with lower-case header names, which is what a route handler and a flow receive. The result follows `skillsConfig.auth`:

| `auth` | Allowed when | `authInfo` | Refused with |
| --- | --- | --- | --- |
| `"inherit"`, or unset | The request has what the MCP endpoint would accept: no token at all on a server that lets anonymous callers in. Always, on a server without `auth` or in `public` mode. | The caller: `{ token, clientId, scopes, expiresAt, user, extra }`, where `user` holds the token's claims, or an anonymous caller's made-up ones. `{}` on a public server. | `401` `Authentication required`, or `403` `Insufficient scope` for a token without the server's `requiredScopes`, and the `WWW-Authenticate` challenge in `headers`. |
| `"public"` | Always. | `{}` | |
| `"api-key"`, `"bearer"` | The request has the endpoint's credential, as in [Skills over HTTP](#skills-over-http). | `{}`: the caller isn't known. | `401` with the endpoint's message, or `500` `Server misconfiguration` without `apiKeys` or `jwt.issuer`. No `headers`. |

`authInfo` is what decides which skills the caller may see: the built-in endpoints check each skill's `authorities` against it, so with `"api-key"` and `"bearer"` a gated skill is hidden from everyone.

`createSkillHttpAuthValidator(skillsConfig, logger?)` is the older, header-only check. It returns `null` for `"public"`, and otherwise a validator whose `validate({ headers })` resolves to `{ authorized, error?, statusCode? }`. It checks `"api-key"` and `"bearer"` as above, but it can't run the server's own auth, so for `"inherit"`, and for an unset `auth`, it refuses every request with `500` `Server misconfiguration` and logs that `authorizeSkillHttpRequest()` applies it. Code that took a `null` validator to mean "no auth needed" should call `authorizeSkillHttpRequest()` instead. The tests get a scope from [`createForGraph()`](https://frontmcp.dev/reference/server/apps#giving-an-app-its-own-endpoint), which builds the server without serving it:

```ts main.ts active
import { App, FrontMcp, Skill } from "@frontmcp/sdk";
import { JWKS } from "./tokens";

@Skill({ name: "triage-ticket", description: "How to triage a new ticket", instructions: "1. Read the ticket. 2. Set a priority." })
export class TriageTicket {}

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

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

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

```ts skills-auth.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, authorizeSkillHttpRequest, createSkillHttpAuthValidator } from "@frontmcp/sdk";
import { config } from "./main";
import { ADA } from "./tokens";

type Config = Parameters<typeof FrontMcpInstance.createForGraph>[0];
type SkillsRequest = Parameters<typeof authorizeSkillHttpRequest>[2];

/** The main endpoint of a server with this config. */
const scopeOf = async (cfg: Config) => (await FrontMcpInstance.createForGraph(cfg)).getPrimaryScope()!;

/** A request to a route of your own, as a route handler or a flow receives it. */
const request = (headers: Record<string, string> = {}) =>
  ({ method: "GET", path: "/runbooks", url: "/runbooks", headers: { host: "desk.example.com", ...headers }, query: {} }) as unknown as SkillsRequest;

test("inherit: the server's own auth, and the caller it verified", async () => {
  const strict = await scopeOf({ ...config, auth: { ...config.auth, allowAnonymous: false } });
  const access = await authorizeSkillHttpRequest(strict, config.skillsConfig, request({ authorization: `Bearer ${ADA}` }));
  expect(access).toMatchObject({ allowed: true, authInfo: { user: { sub: "ada", roles: ["admin"] } } });

  // This server lets anonymous callers in, so the MCP endpoint would take a request without a token
  expect(await authorizeSkillHttpRequest(await scopeOf(config), config.skillsConfig, request())).toMatchObject({
    allowed: true,
    authInfo: { user: { sub: expect.stringMatching(/^anon:/), name: "Anonymous" } },
  });
  expect(await authorizeSkillHttpRequest(strict, config.skillsConfig, request())).toEqual({
    allowed: false,
    status: 401,
    error: "Authentication required",
    headers: { "WWW-Authenticate": 'Bearer resource_metadata="http://desk.example.com/.well-known/oauth-protected-resource"' },
  });
});

test("inherit: a token without the server's requiredScopes is a 403", async () => {
  const scope = await scopeOf({ ...config, auth: { ...config.auth, requiredScopes: ["tickets:admin"] } });
  expect(await authorizeSkillHttpRequest(scope, config.skillsConfig, request({ authorization: `Bearer ${ADA}` }))).toMatchObject({
    allowed: false,
    status: 403,
    error: "Insufficient scope",
    headers: { "WWW-Authenticate": expect.stringContaining('error="insufficient_scope"') },
  });
});

test("inherit on a public server lets everyone in, as nobody", async () => {
  const { auth, ...open } = config;
  expect(await authorizeSkillHttpRequest(await scopeOf(open), open.skillsConfig, request())).toEqual({ allowed: true, authInfo: {} });
});

test("api-key: the endpoint's own credential, and no caller", async () => {
  const keyed = { enabled: true, auth: "api-key" as const, apiKeys: ["planner-key-1"] };
  const scope = await scopeOf(config);
  expect(await authorizeSkillHttpRequest(scope, keyed, request({ "x-api-key": "planner-key-1", authorization: `Bearer ${ADA}` }))).toEqual({ allowed: true, authInfo: {} });
  expect(await authorizeSkillHttpRequest(scope, keyed, request({ authorization: "ApiKey wrong-key" }))).toEqual({ allowed: false, status: 401, error: "Invalid or missing API key" });
});

test("createSkillHttpAuthValidator() checks headers only, and refuses inherit", async () => {
  const keyed = createSkillHttpAuthValidator({ enabled: true, auth: "api-key", apiKeys: ["planner-key-1"] })!;
  expect(await keyed.validate({ headers: { "X-API-Key": "planner-key-1" } })).toEqual({ authorized: true });
  expect(await keyed.validate({ headers: { authorization: "ApiKey planner-key-1" } })).toEqual({ authorized: true });

  const inherit = createSkillHttpAuthValidator({ enabled: true })!;
  expect(await inherit.validate({ headers: { authorization: `Bearer ${ADA}` } })).toEqual({ authorized: false, error: "Server misconfiguration", statusCode: 500 });

  expect(createSkillHttpAuthValidator({ enabled: true, auth: "public" })).toBeNull();
});
```

```ts tokens.ts hidden
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// Each has sub, name, tenant ("acme" or "globex"), scope and sites claims, and roles where listed.
// nour: an agent at acme. roles ["agent"], scope "tickets:read tickets:write", sites ["berlin", "lisbon"],
//   and "https://desk.example.com/roles": ["lead"]
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
// sam: a customer at acme. No roles, scope "tickets:read", sites ["berlin"]
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// ada: an admin at acme. roles ["admin"], scope "tickets:read tickets:write tickets:export", sites ["berlin", "lisbon", "oslo"]
export const ADA = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoiYWRhIiwibmFtZSI6IkFkYSBMaW5kIiwicm9sZXMiOlsiYWRtaW4iXSwic2NvcGUiOiJ0aWNrZXRzOnJlYWQgdGlja2V0czp3cml0ZSB0aWNrZXRzOmV4cG9ydCIsInRlbmFudCI6ImFjbWUiLCJzaXRlcyI6WyJiZXJsaW4iLCJsaXNib24iLCJvc2xvIl19.aW_2x454s_PueXj_b5qLin7u6B3ohwo9tAC2MDq_oSfEcdYyFjNbG4QvNoRXrpQedvJJAqPOeJW55g-qXQTCRg";
// lin: an agent at globex. roles ["agent"], scope "tickets:read tickets:write", sites ["oslo"]
export const LIN = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibGluIiwibmFtZSI6IkxpbiBXdSIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJnbG9iZXgiLCJzaXRlcyI6WyJvc2xvIl19.xxZBZ1I_geLP6AeBr0QZ_y3WWEhcrHoES_wDOERjlKF4yugWt5O9ZALcutksu-BPbpGXYmumxNMYlO9cz3tB6w";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

On FrontMCP's Node server, a route in `http.routes` gets the scope from the instance `bootstrap()` returns. This handler was run on Node, and answered `401` with the challenge without a token, and `200` with `ada`'s token:

```ts main.ts
import { FrontMcpInstance, authorizeSkillHttpRequest, type ScopeEntry } from "@frontmcp/sdk";

let scope: ScopeEntry | undefined;
const skillsConfig = { enabled: true }; // auth: "inherit"

const instance = await FrontMcpInstance.bootstrap({
  ...config, // info, apps, auth
  skillsConfig,
  http: {
    port: 3000,
    routes: [
      {
        method: "GET",
        path: "/runbooks",
        handler: async (req, res) => {
          const access = await authorizeSkillHttpRequest(scope!, skillsConfig, req);
          if (!access.allowed) {
            for (const [name, value] of Object.entries(access.headers ?? {})) res.setHeader(name, value);
            return res.status(access.status).json({ error: access.error });
          }
          const caller = access.authInfo.user as { sub?: string } | undefined; // the token's claims
          res.json({ caller: caller?.sub ?? null });
        },
      },
    ],
  },
});
scope = instance?.getPrimaryScope();
```

### Hiding versus refusing

Keeping a tool from a caller takes two things: leaving it out of their list, so the model doesn't try it, and refusing the call, because anyone can send a tool's name. `authorities` does both. The other ways do one:

| | Left out of `tools/list` | Call refused |
| --- | --- | --- |
| `authorities` | For callers who fail the rule | For callers who fail the rule, before `execute()` |
| A check in `execute()` | No | Yes, with a message you write for the model |
| [`visibility: "hidden"`](https://frontmcp.dev/learn/authorizing-calls#hiding-a-tool-isnt-protecting-it) | For everyone | No |
| A hook that filters `tools/list` | For whoever the hook drops | No |

The last row is a common home-made pattern: a plugin that reads each tool's required roles and filters the list. It hides the tool, and the tool still runs for anyone who calls it:

```ts roles.plugin.ts active
import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin } from "@frontmcp/sdk";

declare global {
  interface ExtendFrontMcpToolMetadata {
    requiredRoles?: string[];
  }
}

@Plugin({ name: "roles" })
export class RolesPlugin extends DynamicPlugin<object> {
  // 🚩 Filters the list. Nothing refuses the call.
  @ListToolsHook.Did("findTools")
  async hideByRole(ctx: FlowCtxOf<"tools:list-tools">) {
    const user = ctx.state.authInfo?.user as { roles?: string[] } | undefined;
    const roles = user?.roles ?? [];
    const tools = ctx.state.tools ?? [];
    ctx.state.set("tools", tools.filter(({ tool }) => (tool.metadata.requiredRoles ?? []).every((role) => roles.includes(role))));
  }
}
```

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

@Tool({ name: "approve_refund", description: "Approve a refund. Managers only.", inputSchema: { id: z.string() }, requiredRoles: ["manager"] })
export class ApproveRefund extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, approved: true };
  }
}

@Tool({ name: "approve_refund_safely", description: "Approve a refund. Managers only.", inputSchema: { id: z.string() }, authorities: { roles: { any: ["manager"] } } })
export class ApproveRefundSafely extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, approved: true };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { RolesPlugin } from "./roles.plugin";
import { ApproveRefund, ApproveRefundSafely } from "./refunds.tools";

@App({ id: "billing", name: "Billing", tools: [ApproveRefund, ApproveRefundSafely], plugins: [RolesPlugin] })
class Billing {}

@FrontMcp({ info: { name: "billing", version: "1.0.0" }, apps: [Billing], authorities: { claimsMapping: { roles: "roles" }, profiles: {} } })
export default class Server {}
```

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

test("both are hidden from a caller without the role", async ({ mcp }) => {
  expect(await mcp.tools.list()).toHaveLength(0);
});

test("the filtered tool still runs when called by name", async ({ mcp }) => {
  expect((await mcp.tools.call("approve_refund", { id: "R-7" })).json()).toEqual({ id: "R-7", approved: true });
});

test("✅ the one with authorities is refused", async ({ mcp }) => {
  expect(await mcp.tools.call("approve_refund_safely", { id: "R-7" })).toBeError("AUTHORITY_DENIED");
});
```

The Call tab shows an anonymous caller approving a refund. Tool names aren't secrets: they're in your code, your docs and old conversations. If a list hook is how you decide visibility, refuse the same callers with `authorities` or a check in `execute()`, or put the rule in `authorities` and drop the hook.

### Auditing decisions

The check is a flow stage, `checkEntryAuthorities`, so a [hook](https://frontmcp.dev/reference/sdk/hooks) can record every decision. `Did` hooks run only when the stage succeeds, so use an `Around` hook to see refusals too:

```ts audit.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

export const decisions: string[] = [];

@Plugin({ name: "audit" })
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Around("checkEntryAuthorities")
  async record(ctx: FlowCtxOf<"tools:call-tool">, next: () => Promise<void>) {
    const tool = ctx.state.tool?.metadata.name;
    try {
      await next();
      decisions.push(`allowed ${tool}`);
    } catch (error) {
      decisions.push(`refused ${tool}: ${(error as { deniedBy?: string }).deniedBy}`);
      throw error; // keep the refusal
    }
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { AuditPlugin } from "./audit.plugin";

@Tool({ name: "delete_ticket", description: "Delete a ticket. Admins only.", inputSchema: { id: z.string() }, authorities: "admin" })
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}

@Tool({ name: "get_ticket", description: "Get a ticket.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

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

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } } })
export default class Server {}
```

```ts audit.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, DynamicPlugin, FlowCtxOf, FrontMcpInstance, Plugin, ToolHook } from "@frontmcp/sdk";
import { decisions } from "./audit.plugin";
import { DeleteTicket } from "./main";

test("every decision is recorded, refusals included", async ({ mcp }) => {
  decisions.length = 0;
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(await mcp.tools.call("delete_ticket", { id: "T-1" })).toBeError("AUTHORITY_DENIED");
  expect(decisions).toEqual(["allowed get_ticket", "refused delete_ticket: profile:admin: roles.any: user has none of 'admin'"]);
});

test("a hook that swallows the refusal lets the call through", async () => {
  @Plugin({ name: "swallow" })
  class Swallow extends DynamicPlugin<object> {
    @ToolHook.Around("checkEntryAuthorities")
    async swallow(_ctx: FlowCtxOf<"tools:call-tool">, next: () => Promise<void>) {
      try {
        await next();
      } catch {
        // forgot to rethrow
      }
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket], plugins: [Swallow] })
  class Desk {}
  const authorities = { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } };
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], authorities });
  const result = await server.callTool("delete_ticket", { id: "T-1" });
  await server.dispose();
  expect(result.structuredContent).toEqual({ id: "T-1", deleted: true });
});
```

The stage runs for tools without `authorities` too, and passes them. Declare the hook on a plugin: a hook on a tool class can't run before the tool's context is created, which is after this stage, so since FrontMCP 1.9.0 such a hook stops the server at startup with `InvalidHookFlowError` (`Tool "…" declares hooks that would never run`); before, it was registered and never ran. Rethrow what `next()` throws: a hook that swallows it lets the refused call through, as the second test shows. An `Around` hook that never calls `next()` skips the check altogether, which is how you'd put an outside policy engine in its place; it must then throw to refuse.

### Rules that check nothing

A rule is only as strict as the fields FrontMCP recognizes in it. `{ roles: {} }` names no role to check, `allOf: []` no rule, and a misspelled field like `role:` isn't a field at all. FrontMCP checks every rule when the server starts, and a rule like these stops it, with a message that says what's wrong. So does a misspelled profile name, like `"admn"`. A guard is checked when it runs: it lets the caller in only by returning `true`, so one that forgets to `return` refuses everyone:

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

// ✅
@Tool({ name: "purge_tickets", description: "Delete every closed ticket. Admins only.", inputSchema: {}, authorities: { roles: { any: ["admin"] } } })
export class PurgeTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

// 🚩 Forgets to return, so the guard returns undefined, which refuses everyone, admins too.
@Tool({
  name: "purge_attachments",
  description: "Delete the attachments of closed tickets. Admins only.",
  inputSchema: {},
  // @ts-expect-error TypeScript catches the missing return; JavaScript doesn't.
  authorities: { guards: [({ user }) => { user.roles.includes("admin"); }] },
})
export class PurgeAttachments extends ToolContext {
  async execute() {
    return { purged: 40 };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { PurgeAttachments, PurgeTickets } from "./purge.tools";

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

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], authorities: { claimsMapping: { roles: "roles" }, profiles: {} } };

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

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

/** Starts a server whose one tool has this rule. */
async function start(authorities: unknown, profiles: Record<string, unknown> = {}) {
  @Tool({ name: "purge_everything", description: "Delete every ticket.", inputSchema: {}, authorities: authorities as never })
  class PurgeEverything extends ToolContext {
    async execute() {
      return { purged: 99 };
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [PurgeEverything] })
  class Desk {}
  return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], authorities: { profiles } });
}

test("a rule that checks nothing stops the server", async () => {
  await expect(start({ roles: {} })).rejects.toThrow(`Invalid authorities rule: Tool "purge_everything": authorities.roles needs "all" or "any"`);
  await expect(start({})).rejects.toThrow(`Tool "purge_everything": authorities checks nothing`);
  await expect(start({ allOf: [] })).rejects.toThrow(`Tool "purge_everything": authorities.allOf is empty`);
  await expect(start({ role: { any: ["admin"] } })).rejects.toThrow(`Tool "purge_everything": authorities has an unknown field "role"`);
  await expect(start({ anyOf: ["admin"] })).rejects.toThrow(`Tool "purge_everything": authorities.anyOf[0] must be a rule object, not a profile name`);
});

test("so does a profile, even one no entry uses", async () => {
  await expect(start({ roles: { any: ["admin"] } }, { admin: { roles: { any: ["admin"] } }, staff: {} })).rejects.toThrow(`Invalid authorities rule: profile "staff": checks nothing`);
});

test("and so does a profile name the server doesn't know", async () => {
  await expect(start("admn", { admin: { roles: { any: ["admin"] } } })).rejects.toThrow(`Invalid authorities rule: Tool "purge_everything": authorities names an unknown profile "admn"`);
  await expect(start(["admin", "admn"], { admin: { roles: { any: ["admin"] } } })).rejects.toThrow(`authorities names an unknown profile "admn"`);
});

test("the guard that forgets to return refuses an admin too", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const as = { authContext: { user: { sub: "ada", roles: ["admin"] } } };
  await expect(server.callTool("purge_attachments", {}, as)).rejects.toThrow("guards[0]: guard[0] did not return true (returned undefined)");
  expect((await server.callTool("purge_tickets", {}, as)).structuredContent).toEqual({ purged: 12 });
  await server.dispose();
});

test("an unknown evaluator and a false guard refuse everyone", async () => {
  const as = { authContext: { user: { sub: "ada", roles: ["admin"] } } };
  const typo = await start({ custom: { tenantCheck: true } });
  await expect(typo.callTool("purge_everything", {}, as)).rejects.toThrow(`Access denied to Tool "help-desk:purge_everything": custom evaluator 'tenantCheck' is not registered`);
  expect((await typo.listTools(as)).tools.map((t) => t.name)).not.toContain("purge_everything");
  await typo.dispose();
  const no = await start({ guards: [() => false] });
  await expect(no.callTool("purge_everything", {}, as)).rejects.toThrow("guards[0]: guard[0] denied");
  await no.dispose();
});

test("✅ a rule that names a role refuses an anonymous caller", async ({ mcp }) => {
  expect(await mcp.tools.call("purge_tickets", {})).toBeError("AUTHORITY_DENIED");
});
```

No setting lets such a rule through: to leave an entry open, remove its `authorities`. The name of a `custom` evaluator is still only looked up when a call is made, so a misspelled one starts, and refuses everyone. Test each protected tool as a caller who should be refused, and as one who should get in.

---

## Troubleshooting

### `Authorities configuration required: Tool "…" declare 'authorities' metadata…`

The full message goes on: `…but authorities enforcement is not fully configured (engine/context builder missing). Add 'authorities: { claimsMapping: {...}, profiles: {...} }' to your @FrontMcp() decorator…`. An entry has `authorities` and the server has no `authorities` option, so it refuses to start. Add the option, even as `authorities: { profiles: {} }`. Under `create()`, which drops the option, use `createDirect()`.

### `Invalid authorities rule: Tool "…": authorities checks nothing`

The server doesn't start because a rule can't check anything: it's empty, has only fields FrontMCP doesn't know (a typo like `role:`), or holds an empty list or object. The rest of the message says which, like `authorities.roles needs "all" or "any"`, `authorities.allOf is empty` or `authorities has an unknown field "role"`, and `profile "…"` instead of `Tool "…"` points at a profile. Fix the rule, or remove `authorities` to leave the entry open. See [Rules that check nothing](#rules-that-check-nothing).

### `guards[0]: guard[0] did not return true (returned undefined)`

A guard returned something other than `true`, here nothing: usually a missing `return`, or a check written as `if (…) true;`. Only `true` lets the caller in. Return `true`, or a string saying why not.

### `Access denied to Tool "…": profile:agent: roles.any: user has none of 'agent', 'admin'`

The caller has none of the roles. If they should have, check where their roles are:

1. **Is the caller signed in?** Anonymous callers have no roles. The Playground's own client is anonymous.
2. **Where does the token keep roles?** Without `claimsMapping.roles`, only a top-level `roles` claim is read. Point it at your provider's claim, like `"realm_access.roles"` or `"https://example.com/roles"`.
3. **Is it the exact name?** Roles are compared exactly: `Admin` isn't `admin`.

### `Invalid authorities rule: Tool "…": authorities names an unknown profile "…"`

The entry names a profile that isn't in `authorities.profiles`, often a typo, so the server doesn't start. Fix the name, or add the profile. A `custom` evaluator's name isn't checked at startup: `custom evaluator 'x' is not registered` shows up only when the entry is called, and in the meantime the entry is hidden from every list.

### TypeScript accepts a profile name that isn't in `profiles`

Profile names are typed as `string`, and adding names to the global `FrontMcpAuthorityProfiles` interface doesn't narrow them in FrontMCP 1.9.4. Check names with `satisfies` against a type taken from your `profiles` object: see [Typing profile names](#typing-profile-names). The server still refuses an unknown name when it starts.

### A tool is missing from everyone's `tools/list`

Its rule depends on the call: an `input.` path, `{ fromInput }`, or `relationships`. Lists are checked without arguments, so the rule fails there for everyone. Move the argument check into `execute()`, as in [Rules on the call's arguments](#rules-on-the-calls-arguments). A `custom` check whose evaluator isn't registered does the same.

### `attributes.match: 'env.NODE_ENV' expected 'production' but got '[redacted]'`

The rule compares an environment variable, and the server's value is different, or the variable isn't set. A refusal never shows an `env.` value, so check the variable where the server runs. Variables are read when the rule is checked, from the server's process, not from the caller. See [What a rule can't see](#what-a-rule-cant-see).

### `tools/list` fails with a guard's error

A guard threw while `tools/list` was being checked, and that fails the whole list (`-32603`; the message is hidden in production). Catch errors inside the guard and return `false` or a reason.

### `relationships: user '…' is not '…' of …` for every caller

The server has no `relationshipResolver`, so every relationship check fails. Add one to `@FrontMcp({ authorities })`.

### Anyone can call a tool or agent that has `authorities`

A rule that checks nothing stops the server, so the rule itself runs. Check what surrounds it: a hook around `checkEntryAuthorities` that catches the refusal and doesn't rethrow it ([Auditing decisions](#auditing-decisions)); or a tool of an app with its own `auth` on the main endpoint of a `local` or `remote` server, where the server's `auth` applies and [the app's is checked per call only with `incrementalAuth`](https://frontmcp.dev/reference/auth/modes#auth-for-one-app). If the caller is who you expected, the rule may simply admit them: call the entry as a caller who should be refused.

### `-32003` from `resources/read` or `prompts/get`

That's how resources, prompts and skills refuse. `error.data.deniedBy` says which part of the rule failed, and `error.data.requiredScopes` which scopes would help, if the server has a `scopeMapping`.
