Client ID metadata (CIMD)

With Client ID Metadata Documents (CIMD), an MCP client doesn't register with your server. Its client_id is an https URL, and at that URL the client publishes a small JSON document about itself: its name, its logo, and the redirect URIs it signs users in with. When such a client starts a sign-in, FrontMCP fetches the document, checks it, shows the client's name to the user, and sends the authorization code only to a redirect URI the document lists. Nothing is stored on your server, so it works in production, where dynamic registration is usually off, and a client can connect to a server it has never seen without a registration step. It applies when FrontMCP is the authorization server, in local and remote mode, and it's on by default.

@FrontMcp({ auth: { mode: "local" | "remote", cimd?: { enabled?, cache?, security?, network? }, requireRegisteredClients? } })

Reference

auth.cimd

CIMD needs no configuration: in local and remote mode it's on with the settings below. Set cimd to change them. What makes CIMD protect anything is requireRegisteredClients, which is on by default:

main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { NotesApp } from "./notes.app";

@FrontMcp({
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: {
    mode: "local",
    local: { issuer: "https://notes.example.com" },
    cimd: {
      cache: { defaultTtlMs: 600_000 },
      security: { blockedDomains: ["untrusted.example.org"] },
      network: { timeoutMs: 3000 },
    },
  },
})
export default class Server {}

See more examples below.

Which client ids are metadata URLs

A client_id is treated as a metadata URL when it's an https URL with a path other than /, like https://notes.example.com/oauth/client.json. Anything else is an ordinary client id, and FrontMCP fetches nothing for it: http://… URLs (unless allowInsecureForTesting is on and the host is localhost), https://notes.example.com/ with no path, and plain strings. What happens to ordinary ids depends on requireRegisteredClients.

What FrontMCP checks

At /oauth/authorize, after the request itself is valid and before the login page (in local mode) or the redirect to your provider (in remote mode):

  1. The URL. security.allowedDomains and security.blockedDomains, then the host itself: localhost names and private, loopback and other blocked addresses are refused.
  2. The cache. A document fetched earlier and still fresh is used as it is, and steps 3 to 5 are skipped.
  3. DNS. Where Node's node:dns is available, FrontMCP looks the host up and refuses it if any address it resolves to is blocked, or if it doesn't resolve at all.
  4. The fetch. A GET with Accept: application/json, within network.timeoutMs, reading at most network.maxResponseSizeBytes. Redirects follow network.redirectPolicy, and every hop is checked again from step 1.
  5. The document. It must be JSON, match the document format, and have a client_id equal to the URL it was fetched from, character for character.
  6. The redirect URI. The request's redirect_uri must be one the document lists. See matching redirect URIs.

Any failure answers 400 with an HTML page, "Authorization Error", invalid_request and a message naming the problem, and never redirects: the redirect URI hasn't been checked, so FrontMCP won't send anyone to it. Troubleshooting lists every message.

After that, the sign-in goes on as for any client. On local mode's login page, the client is shown by its client_name and logo_uri instead of its URL. At /oauth/token, the client sends its URL as client_id and proves itself with PKCE; there's no secret.

The metadata document

A client publishes a JSON object at its URL:

client.json
{
  "client_id": "https://notes.example.com/oauth/client.json",
  "client_name": "Notes Desktop",
  "logo_uri": "https://notes.example.com/logo.png",
  "redirect_uris": ["http://127.0.0.1:33418/callback", "https://notes.example.com/oauth/callback"]
}
FieldTypeRequiredWhat FrontMCP does with it
client_idURLYesMust equal the URL the document was fetched from.
client_namestring, not emptyYesShown on the login page.
redirect_urisURL[], at least oneYesThe only redirect URIs the client may use.
logo_uriURLShown on the login page.
token_endpoint_auth_method"none", "client_secret_basic", "client_secret_post" or "private_key_jwt"Checked against that list, and otherwise not used: FrontMCP doesn't authenticate CIMD clients at the token endpoint beyond PKCE, whatever this says. Defaults to "none".
grant_types, response_typesstring[]Not enforced: a document with grant_types: ["authorization_code"] can still refresh.
scopestringNot enforced: the client can ask for other scopes, and gets the ones the server's allowedScopes lists.
client_uri, tos_uri, policy_uri, jwks_uriURLChecked to be URLs, and not used.
jwks{ keys: object[] }Checked for shape, and not used.
contactsemail[]Checked to be email addresses, and not used.
software_id, software_version, software_statementstringNot used.

Other fields are ignored.

Options

