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.
@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 {}Options
| Option | Type | Default | Description |
|---|---|---|---|
profiles | Record<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 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 reads the same mapping. |
claimsResolver | (authInfo) => { roles, permissions, claims } | none | Works 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. |
evaluators | Record<string, { name, evaluate(policy, ctx) }> | none | Checks of your own, which a rule names under custom. |
relationshipResolver | { check(type, resource, resourceId, userSub, ctx) } | none | Answers 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? } | 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. 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:
| 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. 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".
| 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 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. |
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.
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. |
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".
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 (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:
{
"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:
| 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 explains when a check in execute(), with a message for the model, serves callers better.
Caveats
- Rules run before validation.
input.andfromInputsee 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 whiletools/listis 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 theauthContext.userof each call (without one, the caller isdirect, with no roles), and itscallTool()throws the refusal.create()checks them the same way (before 1.9.3 it dropped theauthoritiesoption, so a server with rules didn't start under it). this.authand rules agree. Both readclaimsMapping, or, when there is one, whatclaimsResolverreturns.- Scopes mean what their issuer checked. In
localandremotemodes, a token'sscopeis what the client asked for, limited to the server'sallowedScopes: any client can get any allowed scope, for any user, so a rule on scopes (throughpermissions: "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, intransparentmode, 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:
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:
// 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:
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:
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:
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:
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:
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
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:
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.
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
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:
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:
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:
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. | {}: 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:
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:
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" | 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:
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:
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:
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:
- Is the caller signed in? Anonymous callers have no roles. The Playground's own client is anonymous.
- Where does the token keep roles? Without
claimsMapping.roles, only a top-levelrolesclaim is read. Point it at your provider's claim, like"realm_access.roles"or"https://example.com/roles". - Is it the exact name? Roles are compared exactly:
Adminisn'tadmin.
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.