Deciding Who Can Call What
Authentication tells your server who is calling. Authorization decides what they may do: anyone may search tickets, only support agents may close them, only admins may delete them. FrontMCP gives you two places to decide. A tool can check the caller inside execute(), and a tool can declare who may use it with authorities, so FrontMCP refuses the call, and hides the tool, before your code runs.
You will learn
- How to read who is calling inside
execute() - How to refuse a call with an error the model can act on
- How to declare who may call a tool with
authorities - How to hide tools from callers who can't use them
- Why
visibility: "hidden"doesn't protect a tool
A tool anyone can call
This close_ticket does its job. But in the Playground you're an anonymous caller, and it closed the ticket anyway:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const tickets = new Map([
["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
]);
@Tool({
name: "close_ticket",
description: "Close a support ticket.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
ticket.status = "closed";
return { id, closed: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Putting the server behind a shared key or an identity provider keeps strangers out, but everyone who gets in can still call every tool. A customer who signs in to check on their own ticket could close anyone's. The tool has to know who is calling, and decide.
Reading who's calling
Inside execute(), this.auth describes the caller FrontMCP authenticated for this request:
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({
name: "whoami",
description: "Say who the server thinks is calling.",
inputSchema: {},
annotations: { readOnlyHint: true },
})
export class WhoAmI extends ToolContext {
async execute() {
const { user, isAnonymous, scopes, roles } = this.auth;
return { caller: user.sub, anonymous: isAnonymous, scopes, roles };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
What each field holds depends on the server's auth mode:
| Field | public | static | transparent, with a token |
|---|---|---|---|
user.sub | anon: and a new id per request | static: and 12 hex characters | The token's sub |
isAnonymous | true | false | false |
scopes | The mode's anonymousScopes, ["anonymous"] by default | The mode's scopes, ["static"] by default | The token's scope claim, split on spaces, or its scp claim |
roles | [] | [] | The token's roles claim |
claims | { sub, iss: "public", name: "Anonymous", scope } | { sub, iss: "static", name: "Static token", scope } | Every claim in the token, such as name or roles |
hasScope(), hasRole() and hasPermission() check those lists for you. claims holds what the token said, and nothing else: FrontMCP verified the token's signature, but a roles claim means what your identity provider meant by it. If your provider keeps roles in another claim, the claimsMapping further down tells this.auth where to look.
this.auth is built from this.context.authInfo, the raw result of authentication: clientId, scopes, the token's claims in user, and token, the token itself. It's the same for every client, whatever MCP version it speaks. (Before 1.8.7, a client that kept a session, which means every version before 2026-07-28, saw this.auth.scopes empty and hasScope() always false, and a tool had to read this.context.authInfo.scopes.)
Refusing a call the model can act on
When the caller isn't allowed to do something, stop with this.fail() and a PublicMcpError whose message tells the model what happened and what to do instead. Support agents sign in with tokens that carry the tickets:write scope; nobody else has it:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const tickets = new Map([
["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
]);
@Tool({
name: "close_ticket",
description: "Close a support ticket. Only signed-in support agents can close tickets.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
if (!this.auth.hasScope("tickets:write")) {
this.fail(
new PublicMcpError(
"Only signed-in support agents can close tickets. Tell the user to sign in as an agent. Don't retry this call until they have.",
"FORBIDDEN",
),
);
}
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
ticket.status = "closed";
return { id, closed: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The result is an ordinary tool error, with isError: true. Three details make it useful:
- The message is written for the model. It says what the rule is and what to do next. "Forbidden" alone would leave the model guessing, and likely retrying.
PublicMcpErrorkeeps it readable in production. The message reaches the model word for word. A plainErrorwould become "Internal FrontMCP error" with an error ID.- The second argument is a code. It arrives as
_meta.code: "FORBIDDEN", so a client or a test can tell a refusal from other failures without parsing the text. (There's also a third argument, an HTTP status, but a tool error always goes out with HTTP200.)
Check before you change anything, so a refused call has no effects. And put the rule in the description too ("Only signed-in support agents can close tickets"), so a model can tell the user before it tries.
Declaring who may call a tool
A check in execute() only runs once the tool is called. The tool is still listed for everyone, and in a server with many tools it's easy to forget the check in one of them. authorities puts the rule on the tool itself. FrontMCP checks it before execute() runs, and leaves the tool out of tools/list for callers who wouldn't pass. Rules are usually named once, as profiles, in @FrontMcp:
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, DeleteTicket, SearchTickets } from "./tickets.tools";
@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket, DeleteTicket] })
class HelpDeskApp {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
authorities: {
// Where to find the caller's roles: the token's `roles` claim.
claimsMapping: { roles: "roles" },
profiles: {
agent: { roles: { any: ["agent", "admin"] } },
admin: { roles: { any: ["admin"] } },
},
},
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Open the Capabilities tab: an anonymous caller has no roles, so only search_tickets is listed. Calling close_ticket by name is refused anyway. The last test calls the server in-process as the agent nour, whose claims include roles: ["agent"]: close_ticket appears and works, and delete_ticket stays hidden. Running FrontMCP Anywhere covers createDirect().
The pieces:
authoritieson a tool names the rule: a profile like"agent", a list of profiles that must all pass, like["agent", "billing"], or a rule written in place.profilesin@FrontMcp({ authorities })names reusable rules.roles: { any: [...] }passes if the caller has at least one of the roles;allrequires every one.claimsMappingtells FrontMCP where the caller's roles are in the token's claims.roles: "roles"reads therolesclaim, and a path like"realm_access.roles"reads a nested one.permissionsworks the same way, andthis.auth.rolesfollows the same mapping. When roles have to be worked out rather than read,claimsResolvertakes a function instead, which getsthis.context.authInfoand returns{ roles, permissions, claims }; rules andthis.authboth use what it returns.
A tool with authorities on a server without an authorities option doesn't start: FrontMCP refuses, rather than leave the tool open by accident.
Rules can check more than roles:
| Rule | Passes when |
|---|---|
{ roles: { any: ["agent"] } } | The caller has at least one of the roles. all requires all of them. |
{ permissions: { all: ["tickets:export"] } } | The caller has every permission. any requires one. |
{ attributes: { conditions: [{ path: "input.id", op: "startsWith", value: "T-" }] } } | Each condition holds. Paths start with user., claims., input. (the call's arguments) or env. (the server's environment variables). |
{ guards: [(ctx) => ...] } | Every function returns true. Anything else refuses, undefined included, and a string becomes the reason. |
{ anyOf: [ruleA, ruleB] } | At least one of the rules passes. allOf and not also exist. |
Authorities has every rule form and operator, and the settings that look like they protect a tool but don't.
Scopes as permissions
Roles are one way to describe callers. Scopes are another: a token for a support agent carries tickets:write, and an anonymous caller in transparent mode gets the anonymousScopes you set. Point claimsMapping.permissions at the scope claim, and FrontMCP splits it on spaces into permissions that rules can require:
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: {
mode: "transparent",
provider: "https://auth.example.com",
allowAnonymous: true,
anonymousScopes: ["tickets:read"],
},
authorities: {
claimsMapping: { roles: "roles", permissions: "scope" },
profiles: { reader: { permissions: { all: ["tickets:read"] } } },
},
})
export default class Server {}The last challenge on this page uses this setup.
Hiding a tool isn't protecting it
visibility: "hidden" looks like a way to keep a tool away from callers. It isn't:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "purge_closed_tickets",
description: "Delete every closed ticket.",
inputSchema: {},
annotations: { destructiveHint: true },
visibility: "hidden",
})
export class PurgeClosedTickets extends ToolContext {
async execute() {
return { purged: 12 };
}
}
@Tool({
name: "rebuild_search_index",
description: "Rebuild the ticket search index.",
inputSchema: {},
visibility: "internal",
})
export class RebuildSearchIndex extends ToolContext {
async execute() {
return { rebuilt: true };
}
}
@Tool({ name: "get_ticket", description: "Get one ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, title: "Cannot log in" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Both tools are missing from the Capabilities tab, but the Tests tab shows the difference. Anyone who knows the name of a hidden tool can call it, and names aren't secrets: they're in your code, your docs and old conversations. Hidden is for tools the model doesn't need to see, such as one your own client code calls by name; it keeps them out of the list, nothing more. An internal tool isn't reachable by clients at all; a call gets Tool "rebuild_search_index" not found.
In tools/list | Callable by clients | |
|---|---|---|
No option (visibility: "public") | Yes | Yes |
visibility: "hidden" | No, for everyone | Yes, by anyone who knows the name |
visibility: "internal" | No | No |
authorities: … | Only for callers the rule lets through | Only by callers the rule lets through |
To keep a tool from a caller, use authorities or a check in execute(). Visibility only decides what a list shows.
Recap
this.authholds the caller:user.sub,isAnonymous,scopes,rolesand the token'sclaims, with checks likehasScope()andhasRole().- To refuse a call,
this.fail(new PublicMcpError(message, "FORBIDDEN"))before changing anything, with a message that tells the model what to do next. authoritieson a tool, with profiles and aclaimsMappingin@FrontMcp({ authorities }), refuses calls beforeexecute()and hides the tool from callers who can't use it.- An
authoritiesrefusal has the codeAUTHORITY_DENIEDand a message written for developers, so useexecute()checks where the model needs an explanation. visibility: "hidden"only hides a tool fromtools/list; anyone can still call it.internaltools can't be called by clients.- Every property and method of
this.auth, per auth mode and entry point, is in thethis.authreference.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 3
Refuse anonymous callers
reopen_ticket reopens a closed ticket for anyone. Make it refuse anonymous callers with a tool error whose code is FORBIDDEN and whose message tells the model to ask the user to sign in. A refused call must leave the ticket closed.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const tickets = new Map([
["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }],
]);
@Tool({ name: "get_ticket", description: "Get one support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
return ticket;
}
}
@Tool({
name: "reopen_ticket",
description: "Reopen a closed support ticket.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = tickets.get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
ticket.status = "open";
return { id, reopened: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.