OptionTypeDefaultDescription
enabledbooleantruefalse stops FrontMCP from fetching documents, and client_id_metadata_document_supported in its metadata becomes false. A metadata URL used as a client_id is then refused with CimdDisabledError's message, CIMD (Client ID Metadata Documents) is disabled on this server, whatever requireRegisteredClients says, unless it's one of your registered clients. Changed in 1.9.3: before, such URLs counted as ordinary ids, and requireRegisteredClients: false let them in with any redirect URI.
cache.defaultTtlMsnumber3600000 (1 hour)How long a document is kept when its response has no caching headers.
cache.minTtlMsnumber60000 (1 minute)The shortest time a document is kept, even with Cache-Control: no-cache or no-store.
cache.maxTtlMsnumber86400000 (1 day)The longest time a document is kept, whatever its headers say.
cache.type, cache.redis"memory" or "redis", { url } or { host, port, password?, db?, tls? }, with keyPrefix?"memory", and keyPrefix "cimd:"Where fetched documents are kept: in each server instance's memory, or in Redis, shared by every instance. With "redis", the server connects when it starts, and doesn't start without redis or when Redis can't be reached. See Sharing the cache between instances. Changed in 1.9.3: before, documents were cached in memory whatever type said.
security.blockPrivateIPsbooleantrueRefuse localhost names and blocked addresses, literal or through DNS.
security.allowedDomainsstring[]Only these hosts may serve documents. example.com matches example.com and every subdomain; *.example.com does the same.
security.blockedDomainsstring[]These hosts, and their subdomains, may not.
security.warnOnLocalhostRedirectsbooleantrueLog a warning when all of a document's redirect URIs are on localhost, 127.0.0.1 or ::1: CIMD client "<url>" has only localhost redirect URIs - this may be a development client. Nothing else changes.
security.allowInsecureForTestingbooleanfalseAccept http:// metadata URLs on localhost, and turn every address check off. For tests on your own machine only.
network.timeoutMsnumber, at least 1005000How long a fetch may take, redirects included.
network.maxResponseSizeBytesnumber, at least 102465536 (64 KB)The largest document FrontMCP reads.
network.redirectPolicy"deny", "same-origin" or "allow""deny"Whether to follow a redirect when fetching the document. The document found at the end must still name the original URL as its client_id.
network.maxRedirectsnumber5How many redirects "same-origin" and "allow" follow.

A number below its minimum is a configuration error, like Too small: expected number to be >=100 at auth.cimd.network.timeoutMs, and the server doesn't start.

Caching

FrontMCP keeps each document it fetched, in memory, for a time it works out from the response:

  • Cache-Control: max-age=N: N seconds, less the response's Age. s-maxage wins over max-age.
  • Cache-Control: no-cache or no-store: cache.minTtlMs, so the document is still reused for a minute by default.
  • No Cache-Control, but Expires: until then.
  • Neither: cache.defaultTtlMs.

The result is kept between cache.minTtlMs and cache.maxTtlMs. When a document with an ETag or Last-Modified goes stale, FrontMCP asks again with If-None-Match or If-Modified-Since, and a 304 Not Modified keeps the document it has. By default each server instance has its own cache, and a restart empties it; with cache.type: "redis", every instance shares one, and it outlives restarts.

Blocked addresses

With security.blockPrivateIPs on, FrontMCP refuses to fetch from:

  • localhost, localhost.localdomain and any name ending in .localhost;
  • IPv4 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16 (which includes cloud metadata services), 172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16, 198.18.0.0/15, 224.0.0.0/4, 240.0.0.0/4 and 255.255.255.255;
  • IPv6 ::, ::1, fc00::/7, fe80::/10, fec0::/10, and IPv6 addresses that embed a blocked IPv4 address, like ::ffff:10.0.0.1.

Before each fetch, a host name is also looked up in DNS, and refused if any of its addresses is blocked (Host "localtest.me" resolves to a blocked address (::1): IPv6 loopback address (::1) is not allowed) or if it doesn't resolve (DNS resolution failed for "…"; refusing to fetch an unvalidated host). The lookup needs node:dns: where it isn't available, as in this site's Playground in the browser, only literal addresses and localhost names are checked. And the lookup is separate from the one fetch() makes, so a DNS server that answers differently the second time can still steer the fetch.

CIMD, registration and requireRegisteredClients

A client can identify itself to FrontMCP in three ways:

