Progressive auth

A user doesn't have to grant everything when they first sign in. In local mode, FrontMCP can keep track of what each token may reach and, when a tool needs more, answer with a link where the user grants it. There are two kinds of "more": another app, with incrementalAuth, where the user authorizes it without signing in again and the client gets a new token; and a credential a tool needs, like an API key for another service, which the user connects through a link while the token stays the same. Tool consent is the third way to narrow a token, but it can only be widened by signing in again.

auth: { mode: "local", incrementalAuth: { enabled?, skippedAppBehavior? } }
@Tool({ authProviders: [{ name, required? }] })
await this.credentials.requireConnect({ key, context? })

Reference

Three ways a token is narrowed

AppsCredentialsTools
Turned on byincrementalAuthauthProviders on a tool, or this.credentialsconsent: { enabled: true }
Recorded inthe token's authorized_apps claimthe user's credential vault, on the serverthe token's consent claim
A refused call getsan isError result, AUTHORIZATION_REQUIREDa JSON-RPC error, -32001an isError result, TOOL_NOT_CONSENTED
Its link/oauth/authorize?app=…&tool=…&ticket=…, relativehttp://…/oauth/connect?token=…, absolutenone
The userauthorizes the app, without signing in againtypes the credential into a one-field pagesigns in again
The clientexchanges a new code for a new tokenretries with the same tokenuses the new token

incrementalAuth

main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk, Billing],
  auth: { mode: "local", incrementalAuth: {} },
})
export default class Server {}
OptionDefaultWhat it does
enabledtrue once the block is thereTokens carry authorized_apps, and calls into other apps are refused. Without the block, or with false, there are no app grants: every token reaches every app.
skippedAppBehavior"anonymous"Only for an app whose own auth is { mode: "public" }: with "anonymous", its tools run for a token that wasn't granted the app; "require-auth" refuses them too. An app with its own auth also turns the sign-in page into a provider picker: see Apps with their own auth.
allowSkip, showAllAppsAtOncetrueAccepted and not read yet, in FrontMCP 1.9.3.

The first grant

The client says which apps to grant with apps on /oauth/authorize: apps=help-desk or apps=help-desk,billing, by app id. Ids of apps the server doesn't have are dropped. Without apps, the user gets every app, so a client that knows nothing about progressive auth still works. The token records the grant:

{ "sub": "7bd15cce-…", "scope": "openid email", "email": "nour@example.com", "authorized_apps": ["help-desk"], "iat": 1790589222, "iss": "http://localhost:3000", "exp": 1790592822, "jti": "…", "aud": "http://localhost:3000" }

What the client gets

A call to a tool of an app the token wasn't granted doesn't run. The client gets a tool result:

{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Authorization required for billing. Please authorize to use billing:refund_invoice.\n\nTo authorize, click: /oauth/authorize?app=billing&tool=billing%3Arefund_invoice&ticket=eyJkYXRh…"
  }],
  "_meta": {
    "code": "AUTHORIZATION_REQUIRED",
    "authorization_required": true,
    "app": "billing",
    "tool": "billing:refund_invoice",
    "auth_url": "/oauth/authorize?app=billing&tool=billing%3Arefund_invoice&ticket=eyJkYXRh…",
    "session_mode": "stateful",
    "supports_incremental": true,
    "errorId": "err_2a4c81ce2475c193",
    "timestamp": "2026-09-27T11:20:44.298Z"
  }
}

auth_url is a path on your server, and it only carries the app, the tool and a ticket. It isn't an OAuth request by itself: opened as it is, it's a 400 error page. The client resolves it against the server's URL and adds its own authorize parameters, as for a sign-in: response_type, client_id, redirect_uri, code_challenge and state. Then it opens it in the user's browser. The _meta fields and the link are FrontMCP's own, not part of MCP, so this takes a client written for them. A model that only reads the text sees a link it can't use.

When the tool declares authProviders with scopes, _meta.required_scopes lists those scopes, so the client knows what the tool will ask for; without any, it's left out. (Changed in 1.9.3: before, it was never filled.) The _meta shape also has elicit_id and pending_auth_id; FrontMCP 1.9.3 doesn't fill them yet.

The ticket

