Let an MCP client discover your Authorization Server
An OAuth-capable MCP client that has never seen your engine still needs to know where to get a token. RFC 9728 Protected Resource Metadata answers that: the engine publishes a small JSON document naming itself and its Authorization Servers, and points at that document from every 401.
Invokta implements the Resource Server half only. Configuring
auth.resourceMetadata publishes the document and enriches the challenge. It
does not add a login, consent, token, refresh, or user-management route, and it
never makes the engine an Authorization Server.
This recipe runs against the
auth-jwt-bearer-engine
example. Its verification mechanics live in
Authenticate with a JWT bearer token; only the
discovery layer is new here.
Publish the metadata
Section titled “Publish the metadata”-
Accept a resource metadata configuration
export interface AuthJwtBearerMcpHttpOptions {readonly verifier: AccessTokenVerifier;readonly host?: string;readonly port?: number;readonly allowedHosts?: ReadonlyArray<string>;readonly allowedOrigins?: ReadonlyArray<string>;readonly resourceMetadata?: McpHttpProtectedResourceMetadata;readonly challengeScopes?: ReadonlyArray<string>;}McpHttpProtectedResourceMetadatacarriesresource, a non-emptyauthorizationServersarray, and optionalscopesSupported. Making it optional keeps the same composition root usable for a deployment whose clients already hold tokens.challengeScopesis the ordered set of base scopes this Resource Server requires; it needsresourceMetadataand is covered in Name the scopes in the challenge. -
Pass it to
serveMcpHttpreturn serveMcpHttp(engine, {...(options.host === undefined ? {} : { host: options.host }),...(options.port === undefined ? {} : { port: options.port }),auth: {mode: "required",authenticate: createJwtBearerAuthenticate(options.verifier),...(options.resourceMetadata === undefined? {}: { resourceMetadata: options.resourceMetadata }),...(options.challengeScopes === undefined? {}: { challengeScopes: options.challengeScopes }),},});The adapter validates the metadata at startup and snapshots it before it listens. Mutating the configuration object afterwards cannot change the published document or the challenge.
-
Build it from deployment configuration
function readResourceMetadata(issuer: string,): McpHttpProtectedResourceMetadata | undefined {const resource = process.env.AUTH_JWT_RESOURCE;if (resource === undefined || resource === "") return undefined;const configured = readList("AUTH_JWT_AUTHORIZATION_SERVERS");const [first, ...rest] = configured ?? [issuer];if (first === undefined) {throw new Error("AUTH_JWT_AUTHORIZATION_SERVERS must list at least one entry.",);}const scopesSupported = readList("AUTH_JWT_SCOPES_SUPPORTED");return {resource,authorizationServers: [first, ...rest],...(scopesSupported === undefined ? {} : { scopesSupported }),};}resourceis the engine’s public/mcpURL, and it comes from configuration — never fromHost,X-Forwarded-Host, or any other request header. Behind a reverse proxy this is the only value that makes the published document correct. -
Respect the validation rules
The adapter rejects a configuration that cannot describe a real protected resource:
resourcemust be an HTTPS URL whose path is exactly/mcp, with loopback HTTP allowed only for local development;resourcecannot contain credentials, a query, or a fragment;- every entry in
authorizationServersmust be an HTTPS identifier without credentials, query, or fragment; an issuer path is allowed, and any loopback HTTP origin is accepted behind a loopback HTTP resource, so a local identity provider on its own port works while a deployed engine can never advertise a plain-HTTP Authorization Server; - at least one Authorization Server is required.
For the example above,
AUTH_JWT_ISSUERdoubles as the Authorization Server identifier, because the issuer that signs the tokens is the server the client must talk to.
What the client sees
Section titled “What the client sees”The engine now answers two requests it did not answer before.
The metadata document, served on GET before authentication runs — the
discovery path for a resource ending in /mcp is
/.well-known/oauth-protected-resource/mcp:
{ "resource": "https://engine.example.com/mcp", "authorization_servers": ["https://identity.example.com"], "scopes_supported": ["engine:invoke"]}And an enriched challenge on every 401, whose URL is derived from the configured
resource:
HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer resource_metadata="https://engine.example.com/.well-known/oauth-protected-resource/mcp"With challengeScopes configured, the same challenge also names the scopes the
client should ask for:
HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer resource_metadata="https://engine.example.com/.well-known/oauth-protected-resource/mcp", scope="engine:invoke"From there the MCP authorization specification takes over: the client parses the
challenge, fetches the metadata document, picks an Authorization Server from
authorization_servers, retrieves that server’s own metadata (RFC 8414, at
/.well-known/oauth-authorization-server), optionally registers dynamically,
and runs an OAuth 2.1 authorization code flow with PKCE. It sends the RFC 8707
resource parameter set to this engine’s canonical URI, so the token it
receives is audience-bound to your engine — which is exactly what the verifier’s
audience check enforces on the way back in.
Name the scopes in the challenge
Section titled “Name the scopes in the challenge”scopes_supported in the metadata document describes the minimal set this
resource generally supports. It does not tell a client what the request it just
made needed. challengeScopes does, by serializing an ordered scope list into
the 401 challenge itself:
await serveMcpHttp(engine, { auth: { mode: "required", authenticate, resourceMetadata: { resource: "https://engine.example.com/mcp", authorizationServers: ["https://identity.example.com"], scopesSupported: ["engine:invoke"], }, challengeScopes: ["engine:invoke"], },});The adapter validates each RFC 6749 scope token, rejects duplicates, and
snapshots the ordered set before it listens, so the serialized challenge cannot
change afterwards. Challenge scopes require resourceMetadata: a configuration
that names them without it fails at startup rather than serving a challenge no
client can act on.
Omit them when the authentication policy cannot name a stable base set — for an engine whose capabilities require different scopes, the challenge would either over-ask or under-ask.
Run it
Section titled “Run it”Build the repository and start the engine with a resource identifier configured. A loopback HTTP resource is accepted for local development, and it may advertise a loopback HTTP Authorization Server, so an identity provider running on your machine can be used without a certificate:
yarn buildAUTH_JWT_ISSUER="https://your-tenant.example.com/" \AUTH_JWT_AUDIENCE="http://127.0.0.1:3000/mcp" \AUTH_JWT_RESOURCE="http://127.0.0.1:3000/mcp" \AUTH_JWT_SCOPES_SUPPORTED="engine:invoke" \AUTH_JWT_CHALLENGE_SCOPES="engine:invoke" \PORT=3000 \node examples/auth-jwt-bearer-engine/dist/mcp-http.jsRead the published document, which needs no credential:
curl -s http://127.0.0.1:3000/.well-known/oauth-protected-resource/mcpThen trigger the challenge an OAuth-capable client starts from:
curl -si http://127.0.0.1:3000/mcp \ -H 'accept: application/json, text/event-stream' \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"identity_whoami","arguments":{}}}' \ | grep -i www-authenticatePoint an OAuth-capable MCP client at http://127.0.0.1:3000/mcp and it will
follow the same two steps on its own before running the authorization flow with
your provider.
Check the chain
Section titled “Check the chain”curl shows the two documents; it does not tell you whether a client can get
from one to the other. The devtools runs the whole discovery chain and reports
it leg by leg:
npx @invokta/devtools serve dist/engine.jsIn Playground, set Auth to an external endpoint with OAuth, enter your
engine’s /mcp URL, and press Check. Each leg reports on its own — the
401 challenge and whether it advertises resource_metadata, the Protected
Resource Metadata document, the Authorization Server’s RFC 8414 metadata, and
whether dynamic client registration is advertised. A leg that could not run
says what it was waiting for instead of reporting a failure it never attempted,
and Authorize stays disabled until the chain resolves.
The check authorizes nothing and sends no credential: it is the discovery half only, which is the half your engine is responsible for.
For the same chain without a browser — in a deployment pipeline, or against an engine you just released — run the bounded, credential-free inspection:
yarn workspace @invokta/example-auth-jwt-bearer deploy:inspect-oauth \ --url https://engine.example.com/mcpinvokta-deploy inspect-oauth reports each leg with its own outcome and
remediation and exits 1 when the chain is not ready. It sends no token,
cookie, or client credential, registers no client, and mutates nothing.
Verify it
Section titled “Verify it”yarn workspace @invokta/example-auth-jwt-bearer testThe example’s HTTP tests start the adapter with resourceMetadata configured
and assert that the well-known document is served unauthenticated with the
expected fields, and that an unauthenticated tools/call returns 401 with the
resource_metadata challenge pointing at that exact URL.
Read the complete
auth-jwt-bearer-engine
example for the composition root and those tests.
- Authorize with capability access rules turns the scopes a client obtained into a policy decision.
- HTTP authentication is the normative contract for Protected Resource Metadata, the challenge, and the secret rules.
- Self-hosted OAuth adds the host-owned Authorization Server, persistence, proxy topology, and operating runbooks.