Metadata URL (CIMD)Registered (POST /oauth/register)Unregistered id
client_idAn https URLA UUID FrontMCP issuedAnything
What FrontMCP storesNothing but a cacheThe registration, in memoryNothing
Its redirect URIsListed in its documentGiven at registrationWhatever each request says
In productionWorksOff unless you set dcr.enabled in local mode; always off in remote modeRefused, like everywhere by default
With requireRegisteredClients on (the default)AcceptedAcceptedRefused: Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL
With requireRegisteredClients: falseAcceptedAcceptedAccepted, with any redirect URI

In local mode, dcr.allowedClientIds doesn't apply to metadata URLs, but dcr.allowedRedirectUris does: a document's redirect URI outside the list is refused with redirect_uri "…" is not in the configured allowlist.

So in production, where registration is off, a remote server with the defaults serves CIMD clients only, and a local server CIMD clients and the ones in dcr.clients.

Caveats

  • Tools can't tell which client is calling. FrontMCP's token has no claim for the client, and this.context.authInfo.clientId is the user's sub. The client's name is shown at sign-in and not kept.
  • Only four fields count. client_id, client_name, logo_uri and redirect_uris. A document's token_endpoint_auth_method, jwks, scope and grant_types don't restrict the client.
  • The cache is per instance, in memory, unless you choose Redis. A client that changes its document may wait up to its cache time, a day at most, on each instance.
  • Not in transparent mode. There your identity provider is the authorization server, and it has to support CIMD itself. cimd isn't a transparent option.

Usage

To test a CIMD client against a server on your own machine, MockCimdServer from @frontmcp/testing serves its document at http://localhost: see Testing a CIMD client.

Accepting a client by its metadata URL

The server is in local mode with CIMD's defaults. The tests follow Notes Desktop through a whole sign-in, with its URL as client_id:

Open
import "./notes-client.example";
import { NotesApp } from "./notes.app";

// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const, allowedScopes: ["notes:read"] },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A metadata URL counts as a known client, so the default requireRegisteredClients doesn't get in the way, and the token has the notes:read scope because allowedScopes lists it. In remote mode the checks are the same, and a valid client is then sent on to your identity provider instead of the login page.

Holding the client to its redirect URIs

The code goes only to a redirect URI in the client's document. The comparison ignores a trailing slash and the case of the host, and, for a loopback address, the port, because a desktop app picks a free port each time it signs in:

Open
import "./notes-client.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A desktop client should list its loopback redirect with the same host it uses: 127.0.0.1 and localhost are different hosts to FrontMCP.

What happens with a bad document

Each of these clients publishes something FrontMCP won't accept. Every one gets the same kind of answer, a 400 error page naming the problem, and the user never reaches the login page:

Open
import "./clients.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The messages come from FrontMCP's internal errors, and include the client's URL. The page is shown in production too.

Keeping the fetch off your network

A metadata URL is chosen by whoever starts the sign-in, and FrontMCP fetches it from inside your network. So before anything is fetched, private and loopback addresses are refused, and so is a redirect to one. allowedDomains and blockedDomains narrow it further:

Open
import "./clients.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: {
    mode: "local" as const,
    cimd: {
      security: { blockedDomains: ["untrusted.example.org"] },
      network: { redirectPolicy: "allow" as const },
    },
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A host name that passes these checks is then looked up in DNS, in Node, and refused if it resolves to a blocked address. See Blocked addresses for the full list, and what the DNS check can't catch.

Caching the document

A document is fetched once and reused until it's stale. When it has an ETag, FrontMCP then asks whether it changed rather than downloading it again. This server keeps documents for no less than 0 ms, so the second sign-in finds the no-cache document stale at once:

Open
import "./notes-client.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const, cimd: { cache: { minTtlMs: 0 } } },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

With the default minTtlMs of a minute, the no-cache document would have been reused without asking. Each server instance keeps its own cache in memory, and a restart empties it, unless the cache is in Redis.

Sharing the cache between instances

With several instances, cache.type: "redis" keeps the documents in Redis, so a document one instance fetched is used by all of them, and a restart keeps them:

main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { NotesApp } from "./notes.app";

@FrontMcp({
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: {
    mode: "local",
    local: { issuer: "https://notes.example.com" },
    cimd: {
      cache: { type: "redis", redis: { url: process.env.REDIS_URL!, keyPrefix: "notes:cimd:" } },
    },
  },
})
export default class Server {}

The Playground has no Redis, so this was checked in Node against a local Redis-compatible server: two servers with the same redis fetched a client's document once between them, where two servers with the default cache fetched it once each. Each document is stored under keyPrefix and the SHA-256 of its URL. The server connects to Redis when it starts, and fails to start rather than fall back to memory: without redis, with Redis configuration is required when cache type is "redis", and when Redis can't be reached, with StorageConnectionError: Failed to connect to Redis: ….

