Skip to content

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.

  1. 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>;
    }

    McpHttpProtectedResourceMetadata carries resource, a non-empty authorizationServers array, and optional scopesSupported. Making it optional keeps the same composition root usable for a deployment whose clients already hold tokens. challengeScopes is the ordered set of base scopes this Resource Server requires; it needs resourceMetadata and is covered in Name the scopes in the challenge.

  2. Pass it to serveMcpHttp

    return 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.

  3. 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 }),
    };
    }

    resource is the engine’s public /mcp URL, and it comes from configuration — never from Host, X-Forwarded-Host, or any other request header. Behind a reverse proxy this is the only value that makes the published document correct.

  4. Respect the validation rules

    The adapter rejects a configuration that cannot describe a real protected resource:

    • resource must be an HTTPS URL whose path is exactly /mcp, with loopback HTTP allowed only for local development;
    • resource cannot contain credentials, a query, or a fragment;
    • every entry in authorizationServers must 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_ISSUER doubles as the Authorization Server identifier, because the issuer that signs the tokens is the server the client must talk to.

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 Unauthorized
www-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 Unauthorized
www-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.

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.

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:

Terminal window
yarn build
AUTH_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.js

Read the published document, which needs no credential:

Terminal window
curl -s http://127.0.0.1:3000/.well-known/oauth-protected-resource/mcp

Then trigger the challenge an OAuth-capable client starts from:

Terminal window
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-authenticate

Point 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.

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:

Terminal window
npx @invokta/devtools serve dist/engine.js

In 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:

Terminal window
yarn workspace @invokta/example-auth-jwt-bearer deploy:inspect-oauth \
--url https://engine.example.com/mcp

invokta-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.

Terminal window
yarn workspace @invokta/example-auth-jwt-bearer test

The 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.