Testing authentication

A server with auth refuses the mcp fixture's anonymous client, so its tests call as a user: make a token, and sign mcp in with it, or connect a client of your own. The auth fixture makes tokens that a public, local or remote server accepts, once the test gives the server a JWT_SECRET, which the fixture then signs with too. A transparent server only accepts tokens its identity provider signed, so its tests start a stand-in provider, MockOAuthServer, and sign tokens with its keys. To test the sign-in itself, a test walks it the way a browser does, with a client it registers, or a CIMD client whose metadata document MockCimdServer serves. The Playground's test runner has no auth fixture: every example on this page was run with frontmcp test and Jest 30 on FrontMCP 1.9.1, and what the server does with each kind of token was checked again on 1.9.2.

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "…" } });

test("name", async ({ server, auth }) => {
  const client = await server.createClient({ token: await auth.createToken({ sub, scopes?, email?, name?, claims?, expiresIn? }) });
});

new MockOAuthServer(new TestTokenFactory({ issuer, audience }), { port, autoApprove?, testUser?, clientId? });

new MockCimdServer({ port?, debug? }).registerClient({ name, redirectUris?, logoUri?, … }); // returns the client's client_id URL

Reference

What each auth mode accepts

auth.modeThe mcp fixtureA token that works in tests
public (the default)Connects, as an anonymous caller with an anon: idauth.createToken(), with JWT_SECRET in test.use({ env })
staticDoesn't connectOne of the server's tokens
local, remoteDoesn't connectauth.createToken(), with JWT_SECRET in test.use({ env }), or the token from a real sign-in
transparentDoesn't connectA token from a TestTokenFactory whose keys a MockOAuthServer serves as the provider

Connect with the token through server.createClient({ token }), or server.createClientBuilder().withToken(token), or sign the mcp fixture in with await mcp.authenticate(token). For what each mode does with a request, see Auth modes.

The auth fixture

auth.createToken(options)

Resolves to a signed JWT.

OptionTypeDescription
substringRequired. The user's id: this.auth.user.sub in your tools.
scopesstring[]The scope claim, space-separated.
email, namestringThe claims of the same name.
claimsRecord<string, unknown>More claims. They're added last, so they replace any claim, sub, iss and aud included: claims: { aud: "https://other-desk.example.com" } makes a token for another server.
expiresInnumberHow long the token lasts, in seconds. Default 3600. exp is rounded up to a whole second, so the token lasts at least that long, and with expiresIn: 1 it can't expire before its first use. (Before 1.8.7 iat was the current second rounded down and exp was iat + expiresIn, so a token with expiresIn: 1 could expire before the first call.)

How it signs depends on test.use({ env }):

envSigned withiss and aud
With JWT_SECRETHS256 and that secret, as a public, local or remote server signs its own tokensThe server's MCP address: baseUrl and entryPath, like http://localhost:51000, or http://localhost:51000/mcp with entryPath: "/mcp"
WithoutRS256, with a key made for the filehttps://test.frontmcp.local and frontmcp-test. The server under test doesn't know this key, so it refuses these tokens. getJwks() returns the key.

Other members

MemberDescription
createExpiredToken({ sub })A token issued two hours ago that expired an hour ago, with only iss, sub, aud, iat and exp.
createInvalidToken({ sub })A token with the right header and claims, and a signature that isn't one. It isn't a promise.
usersThree users to pass to createToken(): see below.
getIssuer(), getAudience()The iss and aud of its tokens.
getJwks()Resolves to { keys: [publicKey] }, the RS256 key its tokens are signed with. With JWT_SECRET it throws getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret).
Usersubscopesemail, name
auth.users.adminadmin-001admin:*, read, write, deleteadmin@test.local, Test Admin
auth.users.useruser-001read, writeuser@test.local, Test User
auth.users.readOnlyreadonly-001readreadonly@test.local, Read Only User