FrontMCP makes the ticket at the moment it refuses the call, when it knows who the caller is: it holds the user's sub, the app, the tool, the apps already granted, an id and an expiry, signed with JWT_SECRET.

  • /oauth/authorize with a valid ticket skips the sign-in page. It shows an "Authorize Billing" page, and its form only sends pending_auth_id. Submitting it redirects to the client with a code and &incremental=true&app_id=billing.
  • The code's token has the same sub and authorized_apps of the old grant plus the new app. Its scope is what this authorize request asked for, within allowedScopes, and it has no email or name.
  • A ticket works once, for five minutes. After that, or without one, the link is an ordinary sign-in. mode=incremental or app= in the query without a valid ticket changes nothing.
  • The old token keeps working, with the old grant, until it expires.
  • Tickets that were used are remembered in tokenStorage: with memory storage, only by the instance that saw them.

Credentials: authProviders and this.credentials

A tool can need a credential that isn't the user's sign-in: an API key for your CRM, say. In local mode, each user has an encrypted credential vault on the server. authenticate can fill it at sign-in by returning credentials, and the user can add to it later through a link.

sync-crm.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "sync_crm",
  description: "Copy the customer's CRM record into the ticket",
  inputSchema: {},
  authProviders: [{ name: "crm" }],
})
export class SyncCrm extends ToolContext {
  async execute() {
    const { secret } = (await this.credentials.get("crm"))!;
    // call the CRM with the user's key
    return { synced: true, keyEndsWith: secret.slice(-4) };
  }
}

With authProviders: [{ name: "crm" }], FrontMCP checks the vault for crm before execute() runs. If it isn't there, the call is refused with a JSON-RPC error:

{
  "code": -32001,
  "message": "Tool \"help-desk:sync_crm\" requires credentials for crm which are not connected for this session. Connect via: http://localhost:3000/oauth/connect?token=eyJkYXRh…",
  "data": {
    "tool": "help-desk:sync_crm",
    "providers": ["crm"],
    "authUrl": "http://localhost:3000/oauth/connect?token=eyJkYXRh…",
    "auth_url": "http://localhost:3000/oauth/connect?token=eyJkYXRh…",
    "errorId": "err_…"
  }
}

{ name: "calendar", required: false } lets the tool run without it, as sync_calendar does below. The same check thrown by your own code as a ToolCredentialsRequiredError gives the same JSON-RPC error; through this.fail(), it's an isError result with TOOL_CREDENTIALS_REQUIRED instead.

this.credentialsReturns
get(key){ secret, metadata? }, or undefined.
list()The keys in the caller's vault.
requireConnect({ key, context? }){ connected: true, key, credential } when it's there. Otherwise { connected: false, key, resumeUrl, message }, with the same kind of link, so a tool can return it or ask for it in its own words. context is a string handed back to authenticate.

Local mode gives tools this.credentials.

Connecting a credential later

The link, /oauth/connect?token=…, is signed for one user and one key, and lasts ten minutes. It opens a page with one field: the first of your login.fields, or a password field named after the key. The form posts to /oauth/connect, and FrontMCP calls authenticate again, with input.resume:

authenticate: async ({ fields, resume }) => {
  if (resume) {
    // resume: { sub, key, context? }. One field, named after the key here.
    const apiKey = fields[resume.key];
    if (!(await crm.isValidKey(apiKey))) return { ok: false, message: "That isn't a CRM API key." };
    return { ok: true, credentials: [{ key: resume.key, secret: apiKey }] };
  }
  // … the sign-in itself
},

{ ok: false, message } shows the page again with the message. { ok: true, credentials } adds them to the user's vault and shows "crm is now connected. You can return to your session." The client retries the call with the token it already has.

  • A new sign-in of the same user that stores credentials starts a new vault with only those, and tokens from before see the new one too.
  • The link is http://localhost:<http.port>/oauth/connect?…, with http.port defaulting to the PORT environment variable, or 3000, unless you set local.issuer to your public URL.
  • Until it expires, the same link can be used again, to replace the credential.

Consent limits a token to the tools the user ticked at sign-in, and a call to another tool gets TOOL_NOT_CONSENTED. There's no link and no ticket: the only way to widen it is a new sign-in.

Scopes

Local mode doesn't ask for scopes later: a client gets the scopes it asks for when it signs in, as far as allowedScopes allows (see Scopes on Local auth), and nothing in FrontMCP 1.9.3 answers a call by asking for a bigger one. Use app grants or consent to hold back part of a server.


