# Progressive auth

> Letting a signed-in user start with part of a local-mode server and grant more when a tool needs it: app grants with incremental authorization tickets, credentials connected mid-session, and tool consent.

Source: https://frontmcp.dev/reference/auth/progressive

A user doesn't have to grant everything when they first sign in. In [local mode](https://frontmcp.dev/reference/auth/local), 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](https://frontmcp.dev/reference/auth/login-ui#the-consent-screen) is the third way to narrow a token, but it can only be widened by signing in again.

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

---

## Reference

### Three ways a token is narrowed

| | Apps | Credentials | Tools |
| --- | --- | --- | --- |
| Turned on by | `incrementalAuth` | `authProviders` on a tool, or `this.credentials` | `consent: { enabled: true }` |
| Recorded in | the token's `authorized_apps` claim | the user's credential vault, on the server | the token's `consent` claim |
| A refused call gets | an `isError` result, `AUTHORIZATION_REQUIRED` | a JSON-RPC error, `-32001` | an `isError` result, `TOOL_NOT_CONSENTED` |
| Its link | `/oauth/authorize?app=…&tool=…&ticket=…`, relative | `http://…/oauth/connect?token=…`, absolute | none |
| The user | authorizes the app, without signing in again | types the credential into a one-field page | signs in again |
| The client | exchanges a new code for a new token | retries with the same token | uses the new token |