What happens to a token

  • Accepted: tools see its claims. this.auth.user has sub, email and name, this.auth.scopes has its scopes, and this.auth.claims has every claim. (Under frontmcp test, which has a session, this.auth.scopes was [] before 1.8.7, and tools read this.context.authInfo.scopes.)
  • Refused: server.createClient() rejects with Failed to initialize MCP connection to http://localhost:51000/ after 1 attempt: HTTP 401: Unauthorized - {"error":"Unauthorized"}. A 401 or 403 is tried once. The reason is only in the 401's WWW-Authenticate header: see Seeing why a token is refused.
  • Expired during the test: the client's next requests fail with { code: -32000, message: "HTTP 401: Unauthorized" }, in result.error, and the list() methods throw Failed to list tools: HTTP 401: Unauthorized.

Caveats

  • mcp.authenticate(token) signs the mcp fixture in. It opens a new session as the token's user, and the client keeps the token, so mcp.reconnect() uses it too. When the server refuses the token, it rejects with the error createClient() would give, and mcp keeps its old session and identity. On a server that needs a token, it's how a test uses mcp at all: await mcp.authenticate(await auth.createToken({ sub: "agent-1" })).
  • publicMode: true only keeps mcp from asking for an anonymous token. A client given a token, with server.createClient({ token }) or authenticate(), sends it.
  • A server with local.issuer, or FRONTMCP_PUBLIC_URL, issues and expects another iss. Pass it in claims: auth.createToken({ sub, claims: { iss: "https://desk.example.com" } }).
  • test.use({ auth }) doesn't set the server's mode. The server's mode is whatever its @FrontMcp says; auth: { mode: "public" } only keeps mcp anonymous, like publicMode.
  • A public server ignores a token that isn't a JWT, and lets the caller in as anonymous. A JWT it can't verify gets 401.
  • The mcp fixture tries to connect before every test, and on a server that needs a token it prints its could not connect anonymously warning each time. The tests that use only server and auth are unaffected.

Before 1.8.7, mcp.authenticate() changed only the header, so the next request failed with HTTP 404: Not Found and invalid session id; and publicMode: true dropped the token of every client, so the server saw an anonymous caller. Both work as above now.

TestTokenFactory

The factory behind auth, for tokens you sign yourself, like a transparent server's provider does.

import { TestTokenFactory } from "@frontmcp/testing";

const tokens = new TestTokenFactory({ issuer: "http://localhost:50900", audience: "https://desk.example.com" });
OptionDefaultDescription
issuer"https://test.frontmcp.local"The iss of its tokens.
audience"frontmcp-test"Their aud.
hmacSecretSign with HS256 and this secret, instead of an RSA key the factory makes on first use.
MethodReturns
createTestToken({ sub, iss?, aud?, scopes?, exp?, claims? })A token. exp is its lifetime in seconds, default 3600, rounded up like auth's expiresIn; aud can be a list.
createAdminToken(sub?), createUserToken(sub?, scopes?)The admin-001 and user-001 users, with a role claim of admin or user.
createAnonymousToken(expiresIn?)sub anon:<time>, scope anonymous, role anonymous. expiresIn is its lifetime in seconds, default 3600.
createExpiredToken({ sub }), createTokenWithInvalidSignature({ sub })As auth's createExpiredToken() and createInvalidToken().
getPublicJwks(), getIssuer(), getAudience()As auth's getJwks(), getIssuer() and getAudience(). The key's kid is test-key-<time>.

MockOAuthServer

An OAuth 2.1 and OpenID Connect provider for tests, on a port of its own. It publishes a TestTokenFactory's public key, so a server whose provider it is accepts that factory's tokens, and it runs the authorization-code flow with PKCE, so a remote server can sign users in through it.

import { MockOAuthServer, TestTokenFactory } from "@frontmcp/testing";

