Deciding Who Can Call What

IntermediateMCP 2026-07-28

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:

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

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

Fieldpublicstatictransparent, with a token
user.subanon: and a new id per requeststatic: and 12 hex charactersThe token's sub
isAnonymoustruefalsefalse
scopesThe mode's anonymousScopes, ["anonymous"] by defaultThe mode's scopes, ["static"] by defaultThe 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:

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

  1. 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.
  2. PublicMcpError keeps it readable in production. The message reaches the model word for word. A plain Error would become "Internal FrontMCP error" with an error ID.
  3. 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 HTTP 200.)

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:

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

  • authorities on 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.
  • profiles in @FrontMcp({ authorities }) names reusable rules. roles: { any: [...] } passes if the caller has at least one of the roles; all requires every one.
  • claimsMapping tells FrontMCP where the caller's roles are in the token's claims. roles: "roles" reads the roles claim, and a path like "realm_access.roles" reads a nested one. permissions works the same way, and this.auth.roles follows the same mapping. When roles have to be worked out rather than read, claimsResolver takes a function instead, which gets this.context.authInfo and returns { roles, permissions, claims }; rules and this.auth both 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:

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

main.ts
@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:

Open
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/listCallable by clients
No option (visibility: "public")YesYes
visibility: "hidden"No, for everyoneYes, by anyone who knows the name
visibility: "internal"NoNo
authorities: …Only for callers the rule lets throughOnly 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.auth holds the caller: user.sub, isAnonymous, scopes, roles and the token's claims, with checks like hasScope() and hasRole().
  • To refuse a call, this.fail(new PublicMcpError(message, "FORBIDDEN")) before changing anything, with a message that tells the model what to do next.
  • authorities on a tool, with profiles and a claimsMapping in @FrontMcp({ authorities }), refuses calls before execute() and hides the tool from callers who can't use it.
  • An authorities refusal has the code AUTHORITY_DENIED and a message written for developers, so use execute() checks where the model needs an explanation.
  • visibility: "hidden" only hides a tool from tools/list; anyone can still call it. internal tools can't be called by clients.
  • Every property and method of this.auth, per auth mode and entry point, is in the this.auth reference.

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.

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