Usage

The Playground runs these apps without auth; the tests start them with local auth and play the client, as on Local auth. oauth-client.ts is the client from Signing in, step by step: it plays the MCP client and the user's browser, which keeps the sign-in cookie that ties each sign-in to the browser that started it.

Granting one app at sign-in, and another later

The user signs in to the help desk only. When the model reaches for a billing tool, the client follows the auth_url, the user authorizes Billing, and the new token reaches both apps.

Open
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "list_tickets", description: "List open tickets", inputSchema: {} })
class ListTickets extends ToolContext {
  async execute() {
    return { tickets: ["T-1", "T-2"] };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { id: z.string() } })
class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true, by: this.auth.user.sub };
  }
}

@Tool({
  name: "export_invoices",
  description: "Export invoices to the accounting system",
  inputSchema: {},
  authProviders: [{ name: "accounting", scopes: ["invoices:export"] }],
})
class ExportInvoices extends ToolContext {
  async execute() {
    return { exported: true };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ListTickets] })
export class HelpDesk {}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice, ExportInvoices] })
export class Billing {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A client that handles this well tells the user which app is needed (_meta.app) before it opens the browser, and swaps in the new token when the code comes back. Asking for the right scope again on the incremental request keeps the new token's scopes; its email and name are gone either way, so read the user's id from sub.

Asking for a credential when a tool needs it

The help desk syncs tickets with a CRM, and each agent has their own CRM key. The sign-in stores the agent's desk token in the vault; the CRM key is asked for the first time a tool needs it.

Open
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "sync_crm", description: "Copy the customer's CRM record into the ticket", inputSchema: {}, authProviders: [{ name: "crm" }] })
class SyncCrm extends ToolContext {
  async execute() {
    const crm = await this.credentials.get("crm");
    return { synced: true, keyEndsWith: crm!.secret.slice(-4), connectedFrom: crm!.metadata?.connectedFrom ?? null };
  }
}

@Tool({ name: "crm_status", description: "Say whether the user has connected their CRM", inputSchema: {} })
class CrmStatus extends ToolContext {
  async execute() {
    const result = await this.credentials.requireConnect({ key: "crm", context: "crm_status" });
    return result.connected
      ? { connected: true, keys: await this.credentials.list() }
      : { connected: false, connectUrl: result.resumeUrl, message: result.message };
  }
}

@Tool({ name: "sync_calendar", description: "Add the ticket's due date to the user's calendar, if it's connected", inputSchema: {}, authProviders: [{ name: "calendar", required: false }] })
class SyncCalendar extends ToolContext {
  async execute() {
    return { calendar: (await this.credentials.get("calendar")) ? "updated" : "not connected" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SyncCrm, CrmStatus, SyncCalendar] })
export class HelpDesk {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The link in the error starts with http://localhost:3000, FrontMCP's own default address, even under createFetchHandler(), where the tokens name the address the request came to; the test opens its path on the server it has. On a real server, set local.issuer so the link points where users can reach it.


Troubleshooting

Every tool call answers AUTHORIZATION_REQUIRED

The token has no authorized_apps claim, and with incrementalAuth on FrontMCP refuses such tokens for every app. It was issued before you turned incrementalAuth on, or by a sign-in through the provider picker (an app with its own auth, or upstream providers). Sign in again.

The auth_url opens an error page

It's missing the OAuth parameters: the link only carries app, tool and ticket. Add the client's response_type, client_id, redirect_uri, code_challenge and state, and resolve the path against the server's URL.

The ticket was already used, is more than five minutes old, or was signed with another JWT_SECRET (a restart without a fixed one, or another instance). Call the tool again for a new link.

The new token has no email

Tokens from an incremental authorization keep sub and the grant, not email or name. Identify users by sub.

Your session is no longer active. Please sign in again.

A connect link was used for a user without a credential vault: their sign-in's authenticate returned no credentials. See Connecting a credential later.

The token in the link was changed, is more than ten minutes old, or was signed with another JWT_SECRET. Call the tool again for a new link.

Credential connect is not configured on this server.

The server has no authenticate. The connect page needs it to check and store what the user types.

TOOL_NOT_CONSENTED

The user didn't tick this tool on the consent screen. They need to sign in again, and with rememberConsent on they won't see the screen: see Tools: consent.