const idp = new MockOAuthServer(new TestTokenFactory({ issuer: "http://localhost:50900", audience: "help-desk-mcp" }), {
  port: 50900,
  autoApprove: true,
  testUser: { sub: "sso|nour", email: "nour@acme.dev", name: "Nour" },
  clientId: "help-desk-mcp",
});
OptionDefaultDescription
porta random free portSet one: the TestTokenFactory that signs the tokens needs the provider's URL as its issuer when you create it, and so does the server under test.
issuerhttp://localhost:<port>The issuer in its metadata, and the base of the endpoint URLs listed there. The tokens' iss is the factory's issuer, so give the factory the same URL.
autoApprovefalse/oauth/authorize redirects straight back with a code for testUser. Without it, it answers with a login page whose form asks for a sub, an email and a name, and has allow and deny buttons.
testUser{ sub, email?, name?, picture?, claims? }: who signs in with autoApprove, and who /userinfo describes. Required with autoApprove: without it, /oauth/authorize answers 500.
clientIdWhen set, other client_ids are redirected back with error=unauthorized_client.
clientSecretWhen set, /oauth/token requires it, in the form or with HTTP Basic auth, or answers 401 invalid_client.
validRedirectUrisanyThe redirect URIs /oauth/authorize accepts, exactly or with *. Others get 400 Invalid redirect_uri.
accessTokenTtlSeconds, refreshTokenTtlSeconds3600, 30 daysHow long the access and ID tokens last, which is also the expires_in of token responses, and how long refresh tokens work. (Before 1.8.7 the tokens themselves always lasted an hour, whatever accessTokenTtlSeconds said.)
debugfalseLogs each request.
MemberDescription
start()Starts listening. Resolves to { baseUrl, port, issuer, jwksUrl }.
stop()Stops it, closing open connections.
infoWhat start() resolved to. Throws when it isn't running.
setAutoApprove(enabled), setTestUser(user), addValidRedirectUri(uri)Change those options while it runs.
clearStoredTokens()Forgets every code and refresh token it issued.
getTokenFactory()The factory it was given.
EndpointWhat it does
GET /.well-known/jwks.jsonThe factory's public key.
GET /.well-known/openid-configuration, GET /.well-known/oauth-authorization-serverMetadata: the issuer, the endpoints, jwks_uri, and the grants it supports.
GET /oauth/authorizeWith autoApprove, a 302 back to redirect_uri with code and state; otherwise the login page, which posts to POST /oauth/authorize/submit.
POST /oauth/tokenForm-encoded. authorization_code (checks the client, the redirect URI and the PKCE verifier; a code works once), refresh_token (each refresh returns a new refresh token, and the old one stops working) and anonymous. It answers access_token, token_type, expires_in, refresh_token, id_token and scope.
GET /userinfotestUser, for any bearer token. 401 without one.

The access and ID tokens it issues carry the user's sub, email, name and claims, from the factory, with the factory's iss and aud. The access token also has a scope claim with the scopes the client asked for, and the token response's scope lists them; the ID token has no scope. (Before 1.8.7 neither token had one.)

MockOAuthServer and TestTokenFactory work anywhere: a plain Node script can import @frontmcp/testing and start a mock provider, and the code exchange needs no Jest flag. test and expect only work inside Jest, and throw … can only be used inside a Jest test file anywhere else. (Before 1.8.7, importing the package outside Jest threw Do not import @jest/globals outside of the Jest test environment, and the code exchange failed under Jest without NODE_OPTIONS=--experimental-vm-modules.)

MockCimdServer

A stand-in for the web server of a CIMD client, on a port of its own. It serves the metadata document of each client a test registers, at http://localhost:<port>/clients/<name>/metadata.json, and that URL is the client's client_id. FrontMCP fetches documents only from https URLs on public addresses, so the server under test accepts these only with cimd.security.allowInsecureForTesting on: see Testing a CIMD client.

import { MockCimdServer } from "@frontmcp/testing";

const clients = new MockCimdServer();
await clients.start();
const clientId = clients.registerClient({ name: "Notes Desktop" });
// http://localhost:<port>/clients/notes-desktop/metadata.json
OptionDefaultDescription
porta random free portThe port it listens on. The server under test doesn't need to know it: the client_id carries it.
debugfalseLogs each request, and each client registered or removed.
MemberDescription
start()Starts listening. Resolves to { baseUrl, port }, where baseUrl is http://localhost:<port>. Throws Mock CIMD server is already running when it is.
stop()Stops it.
infoWhat start() resolved to. Throws Mock CIMD server is not running when it isn't.
registerClient(options)Serves a client's document, and returns its URL: the client_id. Before start(), throws Mock CIMD server is not running. Call start() first.
getClientId(name)The URL of the client registered with that name. Throws Client "<name>" not found. Register it first. for a name it doesn't serve, and for a client registered with a path of its own.
registerInvalidDocument(path, document)Serves document, any JSON, at path, without checking it, for a test of a document FrontMCP should refuse.
registerFetchError(path, status, body?)Answers requests for path with that status, and body as JSON. It wins over a document at the same path, and works before start().
removeClient(name)Stops serving the client registered with that name. A client registered with a path of its own stays.
clear()Stops serving every document and error.

