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.mode | The mcp fixture | A token that works in tests |
|---|---|---|
public (the default) | Connects, as an anonymous caller with an anon: id | auth.createToken(), with JWT_SECRET in test.use({ env }) |
static | Doesn't connect | One of the server's tokens |
local, remote | Doesn't connect | auth.createToken(), with JWT_SECRET in test.use({ env }), or the token from a real sign-in |
transparent | Doesn't connect | A 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.
| Option | Type | Description |
|---|---|---|
sub | string | Required. The user's id: this.auth.user.sub in your tools. |
scopes | string[] | The scope claim, space-separated. |
email, name | string | The claims of the same name. |
claims | Record<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. |
expiresIn | number | How 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 }):
env | Signed with | iss and aud |
|---|---|---|
With JWT_SECRET | HS256 and that secret, as a public, local or remote server signs its own tokens | The server's MCP address: baseUrl and entryPath, like http://localhost:51000, or http://localhost:51000/mcp with entryPath: "/mcp" |
| Without | RS256, with a key made for the file | https://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
| Member | Description |
|---|---|
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. |
users | Three 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). |
| User | sub | scopes | email, name |
|---|---|---|---|
auth.users.admin | admin-001 | admin:*, read, write, delete | admin@test.local, Test Admin |
auth.users.user | user-001 | read, write | user@test.local, Test User |
auth.users.readOnly | readonly-001 | read | readonly@test.local, Read Only User |
What happens to a token
- Accepted: tools see its claims.
this.auth.userhassub,emailandname,this.auth.scopeshas its scopes, andthis.auth.claimshas every claim. (Underfrontmcp test, which has a session,this.auth.scopeswas[]before 1.8.7, and tools readthis.context.authInfo.scopes.) - Refused:
server.createClient()rejects withFailed to initialize MCP connection to http://localhost:51000/ after 1 attempt: HTTP 401: Unauthorized - {"error":"Unauthorized"}.A401or403is tried once. The reason is only in the401'sWWW-Authenticateheader: see Seeing why a token is refused. - Expired during the test: the client's next requests fail with
{ code: -32000, message: "HTTP 401: Unauthorized" }, inresult.error, and thelist()methods throwFailed to list tools: HTTP 401: Unauthorized.
Caveats
mcp.authenticate(token)signs themcpfixture in. It opens a new session as the token's user, and the client keeps the token, somcp.reconnect()uses it too. When the server refuses the token, it rejects with the errorcreateClient()would give, andmcpkeeps its old session and identity. On a server that needs a token, it's how a test usesmcpat all:await mcp.authenticate(await auth.createToken({ sub: "agent-1" })).publicMode: trueonly keepsmcpfrom asking for an anonymous token. A client given a token, withserver.createClient({ token })orauthenticate(), sends it.- A server with
local.issuer, orFRONTMCP_PUBLIC_URL, issues and expects anotheriss. Pass it inclaims: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@FrontMcpsays;auth: { mode: "public" }only keepsmcpanonymous, likepublicMode.- 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
mcpfixture tries to connect before every test, and on a server that needs a token it prints itscould not connect anonymouslywarning each time. The tests that use onlyserverandauthare 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" });
| Option | Default | Description |
|---|---|---|
issuer | "https://test.frontmcp.local" | The iss of its tokens. |
audience | "frontmcp-test" | Their aud. |
hmacSecret | Sign with HS256 and this secret, instead of an RSA key the factory makes on first use. |
| Method | Returns |
|---|---|
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",
});
| Option | Default | Description |
|---|---|---|
port | a random free port | Set 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. |
issuer | http://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. |
autoApprove | false | /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. | |
clientId | When set, other client_ids are redirected back with error=unauthorized_client. | |
clientSecret | When set, /oauth/token requires it, in the form or with HTTP Basic auth, or answers 401 invalid_client. | |
validRedirectUris | any | The redirect URIs /oauth/authorize accepts, exactly or with *. Others get 400 Invalid redirect_uri. |
accessTokenTtlSeconds, refreshTokenTtlSeconds | 3600, 30 days | How 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.) |
debug | false | Logs each request. |
| Member | Description |
|---|---|
start() | Starts listening. Resolves to { baseUrl, port, issuer, jwksUrl }. |
stop() | Stops it, closing open connections. |
info | What 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. |
| Endpoint | What it does |
|---|---|
GET /.well-known/jwks.json | The factory's public key. |
GET /.well-known/openid-configuration, GET /.well-known/oauth-authorization-server | Metadata: the issuer, the endpoints, jwks_uri, and the grants it supports. |
GET /oauth/authorize | With autoApprove, a 302 back to redirect_uri with code and state; otherwise the login page, which posts to POST /oauth/authorize/submit. |
POST /oauth/token | Form-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 /userinfo | testUser, 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
| Option | Default | Description |
|---|---|---|
port | a random free port | The port it listens on. The server under test doesn't need to know it: the client_id carries it. |
debug | false | Logs each request, and each client registered or removed. |
| Member | Description |
|---|---|
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. |
info | What 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:
| Option | Default | Becomes |
|---|---|---|
name | Required | client_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. |
path | From name | The 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, contacts | client_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:
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:
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:
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 {}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:
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:
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:
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 {}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:
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:
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 {}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.tsThe 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:
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 {}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.