### `incrementalAuth`

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk, Billing],
  auth: { mode: "local", incrementalAuth: {} },
})
export default class Server {}
```

| Option | Default | What it does |
| --- | --- | --- |
| `enabled` | `true` once the block is there | Tokens 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](https://frontmcp.dev/reference/auth/upstream-providers#apps-with-their-own-auth). |
| `allowSkip`, `showAllAppsAtOnce` | `true` | Accepted 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](https://frontmcp.dev/reference/sdk/app). 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:

```json
{ "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:

```json
{
  "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`](#credentials-authproviders-and-thiscredentials) 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`](https://frontmcp.dev/reference/auth/local#scopes), 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`](https://frontmcp.dev/reference/auth/local#storage): 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`](https://frontmcp.dev/reference/auth/local#checking-users-yourself) can fill it at sign-in by returning `credentials`, and the user can add to it later through a link.

```ts 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:

```json
{
  "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](#asking-for-a-credential-when-a-tool-needs-it). The same check thrown by your own code as a [`ToolCredentialsRequiredError`](https://frontmcp.dev/reference/sdk/error-classes) gives the same JSON-RPC error; through `this.fail()`, it's an `isError` result with `TOOL_CREDENTIALS_REQUIRED` instead.

| `this.credentials` | Returns |
| --- | --- |
| `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`:

```ts
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.

> **Pitfall: A credential can only be added to a vault that exists**
The vault is made when a sign-in's `authenticate` returns at least one credential. If the user's sign-in stored none, every connect link answers `409 Your session is no longer active. Please sign in again.`, though the user is signed in. Return a credential at sign-in, even one your tools use for something else, if tools may ask for more later. Without `authenticate` at all, the page shows but submitting it is `500 Credential connect is not configured on this server.`

- 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`](https://frontmcp.dev/reference/auth/local#options) to your public URL.
- Until it expires, the same link can be used again, to replace the credential.

### Tools: consent

[Consent](https://frontmcp.dev/reference/auth/login-ui#the-consent-screen) 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.

> **Pitfall: With rememberConsent, a user can't take back a no**
`rememberConsent` is on by default. A user who signs in again with the same client doesn't see the consent screen again for the tools it offered before, and gets the same tools as before. So a user who left a tool unticked can't tick it later. Set `consent: { enabled: true, rememberConsent: false }` if users should be able to change their choice, at the cost of seeing the screen on every 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](https://frontmcp.dev/reference/auth/local#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](https://frontmcp.dev/reference/auth/local#usage). `oauth-client.ts` is the client from [Signing in, step by step](https://frontmcp.dev/reference/auth/local#signing-in-step-by-step): it plays the MCP client and the user's browser, which keeps the [sign-in cookie](https://frontmcp.dev/reference/auth/local#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.

```ts apps.ts active
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 {}
```

```ts server.ts
import { Billing, HelpDesk } from "./apps";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk, Billing],
  auth: { mode: "local" as const, incrementalAuth: {} },
};
```

```ts incremental.test.ts
import { test, expect } from "@frontmcp/testing";
import { OAuthClient, SERVER, claimsOf } from "./oauth-client";
import { config } from "./server";

/** The ticket link's own parameters (app, tool, ticket), to add to an authorize request. */
const paramsOf = (authUrl: string) => Object.fromEntries(new URL(authUrl, SERVER).searchParams);

async function signInToHelpDesk() {
  const client = await OAuthClient.for(config);
  const tokens = await client.signIn({ email: "nour@example.com" }, { apps: "help-desk" });
  return { client, tokens };
}

test("the token only reaches the apps granted at sign-in", async () => {
  const { client, tokens } = await signInToHelpDesk();
  expect(claimsOf(tokens.access_token).authorized_apps).toEqual(["help-desk"]);
  expect((await client.callTool(tokens.access_token, "list_tickets")).body.result.structuredContent).toEqual({ tickets: ["T-1", "T-2"] });

  const refused = (await client.callTool(tokens.access_token, "refund_invoice", { id: "INV-7" })).body.result;
  expect(refused).toMatchObject({
    isError: true,
    _meta: { code: "AUTHORIZATION_REQUIRED", authorization_required: true, app: "billing", tool: "billing:refund_invoice", session_mode: "stateful", supports_incremental: true },
  });
  expect(refused._meta.auth_url).toMatch(/^\/oauth\/authorize\?app=billing&tool=billing%3Arefund_invoice&ticket=[\w-]+$/);
  expect(refused.content[0].text).toBe(`Authorization required for billing. Please authorize to use billing:refund_invoice.\n\nTo authorize, click: ${refused._meta.auth_url}`);
  expect(refused._meta).not.toHaveProperty("required_scopes");
});

test("required_scopes names the scopes the tool's authProviders ask for", async () => {
  const { client, tokens } = await signInToHelpDesk();
  const refused = (await client.callTool(tokens.access_token, "export_invoices")).body.result;
  expect(refused._meta).toMatchObject({ code: "AUTHORIZATION_REQUIRED", tool: "billing:export_invoices", required_scopes: ["invoices:export"] });
});

test("without apps=, a sign-in grants every app; unknown ids are dropped", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "nour@example.com" });
  expect(claimsOf(access_token).authorized_apps).toEqual(["help-desk", "billing"]);
  const partial = await client.signIn({ email: "nour@example.com" }, { apps: "billing,payroll" });
  expect(claimsOf(partial.access_token).authorized_apps).toEqual(["billing"]);
});

test("with enabled: false, there are no grants", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const, incrementalAuth: { enabled: false } } });
  const { access_token } = await client.signIn({ email: "nour@example.com" }, { apps: "help-desk" });
  expect(claimsOf(access_token)).not.toHaveProperty("authorized_apps");
  expect((await client.callTool(access_token, "refund_invoice", { id: "INV-7" })).body.result.structuredContent).toMatchObject({ refunded: true });
});

test("the link needs the client's own authorize parameters", async () => {
  const { client, tokens } = await signInToHelpDesk();
  const { auth_url } = (await client.callTool(tokens.access_token, "refund_invoice", { id: "INV-7" })).body.result._meta;
  const asIs = await client.send(auth_url);
  expect(asIs.status).toBe(400);
  expect(await asIs.text()).toContain('response_type must be &quot;code&quot; (OAuth 2.1)');
});

test("with the ticket, the user authorizes Billing without signing in again", async () => {
  const { client, tokens } = await signInToHelpDesk();
  const { auth_url } = (await client.callTool(tokens.access_token, "refund_invoice", { id: "INV-7" })).body.result._meta;

  const { status, page, pendingAuthId } = await client.authorize({ ...paramsOf(auth_url), scope: "openid tickets:read" });
  expect(status).toBe(200);
  expect(page).toContain("<title>Authorize Billing - FrontMCP</title>");
  expect(page).toContain("This is an incremental authorization. Your existing session will be expanded to include Billing.");
  expect(page).not.toContain('name="email"');

  const { location, code } = await client.submit({ pending_auth_id: pendingAuthId! });
  expect(location).toMatch(/&incremental=true&app_id=billing$/);

  const more = (await client.exchange(code!)).body;
  const before = claimsOf(tokens.access_token);
  const after = claimsOf(more.access_token);
  expect(after.authorized_apps).toEqual(["help-desk", "billing"]);
  expect(after.sub).toBe(before.sub);
  expect(after).not.toHaveProperty("email"); // not carried over
  expect(more.scope).toBe("openid"); // what this request asked for, within allowedScopes

  expect((await client.callTool(more.access_token, "refund_invoice", { id: "INV-7" })).body.result.structuredContent).toEqual({ id: "INV-7", refunded: true, by: before.sub });
  // the old token keeps its old grant
  expect((await client.callTool(tokens.access_token, "refund_invoice", { id: "INV-7" })).body.result._meta.code).toBe("AUTHORIZATION_REQUIRED");
});

test("a ticket works once", async () => {
  const { client, tokens } = await signInToHelpDesk();
  const { auth_url } = (await client.callTool(tokens.access_token, "refund_invoice", { id: "INV-7" })).body.result._meta;
  await client.authorize(paramsOf(auth_url));
  const second = await client.authorize(paramsOf(auth_url));
  expect(second.page).toContain("<title>Sign In - FrontMCP</title>"); // an ordinary sign-in
});

test("asking for incremental auth without a ticket is an ordinary sign-in", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { page } = await client.authorize({ mode: "incremental", app: "billing", apps: "help-desk" });
  expect(page).toContain('name="email"');
});
```

```ts oauth-client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

export const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://localhost:3000/callback";

/** An MCP client's side of local-mode OAuth, and the user's browser: one request per step. */
export class OAuthClient {
  clientId = "";
  /** PKCE: the client keeps this secret and only sends its SHA-256 to /oauth/authorize. */
  readonly verifier = "a-random-string-of-43-to-128-characters-0123456789";
  /** The browser's cookies. /oauth/authorize sets one that ties the sign-in to this browser. */
  readonly cookies = new Map<string, string>();

  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new OAuthClient((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  /** Sends a request as the browser would: with its cookies, keeping the ones the server sets. */
  async send(path: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(SERVER + path, { redirect: "manual", ...init, headers }));
    for (const line of response.headers.getSetCookie()) {
      const [name, value] = line.split(";")[0].split("=");
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** 1. Register (dynamic client registration). */
  async register(metadata: Record<string, unknown> = {}) {
    const response = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], grant_types: ["authorization_code", "refresh_token"], ...metadata }),
    });
    const body = await response.json();
    if (response.status === 201) this.clientId = body.client_id;
    return { status: response.status, body };
  }

  /** 2. Open the sign-in page, and find the pending_auth_id its form carries. */
  async authorize(params: Record<string, string> = {}) {
    const query = new URLSearchParams({
      response_type: "code",
      client_id: this.clientId,
      redirect_uri: REDIRECT_URI,
      state: "xyz",
      code_challenge: await sha256(this.verifier),
      code_challenge_method: "S256",
      ...params,
    });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] };
  }

  /** 3. Submit the sign-in form, as the user's browser does. */
  async submit(fields: Record<string, string>) {
    const response = await this.send("/oauth/callback", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams(fields),
    });
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  /** 4. Trade the code and the PKCE verifier for tokens. */
  exchange(code: string) {
    return this.token({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  refresh(refreshToken: string) {
    return this.token({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.clientId });
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return { status: response.status, body: await response.json() };
  }

  /** Steps 1 to 4, for tests that only need a token. */
  async signIn(fields: Record<string, string>, params: Record<string, string> = {}) {
    if (!this.clientId) await this.register();
    const { pendingAuthId } = await this.authorize(params);
    const { code } = await this.submit({ pending_auth_id: pendingAuthId!, ...fields });
    return (await this.exchange(code!)).body;
  }

  /** An MCP tools/call, with or without an access token. */
  async callTool(accessToken: string | undefined, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return { status: response.status, headers: response.headers, body: await response.json() };
  }
}

/** The claims of a JWT, without checking it. */
export function claimsOf(jwt: string) {
  return JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
}

async function sha256(text: string) {
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
  return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

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.

```ts crm.tools.ts active
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 {}
```

```ts server.ts
import { HelpDesk } from "./crm.tools";

export type AuthenticateInput = { fields: Record<string, string>; resume?: { sub: string; key: string; context?: string } };

export async function authenticate({ fields, resume }: AuthenticateInput) {
  if (resume) {
    const apiKey = fields[resume.key] ?? "";
    if (!apiKey.startsWith("crm_")) return { ok: false as const, message: "That isn't a CRM API key." };
    return { ok: true as const, credentials: [{ key: resume.key, secret: apiKey, metadata: { connectedFrom: resume.context ?? "tool" } }] };
  }
  // The sign-in: the agent's desk token goes in the vault, which also creates it.
  return { ok: true as const, sub: `agent:${fields.email}`, credentials: [{ key: "desk", secret: `desk-token-for-${fields.email}` }] };
}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "local" as const, authenticate },
};
```

```ts credentials.test.ts
import { test, expect } from "@frontmcp/testing";
import { OAuthClient } from "./oauth-client";
import { authenticate, config, type AuthenticateInput } from "./server";

/** Opens a connect link and submits the one field on its page. */
async function connect(client: OAuthClient, link: string, value: string) {
  const url = new URL(link);
  const page = await (await client.send(url.pathname + url.search)).text();
  const token = url.searchParams.get("token")!;
  const response = await client.send("/oauth/connect", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ token, crm: value }),
  });
  return { page, status: response.status, result: await response.text() };
}