registerClient() builds the document from its options:

OptionDefaultBecomes
nameRequiredclient_name, and the path: lower case, with each run of other characters turned into -, so Notes Desktop is served at /clients/notes-desktop/metadata.json.
pathFrom nameThe part of the path between /clients/ and /metadata.json.
redirectUris["http://localhost:3000/callback"]redirect_uris. The default is the one the signIn() helper uses.
tokenEndpointAuthMethod"none"token_endpoint_auth_method.
grantTypes, responseTypes["authorization_code"], ["code"]grant_types, response_types.
clientUri, logoUri, scope, contactsclient_uri, logo_uri, scope, contacts, when set.

The document's client_id is its own URL, as CIMD requires. It's served with Cache-Control: max-age=3600, and FrontMCP keeps a document as long as that says, so a server under test that fetched one keeps using it for an hour: it still signed a client in after removeClient(). Other paths answer 404 {"error":"not_found","message":"No client at <path>"}, and requests other than GET and OPTIONS answer 405.

Signing in for real

auth.createToken() skips the sign-in: your authenticate doesn't run, and neither does a remote server's trip to the provider. To test those, a test signs in as a browser does. @frontmcp/testing has no helper for it, and a browser's cookies matter: /oauth/authorize sets a sign-in cookie that the callback refuses to go on without. This helper keeps cookies, follows redirects, fills in local mode's sign-in form, and trades the code for tokens:

e2e/sign-in.ts
import { createHash, randomBytes } from "node:crypto";

const REDIRECT_URI = "http://localhost:3000/callback";

/**
 * Signs in to a local or remote server as a user's browser does, following
 * every redirect and keeping cookies, and returns the token response.
 * `fields` fill in local mode's sign-in form. Without `clientId`, it registers a client;
 * a CIMD client passes its metadata URL instead.
 */
export async function signIn(baseUrl: string, fields: Record<string, string> = {}, scope?: string, clientId?: string) {
  const cookies = new Map<string, string>();
  const send = async (url: string, init: RequestInit = {}) => {
    const headers = new Headers(init.headers);
    if (cookies.size) headers.set("cookie", [...cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await fetch(url, { ...init, headers, redirect: "manual" });
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const name = pair.slice(0, pair.indexOf("="));
      const value = pair.slice(pair.indexOf("=") + 1);
      if (value) cookies.set(name, value);
      else cookies.delete(name);
    }
    return response;
  };

  // Register a client, unless a CIMD client's metadata URL was given, and make a PKCE verifier and its challenge.
  let client_id = clientId;
  if (!client_id) {
    const registration = await send(`${baseUrl}/oauth/register`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "tests", redirect_uris: [REDIRECT_URI] }),
    });
    client_id = (await registration.json()).client_id as string;
  }
  const verifier = randomBytes(32).toString("base64url");
  const challenge = createHash("sha256").update(verifier).digest("base64url");

  const query = new URLSearchParams({
    response_type: "code",
    client_id,
    redirect_uri: REDIRECT_URI,
    code_challenge: challenge,
    code_challenge_method: "S256",
    state: "test",
    ...(scope ? { scope } : {}),
  });
  let response = await send(`${baseUrl}/oauth/authorize?${query}`);

  for (let step = 0; step < 10; step++) {
    const location = response.headers.get("location");
    if (location?.startsWith(REDIRECT_URI)) {
      // Back at the client, with a code: trade it for tokens.
      const code = new URL(location).searchParams.get("code");
      if (!code) throw new Error(`Sign-in failed: ${location}`);
      const tokens = await send(`${baseUrl}/oauth/token`, {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id, code_verifier: verifier }),
      });
      return tokens.json();
    }
    if (location) {
      response = await send(new URL(location, response.url).toString());
      continue;
    }
    // Local mode's sign-in page: submit its form.
    const page = await response.text();
    const pendingAuthId = page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1];
    if (response.status !== 200 || !pendingAuthId) {
      const text = page.replace(/<style[^]*?<\/style>/, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
      throw new Error(`Sign-in stopped with ${response.status}: ${text.slice(0, 300)}`);
    }
    response = await send(`${baseUrl}/oauth/callback`, {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ pending_auth_id: pendingAuthId, ...fields }),
    });
  }
  throw new Error("Sign-in didn't finish in 10 steps");
}