Requiring clients to be known

CIMD only means something together with requireRegisteredClients, which is on by default. Turned off, it lets FrontMCP accept client ids it knows nothing about, and an http:// URL is one of those, so a client can skip the document check altogether:

Open
import "./notes-client.example";
import { NotesApp } from "./notes.app";

const info = { name: "notes", version: "1.0.0" };

// The default: requireRegisteredClients is on
export const strict = { info, apps: [NotesApp], auth: { mode: "local" as const } };

// 🚩 For development only
export const lenient = { info, apps: [NotesApp], auth: { mode: "local" as const, requireRegisteredClients: false } };

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

With requireRegisteredClients on, a client is either registered, with the redirect URIs it gave at registration, or has a document listing them. There's no third way in.


Troubleshooting

Every CIMD failure is a 400 error page at /oauth/authorize, "Authorization Error", with invalid_request and one of these messages.

DNS resolution failed for "…"; refusing to fetch an unvalidated host

The full message is CIMD security check failed for <url>: DNS resolution failed for "<host>"; refusing to fetch an unvalidated host. The server couldn't look the client's host up, so FrontMCP refuses to fetch from it rather than fetch unchecked. Check that the name exists and that the server can reach a DNS server. In tests, publish the stand-in document at a public IP address, as this page's examples do, or at http://localhost with allowInsecureForTesting, on your own machine only.

resolves to a blocked address

CIMD security check failed for <url>: Host "<host>" resolves to a blocked address (<address>): …. The client's host name points at a private or loopback address. A document meant for your own network can't be used as a metadata URL; register that client instead.

Localhost addresses are not allowed, Private IP addresses (10.x.x.x) are not allowed, …

The metadata URL's host is localhost or a blocked address. Clients have to publish their documents on a public https host.

Domain "…" is not in the allowed domains list or Domain "…" is blocked

security.allowedDomains doesn't cover the host, or security.blockedDomains does. Both match the domain and its subdomains.

Redirect URI "…" is not registered for client "…"

The sign-in's redirect_uri isn't in the document's redirect_uris. Trailing slashes and the host's case don't matter, and neither does the port of a loopback address; everything else must match, and localhost and 127.0.0.1 are different hosts. If the client just added the URI to its document, FrontMCP may still have the old one: see the next entry.

A changed document isn't picked up

FrontMCP caches documents, for as long as the response's Cache-Control or Expires says, or an hour without them, and never less than cache.minTtlMs, a minute by default. Wait for it, restart the server, or lower cache.maxTtlMs. To have changes picked up quickly, serve the document with a short max-age and an ETag.

CIMD client_id mismatch: URL is "…" but document contains "…"

The document's client_id must be exactly the URL it's served from: same scheme, host, path, and no extra slash. After a redirect, it must still be the first URL.

CIMD document validation failed: …

The document is JSON but doesn't have what the format requires: usually a missing client_name, an empty redirect_uris, or a relative URL where a full one is needed. The message lists each problem, by field.

Failed to fetch CIMD document from …

The rest of the message says why:

  • HTTP 404 Not Found, or another status: the URL doesn't serve the document.
  • Invalid JSON response: it served something else, like an HTML page.
  • Request timeout: it took longer than network.timeoutMs, 5 seconds by default.
  • CIMD fetch redirected to "…" but redirects are disabled.: serve the document at the client_id URL itself, or set network.redirectPolicy.
  • …which is not the same origin as "…": with "same-origin", the redirect went to another host.
  • CIMD fetch exceeded max redirects (5).: raise network.maxRedirects, or fix the redirect loop.

CIMD response from … exceeds maximum size of 65536 bytes

The document is larger than network.maxResponseSizeBytes. A real document is a few hundred bytes; check that the URL serves the document and not a page.

CIMD (Client ID Metadata Documents) is disabled on this server

The server has cimd: { enabled: false }, and the client_id is a metadata URL that isn't one of your registered clients. Turn CIMD back on, or register the client, in local mode with dcr.clients, under that URL as its clientId.

Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL

requireRegisteredClients is on, as it is by default, and the client_id is neither registered nor a metadata URL. An http:// URL or a URL without a path counts as neither; use https and a path like /oauth/client.json.

The login page shows the client's URL instead of its name

FrontMCP didn't treat the client_id as a metadata URL: it isn't https, or has no path. FrontMCP fetched nothing and checked nothing, and with requireRegisteredClients: false it let the client in anyway. See Requiring clients to be known.