test("a tool that needs the credential is refused, with a link", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "nour@example.com" });
  const { body } = await client.callTool(access_token, "sync_crm");
  expect(body.error).toMatchObject({
    code: -32001,
    data: { tool: "help-desk:sync_crm", providers: ["crm"], authUrl: expect.stringMatching(/^http:\/\/localhost:3000\/oauth\/connect\?token=/) },
  });
  expect(body.error.message).toMatch(/^Tool "help-desk:sync_crm" requires credentials for crm which are not connected for this session\. Connect via: /);
});

test("an optional credential doesn't stop the tool", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "nour@example.com" });
  expect((await client.callTool(access_token, "sync_calendar")).body.result.structuredContent).toEqual({ calendar: "not connected" });
});

test("a link that was tampered with is refused", async () => {
  const client = await OAuthClient.for(config);
  const response = await client.send("/oauth/connect?token=not-a-real-token");
  expect(response.status).toBe(400);
  expect(await response.text()).toContain("This connect link is invalid or has expired. Please try again.");
});

test("the user connects it through the link, and the same token works", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "nour@example.com" });
  const link = (await client.callTool(access_token, "sync_crm")).body.error.data.authUrl;

  const wrong = await connect(client, link, "not-a-key");
  expect(wrong.page).toContain('<form method="POST" action="/oauth/connect">');
  expect(wrong.page).toContain('name="crm"');
  expect(wrong.result).toContain("That isn&#39;t a CRM API key.");

  const right = await connect(client, link, "crm_live_8a2f");
  expect(right.status).toBe(200);
  expect(right.result).toContain("is now connected");

  expect((await client.callTool(access_token, "sync_crm")).body.result.structuredContent).toEqual({ synced: true, keyEndsWith: "8a2f", connectedFrom: "tool" });
});