It keeps cookies by name only, which is enough for one server and its provider on localhost. A CIMD client passes its metadata URL as clientId, and isn't registered. Local auth describes each step.


Usage

The examples test this app:

src/help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Who is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    const { user, isAnonymous } = this.auth;
    return { sub: user.sub, email: user.email ?? null, anonymous: isAnonymous };
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket. Needs tickets:write.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (!this.auth.hasScope("tickets:write")) this.fail(new PublicMcpError("Closing tickets needs tickets:write."));
    return { closed: id, by: this.auth.user.sub };
  }
}

@App({ name: "help-desk", tools: [WhoAmI, CloseTicket] })
export class HelpDesk {}

Calling as a signed-in user

Give the server a JWT_SECRET in test.use(), and auth.createToken() makes tokens it accepts. This server is in local mode; public and remote servers work the same way:

src/main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: { mode: "local", allowedScopes: ["tickets:read", "tickets:write"] },
})
export default class Server {}
e2e/signed-in.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });

test("tools see the user the token names", async ({ server, auth }) => {
  const token = await auth.createToken({ sub: "agent-1", email: "nour@acme.dev", scopes: ["tickets:write"] });
  const nour = await server.createClient({ token });
  try {
    expect((await nour.tools.call("whoami")).json()).toEqual({ sub: "agent-1", email: "nour@acme.dev", anonymous: false });
    expect((await nour.tools.call("close_ticket", { id: "T-1" })).json()).toEqual({ closed: "T-1", by: "agent-1" });
  } finally {
    await nour.disconnect();
  }
});

test("a token without tickets:write can't close tickets", async ({ server, auth }) => {
  const lin = await server.createClient({ token: await auth.createToken({ sub: "agent-2", scopes: ["tickets:read"] }) });
  try {
    const result = await lin.tools.call("close_ticket", { id: "T-1" });
    expect(result).toBeError();
    expect(result.text()).toBe("Closing tickets needs tickets:write.");
  } finally {
    await lin.disconnect();
  }
});

test("the built-in users", async ({ server, auth }) => {
  const readOnly = await server.createClient({ token: await auth.createToken(auth.users.readOnly) });
  try {
    expect((await readOnly.tools.call("whoami")).json()).toMatchObject({ sub: "readonly-001", email: "readonly@test.local" });
  } finally {
    await readOnly.disconnect();
  }
});

A token's scopes don't have to be in the server's allowedScopes: that option limits what a sign-in grants, and FrontMCP doesn't check it against a token it's given.

Checking that tokens are refused

A refused token makes server.createClient() reject, so rejects.toThrow() checks it. claims makes a token that's wrong in one way:

e2e/refused.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });

const refused = "HTTP 401: Unauthorized";

test("no token", async ({ server }) => {
  await expect(server.createClient()).rejects.toThrow(refused);
});

test("an expired token", async ({ server, auth }) => {
  await expect(server.createClient({ token: await auth.createExpiredToken({ sub: "agent-1" }) })).rejects.toThrow(refused);
});

test("a token with a bad signature", async ({ server, auth }) => {
  await expect(server.createClient({ token: auth.createInvalidToken({ sub: "agent-1" }) })).rejects.toThrow(refused);
});

test("a token for another server", async ({ server, auth }) => {
  const token = await auth.createToken({ sub: "agent-1", claims: { aud: "https://other-desk.example.com" } });
  await expect(server.createClient({ token })).rejects.toThrow(refused);
});

