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 introduces them; this page has every option.

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

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.

Options

OptionTypeDefaultDescription
profilesRecord<string, Rule>{}Named 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 claimsWhere 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 reads the same mapping.
claimsResolver(authInfo) => { roles, permissions, claims }noneWorks out the caller's roles, permissions and claims in code, from authInfo. Replaces claimsMapping, for rules and for this.auth: see Working out roles in code.
evaluatorsRecord<string, { name, evaluate(policy, ctx) }>noneChecks of your own, which a rule names under custom.
relationshipResolver{ check(type, resource, resourceId, userSub, ctx) }noneAnswers a rule's relationships, such as "is this user the assignee of ticket T-1?". Without one, every relationship check fails.
scopeMapping{ roles?, permissions?, profiles? }noneMaps 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>)[]noneAdd fields of your own to this.auth, from the caller's claims: see Adding fields to this.auth. 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. 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.

authorities on an entry

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

FormExamplePasses when
A profile nameauthorities: "agent"The profile passes.
A list of profile namesauthorities: ["agent", "acme"]Every profile passes. They're checked in order, and the first that fails is the reason given.
A ruleauthorities: { 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. 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 of their own.

Rules

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

FieldTypePasses 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 holds. match: { "claims.tenant": "acme" } is the short form of an eq condition.
relationships{ type, resource, resourceId } or a list of themrelationshipResolver.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.
customRecord<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".
allOfRule[]Every rule passes.
anyOfRule[]At least one rule passes.
notRuleThe 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.

Attribute conditions

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

PathHolds
user.subThe caller's id. Missing for anonymous callers, so { path: "user.sub", op: "exists", value: true } refuses them.
user.roles, user.permissionsThe 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.
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.

opHolds 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".

The context guards receive

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

FieldWhat it is
ctx.user.subThe caller's id, undefined if they're anonymous. A caller with a static key (static:…) counts as signed in.
ctx.user.roles, ctx.user.permissionsAs rules see them.
ctx.user.claimsThe token's claims.
ctx.inputThe call's arguments as sent, or {}.
ctx.envThe server's environment variables, read by name, like ctx.env.NODE_ENV. It can't be listed: Object.keys(ctx.env) is [].
ctx.relationshipsThe 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:

{
  "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:

{
  "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:

ReasonWhen
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 ticketA relationship.
<the evaluator's deniedBy>A custom evaluator.
not: inner policy was granted (negated to denied)not.
custom evaluator 'x' is not registeredA 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 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.
  • 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() is checked against the same caller, so it can't reach what the caller couldn't.
  • In-process servers: createDirect() 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() 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 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, 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:

Open
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" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
// 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"

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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 counts as signed in, as does an in-process call through createDirect() without an authContext, whose caller is direct:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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:

Open
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"] };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The same split works for tenants: a profile like acme in 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:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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, may be async, and returns true, or a string saying why not. Only true lets the caller in; false, a string, and anything else refuse:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Named checks

Example 1 of 2

Relationships

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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 (the token's claims are in authInfo.user) and returns the roles, permissions and claims that rules and this.auth both see:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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, 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.

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Other entries

Example 1 of 4

Resource

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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 shows what they return). Who may read them is skillsConfig.auth:

authRequests 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 handler or a flow that answers HTTP requests, can check skillsConfig.auth the way the built-in endpoints do, with authorizeSkillHttpRequest() from @frontmcp/sdk:

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() 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:

authAllowed whenauthInfoRefused with
"inherit", or unsetThe 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.{}: 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(), which builds the server without serving it:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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/listCall refused
authoritiesFor callers who fail the ruleFor callers who fail the rule, before execute()
A check in execute()NoYes, with a message you write for the model
visibility: "hidden"For everyoneNo
A hook that filters tools/listFor whoever the hook dropsNo

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:

Open
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))));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 can record every decision. Did hooks run only when the stage succeeds, so use an Around hook to see refusals too:

Open
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
    }
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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. 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. 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.

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); 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. 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.