test("a tool can ask for it in its own words, with a context", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "sam@example.com" });
  const status = (await client.callTool(access_token, "crm_status")).body.result.structuredContent;
  expect(status).toEqual({ connected: false, connectUrl: expect.stringContaining("/oauth/connect?token="), message: 'Credential "crm" is not connected. Open the resume URL to connect it.' });

  await connect(client, status.connectUrl, "crm_live_77c1");
  expect((await client.callTool(access_token, "crm_status")).body.result.structuredContent).toEqual({ connected: true, keys: ["desk", "crm"] });
  expect((await client.callTool(access_token, "sync_crm")).body.result.structuredContent.connectedFrom).toBe("crm_status");
});

test("a new sign-in starts a new vault, with only what it stores", async () => {
  const client = await OAuthClient.for(config);
  const first = await client.signIn({ email: "nour@example.com" });
  await connect(client, (await client.callTool(first.access_token, "sync_crm")).body.error.data.authUrl, "crm_live_8a2f");
  const second = await client.signIn({ email: "nour@example.com" });
  expect((await client.callTool(second.access_token, "sync_crm")).body.error.code).toBe(-32001);
  expect((await client.callTool(first.access_token, "sync_crm")).body.error.code).toBe(-32001);
});

test("without authenticate, the link can't be used", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const } });
  const { access_token } = await client.signIn({ email: "kai@example.com" });
  const link = (await client.callTool(access_token, "sync_crm")).body.error.data.authUrl;
  const { page, status, result } = await connect(client, link, "crm_live_0001");
  expect(page).toContain('name="crm"'); // the page shows
  expect(status).toBe(500);
  expect(result).toContain("Credential connect is not configured on this server.");
});