test("a token that expires during the test", async ({ server, auth }) => {
  const client = await server.createClient({ token: await auth.createToken({ sub: "agent-1", expiresIn: 2 }) });
  try {
    expect(await client.tools.call("whoami")).toBeSuccessful();
    await new Promise((resolve) => setTimeout(resolve, 3000));
    const result = await client.tools.call("whoami");
    expect(result.error).toMatchObject({ code: -32000, message: refused });
  } finally {
    await client.disconnect();
  }
});

Seeing why a token is refused

The test client only reports HTTP 401: Unauthorized. The server says why in the WWW-Authenticate header, which a request of your own can read:

test("why a token is refused", async ({ server, auth }) => {
  const token = await auth.createExpiredToken({ sub: "agent-1" });
  const response = await fetch(`${server.info.baseUrl}/`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "initialize",
      params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0.0" } },
    }),
  });
  expect(response.status).toBe(401);
  expect(response.headers.get("www-authenticate")).toContain('error_description="\\"exp\\" claim timestamp check failed"');
});

The header here is Bearer resource_metadata="http://localhost:51000/.well-known/oauth-protected-resource", error="invalid_token", error_description="\"exp\" claim timestamp check failed". Tokens and sessions lists the descriptions.

Testing a static-key server

A static server accepts the keys in its tokens, as bearer tokens:

e2e/static.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

// src/static.ts has auth: { mode: "static", tokens: [process.env.DESK_KEY!] }
test.use({ server: "./src/static.ts", env: { DESK_KEY: "desk-key-1" } });

test("the key is accepted", async ({ server }) => {
  const client = await server.createClient({ token: "desk-key-1" });
  try {
    expect((await client.tools.call("whoami")).json()).toMatchObject({ anonymous: false });
  } finally {
    await client.disconnect();
  }
});

test("another key isn't", async ({ server }) => {
  await expect(server.createClient({ token: "desk-key-2" })).rejects.toThrow("HTTP 401: Unauthorized");
});

Testing a transparent server

A transparent server checks tokens against its provider's keys, and refuses HS256, so auth.createToken() can't make one it accepts. Start a MockOAuthServer as the provider, on a fixed port, and sign tokens with the factory it serves the keys of. The factory's issuer is the provider's URL, and its audience the server's expectedAudience:

src/transparent.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: {
    mode: "transparent",
    provider: process.env.IDP_URL!,
    expectedAudience: "https://desk.example.com",
  },
})
export default class Server {}
e2e/transparent.e2e.spec.ts
import { test, expect, MockOAuthServer, TestTokenFactory } from "@frontmcp/testing";

const IDP_URL = "http://localhost:50900";
const tokens = new TestTokenFactory({ issuer: IDP_URL, audience: "https://desk.example.com" });
const idp = new MockOAuthServer(tokens, { port: 50900 });

test.use({ server: "./src/transparent.ts", env: { IDP_URL } });
test.beforeAll(() => idp.start());
test.afterAll(() => idp.stop());

test("a token from the provider is accepted", async ({ server }) => {
  const token = await tokens.createTestToken({ sub: "sso|nour", scopes: ["tickets:write"], claims: { email: "nour@acme.dev" } });
  const nour = await server.createClient({ token });
  try {
    expect((await nour.tools.call("whoami")).json()).toEqual({ sub: "sso|nour", email: "nour@acme.dev", anonymous: false });
    expect(await nour.tools.call("close_ticket", { id: "T-1" })).toBeSuccessful();
  } finally {
    await nour.disconnect();
  }
});

test("tokens the provider didn't sign, or not for this server, are refused", async ({ server, auth }) => {
  const refused = "HTTP 401: Unauthorized";
  // The auth fixture signs with a key of its own, which the provider doesn't publish
  await expect(server.createClient({ token: await auth.createToken({ sub: "sso|nour" }) })).rejects.toThrow(refused);
  await expect(server.createClient({ token: await tokens.createExpiredToken({ sub: "sso|nour" }) })).rejects.toThrow(refused);
  await expect(
    server.createClient({ token: await tokens.createTestToken({ sub: "sso|nour", aud: "https://other-desk.example.com" }) }),
  ).rejects.toThrow(refused);
});

