this.auth
this.auth describes the caller that your server's authentication established for this request: who they are (user.sub), whether they signed in (isAnonymous), what they were granted (scopes, roles, permissions), and what their token said (claims), with checks like hasScope(). The model writes a tool's arguments, but it can't touch any of this, so this.auth is what access decisions should be based on. It's there in tools, resources, prompts, agents and jobs.
const { user, isAnonymous, scopes, roles, permissions, claims } = this.auth
this.auth.hasScope(scope)
Reference
this.auth
Read this.auth inside execute().
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() } })
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. Ask the user to sign in.", "FORBIDDEN"));
}
return { id, status: "closed", closedBy: this.auth.user.sub };
}
}Properties
| Property | Type | Description |
|---|---|---|
user | { sub, name?, email?, picture? } | The caller. sub is their id: the token's sub claim (or, in a token without one, its client_id or azp; a token with none of them is refused), or the id FrontMCP gives callers without a token. name, email and picture are the claims of the same name, when there are any. |
isAnonymous | boolean | true when user.sub is empty or starts with anon:, that is, when the caller didn't sign in. isAnonymousSubject(sub), exported by @frontmcp/sdk, applies the same rule to a sub you hold. |
scopes | readonly string[] | The scopes the caller was granted: a token's scope and scp claims, or the scopes your auth mode gives. |
roles | readonly string[] | The token's roles claim, or the claim claimsMapping.roles points to. [] when there's none. |
permissions | readonly string[] | The token's permissions claim, or the claim claimsMapping.permissions points to. A string is split on spaces. |
claims | Readonly<Record<string, unknown>> | Every claim of the caller's token, like tenant or realm_access. Callers without a token get a few made-up claims: see what each mode fills in. |
sessionId | string | The same as this.context.sessionId: under MCP 2026-07-28, a new anon: id on every request, even for a caller with a static key, and direct: and a UUID in-process. Don't use it to identify anyone. (Changed in 1.8.5: before, the auth layer could record an id of its own here.) |
mode | string | Meant to name the auth mode. In FrontMCP 1.9.3 it's "public" for a caller holding an anonymous token, and "authenticated" for every other caller, in every mode, public included, so don't rely on it. |
Methods
| Method | Returns true when |
|---|---|
hasScope(scope) | scopes includes scope. |
hasAllScopes(scopes), hasAnyScope(scopes) | scopes includes every one, or at least one, of them. |
hasRole(role) | roles includes role. |
hasAllRoles(roles), hasAnyRole(roles) | roles includes every one, or at least one, of them. |
hasPermission(permission) | permissions includes permission. |
hasAllPermissions(permissions), hasAnyPermission(permissions) | permissions includes every one, or at least one, of them. |
Each is an exact, case-sensitive match: hasScope("tickets") is false for a caller with tickets:read, and nothing expands wildcards.
What each auth mode fills in
public (the default) | static | transparent, with a token | transparent with allowAnonymous, no token | |
|---|---|---|---|---|
user.sub | anon: and a new id per request | static: and 12 hex characters | The token's sub | anon: and a new id per request |
user.name, user.email | "Anonymous", none | "Static token", none | The token's name and email | "Anonymous", none |
isAnonymous | true | false | false | true |
scopes | anonymousScopes, ["anonymous"] by default | The mode's scopes, ["static"] by default | The token's scope and scp | anonymousScopes |
roles, permissions | [] | [] | From the token's claims | [] |
claims | { iss: "public", sub, name, scope } | { iss: "static", sub, name, scope } | Every claim in the token | { iss: "transparent-anon", sub, name, scope } |
Seeing what each mode gives shows all four, and an anonymous token. local and remote, the modes where FrontMCP runs the sign-in itself, need a browser sign-in, so they aren't shown on this page: Local auth and Remote and proxied auth show what their tools see.
Anonymous tokens
In public mode, and in local mode with allowDefaultPublic, a client can also ask /oauth/token for a token with grant_type=anonymous and its client_id. Whoever holds that token is still anonymous: user.sub is anon: and an id that stays the same for as long as the token lasts, isAnonymous is true, and mode is "public". Its scopes are the mode's anonymousScopes, as are those of a caller without a token.
What each entry point fills in
The table of modes above is for requests over HTTP: the Node server, createFetchHandler(), and the Playground. In-process entry points have no token to check, so the calling code says who the user is:
| Entry point | this.auth |
|---|---|
create() or createDirect(), without authContext | user.sub is "direct", and isAnonymous is false: a signed-in user with no scopes and no roles. |
create() or createDirect(), with authContext: { user, scopes } | user.sub, name and email, roles and permissions come from user, and claims holds all of it. scopes is authContext.scopes, or [] without it: a scope claim in user isn't read. |
connect(), without session.user | user.sub is "", and isAnonymous is true. authToken isn't checked or read. |
connect(), with session: { user, scopes } | As for authContext.user. scopes is session.scopes, or [] without it, with or without a user. |
Nothing checks the user you pass in-process: your code is the authentication, so pass only a user your code has verified.
Clients on MCP versions before 2026-07-28, which keep a session over HTTP, get the token's scopes in this.auth.scopes like any other caller. createFetchHandler() and the Playground keep no sessions, so this page can't show it: it was checked against a server listening over HTTP, with a client that opened a session with initialize.
Changed in 1.8.7: before, this.auth.scopes was empty for those clients, although this.context.authInfo.scopes had the scopes, so a tool that checked hasScope() refused them. this.auth now merges what the request's context knows about the caller, so the two agree.
Where it's available
| In | this.auth |
|---|---|
| Tools, resources, agents | The caller of the request. |
| Jobs | The caller who started the run. |
| Channels | An empty caller: user.sub is "" and isAnonymous is true. See Context classes. |
| Prompts | The caller of the request. See Reading the caller in a prompt. |
Options that change this.auth
@FrontMcp({ authorities }) holds the server's authorization rules. Its claimsMapping also tells this.auth where the claims are:
| Option | What it changes |
|---|---|
claimsMapping.roles | A dot path to the roles claim, like "realm_access.roles". this.auth.roles reads it instead of roles. |
claimsMapping.permissions | A dot path to the permissions claim. "scope" turns the token's scopes into permissions. |
claimsMapping.userId | A dot path to the claim to use as user.sub, instead of sub. The token must still have a sub, client_id or azp: one with none of them is refused before the mapping applies. |
pipes | Functions that add fields of your own to this.auth, from the caller's claims. See Adding fields with pipes. (Changed in 1.9: before, FrontMCP never ran them.) |
To work roles out in code instead, see claimsResolver. With one, claimsMapping isn't read: this.auth.roles, permissions and claims are what the resolver returns, the same as rules see, and user.sub is the token's sub. (Changed in 1.9.2: before, the resolver changed only what rules saw.)
Caveats
this.authis built from the raw result of authentication, which is alsothis.context.authInfo. UseauthInfofor whatthis.authleaves out, liketoken, the caller's token itself.- FrontMCP checked the token's signature, issuer, expiry and audience. What a claim means is up to your identity provider: a
rolesclaim holds whatever it put there. - Everything else about a request, like its arguments,
clientInfoand headers, is written by the client. Reading the Request explains why onlythis.authshould decide access. - How the caller is established depends on the server's mode, in Auth modes, and on how its token is checked, in Tokens and sessions.
Usage
Checking a scope before acting
This server accepts tokens from an identity provider in transparent mode, and lets callers without a token in with the scope tickets:read. close_ticket needs tickets:write. The Playground has no token, so its call is refused; the tests call as two signed-in users, with tokens a test key signed ahead of time.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() } })
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. Ask the user to sign in.", "FORBIDDEN"));
}
return { id, status: "closed", closedBy: this.auth.user.sub };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Check before you change anything, so a refused call has no effect, and say in the tool's description who may use it. To refuse the call before execute() runs, and hide the tool from callers who can't use it, declare authorities instead.
Using who the caller is
user and claims carry what the token says about the caller. Here a note records its author, and is filed under the caller's tenant, a claim this provider adds. The tests use the same callAs() and tokens as above.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "add_note", description: "Add a note to a support ticket", inputSchema: { ticket: z.string(), text: z.string() } })
export class AddNote extends ToolContext {
async execute({ ticket, text }: { ticket: string; text: string }) {
const { user, isAnonymous, claims } = this.auth;
return {
ticket,
text,
author: isAnonymous ? "a guest" : `${user.name} <${user.email}>`,
tenant: typeof claims.tenant === "string" ? claims.tenant : null,
};
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
claims is typed unknown per claim, because a token can hold anything. Check a claim's type before you use it.
Reading roles from another claim
Identity providers put roles in different places. Keycloak, for one, puts them in realm_access.roles. Point claimsMapping.roles at them, and this.auth.roles and hasRole() read them from there. permissions: "scope" turns the token's scopes into permissions too, and userId picks the claim that becomes user.sub:
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
async execute() {
const { user, roles, permissions } = this.auth;
return { sub: user.sub, roles, permissions, isLead: this.auth.hasRole("lead") };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Without claimsMapping, the same token gives roles: ["agent"], from its roles claim, and permissions: [], as the second test shows. The same mapping is used by authorities rules, so a rule and a check in execute() agree on what a role is.
Seeing what each mode gives
whoami returns the whole of this.auth except the session id. The Playground's server is in public mode; the tests start one server in each of the other modes and call it over HTTP.
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
async execute() {
const { user, isAnonymous, scopes, roles, permissions, claims, mode } = this.auth;
const matches = { exact: this.auth.hasScope("tickets:read"), prefix: this.auth.hasScope("tickets"), upperCase: this.auth.hasScope("TICKETS:READ") };
return { user, isAnonymous, scopes, roles, permissions, claims, mode, matches };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Calling as a user from tests and scripts
In-process, the code that calls the server says who the user is. create() and createDirect() take an authContext per call, and connect() a session per client:
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
async execute() {
const { user, isAnonymous, scopes, roles, permissions } = this.auth;
return { sub: user.sub, isAnonymous, scopes, roles, permissions };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
create() and createDirect() give a caller the scopes in authContext.scopes, and connect() the scopes in session.scopes, so a test can check a tool's hasScope() in-process. A scope claim in user isn't read by either. (Changed in 1.9.2: before, in-process callers never had scopes. Changed in 1.9.3: connect() takes session.scopes; before, its callers always had [].) Running FrontMCP Anywhere covers create(), createDirect() and connect().
The caller in resources, agents and jobs
ResourceContext, AgentContext and JobContext have the same this.auth. In a job, it's the caller who started the run.
Other entries
Example 1 of 3
Resource
import { Resource, ResourceContext } from "@frontmcp/sdk";
const assigned: Record<string, string[]> = { nour: ["T-1", "T-3"] };
@Resource({ name: "my_tickets", uri: "me://tickets", mimeType: "application/json", description: "Tickets assigned to the caller" })
export class MyTickets extends ResourceContext {
async execute(uri: string) {
const { user, isAnonymous } = this.auth;
const tickets = isAnonymous ? [] : (assigned[user.sub] ?? []);
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ caller: user.sub, tickets }) }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Adding fields with pipes
authorities.pipes turns claims into fields of your own on this.auth. Each pipe gets the caller's claims and returns an object, at once or as a promise, and FrontMCP adds its fields before any hook or execute() reads this.auth, in tools, resources, prompts, agents and jobs. Declare the fields on ExtendFrontMcpAuthContext to give them types:
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { planOf } from "./plans";
declare global {
interface ExtendFrontMcpAuthContext {
tenantId: string;
plan: "enterprise" | "free";
}
}
@Tool({ name: "whoami", description: "Say which tenant the caller belongs to, and its plan", inputSchema: {} })
export class WhoAmI extends ToolContext {
async execute() {
return { sub: this.auth.user.sub, tenantId: this.auth.tenantId, plan: this.auth.plan };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
class HelpDesk {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDesk],
authorities: {
profiles: {},
pipes: [
(claims: Readonly<Record<string, unknown>>) => ({ tenantId: String(claims.tenant ?? "none") }),
async (claims: Readonly<Record<string, unknown>>) => ({ plan: await planOf(String(claims.tenant ?? "")) }),
],
},
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Playground's caller has no token, so its claims have no tenant, and the pipes fall back. In-process, the claims are authContext.user, as the second test shows. A pipe that does a lookup runs on every request, so keep it fast, or cache what it finds. Changed in 1.9: before, FrontMCP accepted pipes and never ran them, so the fields were always undefined.
Reading the caller in a prompt
A prompt has the same this.auth as a tool, so it can shape its messages for the caller:
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";
@Prompt({
name: "handover_note",
description: "Write a handover note for a ticket",
arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class HandoverNote extends PromptContext {
async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
const { user, isAnonymous, scopes } = this.auth;
const from = isAnonymous ? "a colleague" : (user.name ?? user.sub);
const text = `Write a handover note for ticket ${id}, from ${from}. Scopes: ${scopes.join(", ")}.`;
return { messages: [{ role: "user", content: { type: "text", text } }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
PromptContext also still has this.authInfo, the raw result of authentication, which is deprecated: its shape differs between entry points, and claimsMapping doesn't apply to it. To apply the rule of isAnonymous to a sub you hold, use isAnonymousSubject(sub), exported by @frontmcp/sdk: true for a missing or empty sub, and for one that starts with anon:. Changed in 1.9.2: before, prompts had no this.auth, and this.authInfo was the way to read the caller.
Troubleshooting
hasScope() is false though the caller has the scope
Check, in order:
- Is the call in-process?
create()andcreateDirect()give a caller only the scopes inauthContext.scopes, andconnect()none. See Calling as a user. - Is it the exact string?
hasScope()doesn't match prefixes or wildcards:ticketsisn'ttickets:read.
this.auth.roles is empty, though the token has roles
Without a mapping, this.auth.roles reads only a top-level roles claim. Point authorities.claimsMapping.roles at the claim your provider uses, as in Reading roles from another claim. In-process, put roles in authContext.user.
this.auth.user.sub is "direct"
The tool was called with create() or createDirect() without an authContext. FrontMCP treats that as a signed-in user called direct, with isAnonymous: false. Pass authContext: { user: { sub } } on each call, or check the scopes or roles you need rather than isAnonymous.
this.auth.user.sub is ""
The tool was called through connect() without a session.user, or the code runs in a channel's onEvent(), which has no caller. Both count as anonymous.
this.auth.user.sub is different on every call
The caller is anonymous. Under MCP 2026-07-28 each request from a caller without a token gets a new anon: id, so it can't be used to recognize a returning caller, or to keep anything per user. Require a sign-in for tools that need to.
this.auth.mode is "authenticated" in public mode
FrontMCP 1.9.3 sets mode to "authenticated" for callers without a token, in every auth mode, and for every signed-in caller. Only a caller holding an anonymous token gets "public". Use isAnonymous to tell signed-in callers from others.
A field from authorities.pipes is undefined
Check, in order:
- Did the pipe throw? FrontMCP logs
[FrontMcpAuth] pipe failed:and the error's message, leaves that pipe's fields out, and goes on with the request. The other pipes' fields are there. - Is the claim there? A pipe gets the caller's claims: a token's,
authContext.userin-process, and for callers without a token onlyiss,sub,nameandscope. With aclaimsResolver, it gets theclaimsthe resolver returns instead. Give a field a default for a claim that's missing, astenantIddoes in Adding fields with pipes.