test("a sign-in that stored no credential can't connect one", async () => {
  // this sign-in stores nothing in the vault
  const signInOnly = async (input: AuthenticateInput) => (input.resume ? authenticate(input) : { ok: true as const, sub: "agent:kai" });
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const, authenticate: signInOnly } });
  const { access_token } = await client.signIn({ email: "kai@example.com" });
  const link = (await client.callTool(access_token, "sync_crm")).body.error.data.authUrl;
  const { status, result } = await connect(client, link, "crm_live_0001");
  expect(status).toBe(409);
  expect(result).toContain("Your session is no longer active. Please sign in again.");
});
```

```ts oauth-client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

export const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://localhost:3000/callback";

/** An MCP client's side of local-mode OAuth, and the user's browser: one request per step. */
export class OAuthClient {
  clientId = "";
  /** PKCE: the client keeps this secret and only sends its SHA-256 to /oauth/authorize. */
  readonly verifier = "a-random-string-of-43-to-128-characters-0123456789";
  /** The browser's cookies. /oauth/authorize sets one that ties the sign-in to this browser. */
  readonly cookies = new Map<string, string>();

  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new OAuthClient((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  /** Sends a request as the browser would: with its cookies, keeping the ones the server sets. */
  async send(path: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(SERVER + path, { redirect: "manual", ...init, headers }));
    for (const line of response.headers.getSetCookie()) {
      const [name, value] = line.split(";")[0].split("=");
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** 1. Register (dynamic client registration). */
  async register(metadata: Record<string, unknown> = {}) {
    const response = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], grant_types: ["authorization_code", "refresh_token"], ...metadata }),
    });
    const body = await response.json();
    if (response.status === 201) this.clientId = body.client_id;
    return { status: response.status, body };
  }

  /** 2. Open the sign-in page, and find the pending_auth_id its form carries. */
  async authorize(params: Record<string, string> = {}) {
    const query = new URLSearchParams({
      response_type: "code",
      client_id: this.clientId,
      redirect_uri: REDIRECT_URI,
      state: "xyz",
      code_challenge: await sha256(this.verifier),
      code_challenge_method: "S256",
      ...params,
    });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] };
  }

  /** 3. Submit the sign-in form, as the user's browser does. */
  async submit(fields: Record<string, string>) {
    const response = await this.send("/oauth/callback", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams(fields),
    });
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  /** 4. Trade the code and the PKCE verifier for tokens. */
  exchange(code: string) {
    return this.token({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  refresh(refreshToken: string) {
    return this.token({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.clientId });
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return { status: response.status, body: await response.json() };
  }

  /** Steps 1 to 4, for tests that only need a token. */
  async signIn(fields: Record<string, string>, params: Record<string, string> = {}) {
    if (!this.clientId) await this.register();
    const { pendingAuthId } = await this.authorize(params);
    const { code } = await this.submit({ pending_auth_id: pendingAuthId!, ...fields });
    return (await this.exchange(code!)).body;
  }

  /** An MCP tools/call, with or without an access token. */
  async callTool(accessToken: string | undefined, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return { status: response.status, headers: response.headers, body: await response.json() };
  }
}

/** The claims of a JWT, without checking it. */
export function claimsOf(jwt: string) {
  return JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
}

async function sha256(text: string) {
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
  return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

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`](https://frontmcp.dev/reference/auth/local#options) 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 incremental link shows the sign-in page

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](#connecting-a-credential-later).

### `This connect link is invalid or has expired. Please try again.`

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](#tools-consent).