beforeAll runs before the server starts, so the provider is up when the server first asks it for keys. The server finds them at the mock's /.well-known/jwks.json. (Before 1.9.2 it found them through /.well-known/oauth-authorization-server instead.)

Testing the sign-in

With the signIn() helper, a test signs in through the server's own page, and gets a token as a client would:

e2e/local-sign-in.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";
import { signIn } from "./sign-in";

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });

test("signing in with the built-in page", async ({ server }) => {
  const tokens = await signIn(server.info.baseUrl, { email: "nour@acme.dev", name: "Nour" }, "tickets:write");
  expect(tokens).toMatchObject({ token_type: "Bearer", scope: "tickets:write" });

  const nour = await server.createClient({ token: tokens.access_token });
  try {
    expect((await nour.tools.call("whoami")).json()).toMatchObject({ email: "nour@acme.dev", anonymous: false });
    expect(await nour.tools.call("close_ticket", { id: "T-1" })).toBeSuccessful();
  } finally {
    await nour.disconnect();
  }
});

For a server with your own authenticate and login.fields, pass your fields, like { deskKey: "…" }: signIn() submits them as the form does.

Testing a remote server's sign-in

A remote server sends the user to its provider. With MockOAuthServer as the provider and autoApprove, the provider signs testUser in at once, and the helper follows it back. The factory's audience is the server's clientId, which FrontMCP checks the provider's ID token against:

src/remote.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

const idp = process.env.IDP_URL!;

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: {
    mode: "remote",
    provider: idp,
    clientId: "help-desk-mcp",
    scopes: ["openid", "profile", "email"],
    providerConfig: { id: "idp", authEndpoint: `${idp}/oauth/authorize`, tokenEndpoint: `${idp}/oauth/token` },
  },
})
export default class Server {}
e2e/remote-sign-in.e2e.spec.ts
import { test, expect, MockOAuthServer, TestTokenFactory } from "@frontmcp/testing";
import { signIn } from "./sign-in";

const IDP_URL = "http://localhost:50901";
const idp = new MockOAuthServer(new TestTokenFactory({ issuer: IDP_URL, audience: "help-desk-mcp" }), {
  port: 50901,
  autoApprove: true,
  testUser: { sub: "sso|nour", email: "nour@acme.dev", name: "Nour" },
  clientId: "help-desk-mcp",
});

test.use({ server: "./src/remote.ts", env: { IDP_URL, JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });
test.beforeAll(() => idp.start());
test.afterAll(() => idp.stop());

test("signing in through the provider", async ({ server }) => {
  const tokens = await signIn(server.info.baseUrl);
  const nour = await server.createClient({ token: tokens.access_token });
  try {
    expect((await nour.tools.call("whoami")).json()).toEqual({ sub: "sso|nour", email: "nour@acme.dev", anonymous: false });
  } finally {
    await nour.disconnect();
  }
});

Run it like any other file:

npx frontmcp test --runInBand e2e/remote-sign-in.e2e.spec.ts

The provider's redirect_uri is built from http.port, which defaults to PORT, the port the test server is started on, so the http.port line can go. Before 1.9.2, a server without it built the redirect_uri with port 3001, and signIn() failed with fetch failed. See Registering the callback URL.

Testing a CIMD client

A CIMD client doesn't register: its client_id is the URL of its metadata document. A test starts a MockCimdServer to serve that document, registers the client, and signs in with the URL, which the signIn() helper takes as its fourth argument. FrontMCP fetches a document only from an https URL on a public address, so the server turns on allowInsecureForTesting when the test's environment asks for it:

src/cimd.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: {
    mode: "local",
    allowedScopes: ["tickets:read", "tickets:write"],
    // Lets a client publish its document at http://localhost, as MockCimdServer does. Tests only.
    cimd: { security: { allowInsecureForTesting: process.env.CIMD_ON_LOCALHOST === "1" } },
  },
})
export default class Server {}
e2e/cimd.e2e.spec.ts
import { test, expect, MockCimdServer } from "@frontmcp/testing";
import { signIn } from "./sign-in";

const clients = new MockCimdServer();

test.use({ server: "./src/cimd.ts", env: { CIMD_ON_LOCALHOST: "1", JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });
test.beforeAll(() => clients.start());
test.afterAll(() => clients.stop());

test("a client signs in with its metadata URL as client_id", async ({ server }) => {
  const clientId = clients.registerClient({ name: "Notes Desktop" });
  expect(clientId).toBe(`${clients.info.baseUrl}/clients/notes-desktop/metadata.json`);

  const tokens = await signIn(server.info.baseUrl, { email: "nour@acme.dev" }, "tickets:write", clientId);
  const nour = await server.createClient({ token: tokens.access_token });
  try {
    expect((await nour.tools.call("whoami")).json()).toMatchObject({ email: "nour@acme.dev", anonymous: false });
  } finally {
    await nour.disconnect();
  }
});

test("a redirect URI the document doesn't list is refused", async ({ server }) => {
  const clientId = clients.registerClient({ name: "Notes Web", redirectUris: ["https://notes.example.com/callback"] });
  await expect(signIn(server.info.baseUrl, { email: "nour@acme.dev" }, undefined, clientId)).rejects.toThrow("is not registered for client");
});

test("a document that can't be fetched, or isn't valid, is refused", async ({ server }) => {
  const { baseUrl } = clients.info;
  clients.registerFetchError("/clients/gone/metadata.json", 500);
  await expect(signIn(server.info.baseUrl, {}, undefined, `${baseUrl}/clients/gone/metadata.json`)).rejects.toThrow(
    "Failed to fetch CIMD document",
  );

  // No client_name
  clients.registerInvalidDocument("/clients/nameless/metadata.json", {
    client_id: `${baseUrl}/clients/nameless/metadata.json`,
    redirect_uris: ["http://localhost:3000/callback"],
  });
  await expect(signIn(server.info.baseUrl, {}, undefined, `${baseUrl}/clients/nameless/metadata.json`)).rejects.toThrow(
    "CIMD document validation failed: client_name",
  );
});

MockCimdServer runs in the test's process, and the server under test, in its own, fetches the documents from it over HTTP. A refused document stops the sign-in at /oauth/authorize, with the 400 page that CIMD's troubleshooting lists, and signIn() throws Sign-in stopped with 400: and the page's text. Each test registers a client of its own, because the server keeps a document it fetched for an hour. This was run with frontmcp test and Jest 30 on FrontMCP 1.9.4.


Troubleshooting

Failed to initialize MCP connection to http://localhost:51000/ after 1 attempt: HTTP 401: Unauthorized - …

The server refused the client's token, or it had none. Check that test.use({ env }) has a JWT_SECRET (without it, auth tokens are RS256 and no server accepts them), that publicMode is off, and that the token's iss and aud are the server's: a server with local.issuer needs claims: { iss }. For a transparent server, sign with the factory its MockOAuthServer serves. Seeing why a token is refused reads the server's reason.

getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)

auth.getJwks() was called with a JWT_SECRET in test.use({ env }). Those tokens are checked with the secret, and have no public key.

error_description="no_provider_verified (kid=test-key-…)"

A transparent server couldn't verify the token with its provider's keys: it was signed by another factory (like the auth fixture's), its iss isn't the provider, or it expired. Give the factory the provider's URL as issuer, and pass that factory to the MockOAuthServer.

expect() from @frontmcp/testing can only be used inside a Jest test file

expect() or test() from @frontmcp/testing ran outside Jest, in a script or another test runner. The package imports anywhere, and TestTokenFactory, MockOAuthServer and httpMock work there, but test and expect need Jest: run the file with frontmcp test.

Sign-in stopped with 400: … Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL for a MockCimdServer client

The server under test doesn't have cimd.security.allowInsecureForTesting on, so it doesn't count the client's http://localhost URL as a metadata URL at all, and refuses it as a client it doesn't know. Turn the option on through the test's environment, as in Testing a CIMD client.

Sign-in stopped with 200 from the signIn() helper

The page it reached isn't local mode's sign-in form: for example, MockOAuthServer's own login page, shown when autoApprove is off. Turn autoApprove on, with a testUser.