1. MCP Server as a Resource Server: what exactly we configure
In the previous lecture we configured the Auth Server—the component that issues tokens. Now we’ll work on the second side of this pair: the MCP server as a Resource Server, which accepts and verifies those tokens.
From an OAuth 2.1 perspective, your MCP server is a Resource Server. It holds “resources” (MCP tools, user data) and accepts requests with an access token in the Authorization: Bearer ... header. Before executing a tool, it must verify that the token is genuine, not expired, issued by a trusted authorization server (Auth Server), intended for this specific MCP server, and also has the required rights (scope).
It’s important to separate two levels:
- Transport layer—this is where HTTP headers and tokens are handled. There you:
- accept/parse Authorization: Bearer,
- return 401 Unauthorized with WWW-Authenticate: Bearer ... when the token is missing/invalid,
- build the user context when the token is valid.
- MCP SDK layer, which doesn’t need to know about JWT at all. It simply receives an “already authenticated” call and can use ctx.userId, ctx.scopes, etc., inside the handler.
Analogy: the MCP SDK is the cook in the kitchen, and the OAuth middleware is the bouncer at the door. The cook doesn’t check passports; they just prepare orders.
As our running example, we’ll continue with GiftGenius: an MCP server at http://localhost:3000 with the tool list_my_gifts, and an Auth Server (for example, Keycloak or a custom mini AS) at http://localhost:4000.
2. .well-known/oauth-protected-resource: the business card of your MCP resource
Why a resource needs .well-known
When ChatGPT (or MCP Jam) first hits your MCP server and gets a 401, it needs to figure out two things:
- where to obtain a token;
- what permissions this resource supports in general.
To avoid hard-coding this in clients, a discovery endpoint is used:
GET /.well-known/oauth-protected-resource
This endpoint returns JSON with Protected Resource Metadata per RFC 9728.
GiftGenius example:
{
"resource": "http://localhost:3000",
"authorization_servers": ["http://localhost:4000"],
"scopes_supported": ["gifts:read", "gifts:write"],
"bearer_methods_supported": ["header"]
}
OpenAI’s guides show almost the same example, just with HTTPS and real domains.
The client (ChatGPT/Jam) reads this document and:
- understands that the token must have the audience http://localhost:3000,
- knows which authorization_servers to use (issuer URL),
- sees the list of supported scopes (making it easier to build the consent screen and prompts).
Metadata fields explained
Summary of the main fields:
| Field | Purpose |
|---|---|
|
The canonical HTTPS/HTTP identifier of the MCP server. It must later match the token’s aud. |
|
A list of URLs to your authorization servers (Auth Server/issuer). The client will use them to fetch OAuth/OIDC metadata. |
|
An array of supported scopes; needed by the client for better UX and to request the correct token. |
|
How the token is sent: typically ["header"], i.e., Authorization: Bearer .... |
Additionally, some publish resource_documentation, jwks_uri, introspection_endpoint, etc., but for a basic scenario the first four are sufficient.
Critical point: resource must match what the Auth Server puts into the token’s aud. If they don’t match, the MCP client (and you) will reject the token.
Implementing .well-known in Next.js 16
Assume our MCP server runs inside a Next.js app (Apps SDK backend, port 3000). The simplest approach is to create a route handler at app/.well-known/oauth-protected-resource/route.ts:
// app/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const body = {
resource: "http://localhost:3000",
authorization_servers: ["http://localhost:4000"],
scopes_supported: ["gifts:read", "gifts:write"],
bearer_methods_supported: ["header"],
};
return NextResponse.json(body);
}
In production, resource must be the HTTPS URL of your MCP server’s production environment (for example, https://mcp.giftgenius.com), and it must match the aud in tokens from the IdP.
3. WWW-Authenticate and 401: how MCP tells the client “a token is required”
We’ve already created the resource’s “business card” at .well-known/oauth-protected-resource. Now let’s see how an MCP server hints to the client that it should go look at that card—via 401 and the WWW-Authenticate header.
Basic scenario: request without a token
Imagine ChatGPT calls the list_my_gifts tool for the first time. The network request looks something like:
GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000
There’s no token. The MCP server shouldn’t silently return 403 or some HTML page. Correct behavior for a protected resource in OAuth is to return 401 Unauthorized and use the WWW-Authenticate header to explain how to authenticate.
Example of a correct response:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource", scope="gifts:read"
Content-Type: application/json
{"error":"unauthorized","error_description":"Missing or invalid access token"}
Important details:
- the Bearer scheme says we want an OAuth Bearer token;
- the resource_metadata parameter points to .well-known/oauth-protected-resource;
- the scope parameter hints at the minimum required permission (for example, gifts:read).
MCP Jam and ChatGPT can read this header. Seeing it, they will:
- Fetch .well-known/oauth-protected-resource.
- Use authorization_servers to find the Auth Server and its OpenID/OAuth metadata.
- Run the Authorization Code + PKCE flow, open a login page for the user, and obtain a token.
In other words, WWW-Authenticate is the trigger—without it the client won’t even guess there’s OAuth here.
Middleware for 401 responses (Next.js)
Let’s write a small utility to use on all protected endpoints. First—a function that builds the response:
// lib/authResponses.ts
import { NextResponse } from "next/server";
export function unauthorized(scope?: string) {
const wwwAuth = [
`Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`,
scope ? `scope="${scope}"` : null,
]
.filter(Boolean)
.join(", ");
return new NextResponse(
JSON.stringify({
error: "unauthorized",
error_description: "Missing or invalid access token",
}),
{
status: 401,
headers: {
"WWW-Authenticate": wwwAuth,
"Content-Type": "application/json",
},
}
);
}
Any route (for example, our MCP endpoint) can now simply call return unauthorized("gifts:read"), and the client will receive a correct challenge. The unauthorized() function returns a NextResponse object (compatible with the standard Response). In later examples we’ll sometimes throw this object as an exception and, in route handlers, catch it as a Response to avoid duplicating the 401-response construction code in every route.
4. Receiving and verifying the Bearer token
Now for the interesting part: how to accept and verify a Bearer token.
Where to perform the verification
Your MCP transport is likely implemented either:
- in a Next.js route handler (app/mcp/route.ts) that accepts POST and delegates to the MCP SDK, or
- in an Express/Fastify server that listens on /mcp and forwards JSON to an MCP handler.
In all these options, the HTTP layer must:
- read Authorization from the header;
- return 401 via our unauthorized if it’s missing/invalid;
- on success—construct the context object (userId, scopes, roles) and pass it into the MCP SDK (via handler args/context).
The MCP SDK (for example, @modelcontextprotocol/sdk) doesn’t need to know what a JWT is. That’s your responsibility.
Verification options: JWT vs introspection
There are two main styles:
- Verify the JWT’s signature and claims locally using the Auth Server’s JWK keys.
- Call the authorization server’s /introspect endpoint and ask, “Is this token still valid? What scopes does it have?”
In this course we’ll assume the Auth Server issues JWTs and publishes a jwks_uri, and the MCP server verifies signatures and claims locally (faster and more autonomous).
The verifyAccessToken utility in TypeScript
We’ll use the popular jose library (ESM-friendly). We need a helper like this:
// lib/verifyAccessToken.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("http://localhost:4000/.well-known/jwks.json")
);
const EXPECTED_ISS = "http://localhost:4000";
const EXPECTED_AUD = "http://localhost:3000";
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: EXPECTED_ISS,
audience: EXPECTED_AUD,
});
return {
sub: String(payload.sub),
scopes: String(payload.scope || "").split(" ").filter(Boolean),
raw: payload,
};
}
In this helper we:
- download the Auth Server’s JWK keys via jwks_uri;
- verify the signature and standard claims (iss, aud);
- extract sub (user id) and scope (a space-separated string, hence split(" ")).
audience must match the resource from our .well-known/oauth-protected-resource, which ensures the token was issued for our MCP server.
Simple Authorization header check
Let’s create a small helper that extracts the token from the header and runs it through verifyAccessToken:
// lib/getUserFromRequest.ts
import type { NextRequest } from "next/server";
import { unauthorized } from "./authResponses";
import { verifyAccessToken } from "./verifyAccessToken";
export async function getUserFromRequest(req: NextRequest) {
const auth = req.headers.get("authorization") || "";
const [, token] = auth.split(" ");
if (!token) throw unauthorized("gifts:read");
try {
return await verifyAccessToken(token);
} catch {
throw unauthorized("gifts:read");
}
}
Note: here we throw unauthorized(...) (that is, a Response object) as an exception, so in the route handler we can concisely catch it and return it as the response.
5. audience and scope: binding a token to resources and actions
Audience (aud): who the token is issued for
The aud claim answers whether this token is intended for this resource. In our case:
- the Auth Server sets aud in the token to http://localhost:3000;
- our .well-known/oauth-protected-resource publishes resource: "http://localhost:3000";
- verifyAccessToken checks that this matches.
If the token is intended for a different resource (for example, https://api.other-app.com), your MCP server must reject it as “not addressed to me.”
A common mistake is forgetting to synchronize resource and aud, which makes everything look configured while ChatGPT keeps getting 401. We’ll return to this in the “Common mistakes” section.
Scopes: what exactly can be done
The token’s scope claim is the set of rights the user granted to the client. In our example:
- gifts:read — permission to read your gifts;
- gifts:write — permission to create/update gifts.
In .well-known/oauth-protected-resource, these values appear as scopes_supported so the client knows in advance what to request.
The authorization server’s discovery document (.well-known/openid-configuration) also publishes scopes_supported, but that’s the list of global IdP scopes (don’t confuse it with the resource server’s .well-known/oauth-protected-resource).
It’s important not to confuse these two lists: the resource’s scopes_supported describes the permissions your MCP server needs specifically, while the IdP’s scopes_supported is the provider’s global “catalog.” The client typically takes the intersection of the two.
At the MCP server level you need to:
- decide which scopes are required for each tool;
- verify on every tool call that the token contains those scopes.
Let’s write a helper:
// lib/requireScope.ts
import { unauthorized } from "./authResponses";
export function requireScope(
user: { scopes: string[] },
needed: string[]
) {
const hasAll = needed.every((s) => user.scopes.includes(s));
if (!hasAll) throw unauthorized(needed.join(" "));
}
Now you can call requireScope(user, ["gifts:read"]) before executing a tool.
6. Wiring it to MCP tools: from token to list_my_gifts
MCP route in Next.js
Assume we have an MCP server based on some SDK that can handle HTTP requests. From Next.js’s perspective, it might look like this:
// app/api/mcp/route.ts
import { NextRequest } from "next/server";
import { unauthorized } from "@/lib/authResponses";
import { getUserFromRequest } from "@/lib/getUserFromRequest";
import { mcpServer } from "@/lib/mcpServer";
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req);
const body = await req.json();
const result = await mcpServer.handle(body, { user });
return Response.json(result);
} catch (err) {
if (err instanceof Response) return err; // unauthorized(...)
console.error(err);
return unauthorized();
}
}
The important bits here are:
- we extract the user and scopes from the token (getUserFromRequest);
- we pass them into the MCP server via the context { user };
- if the token is missing/invalid, we return our 401 with WWW-Authenticate.
The exact API of your MCP SDK may differ, but the idea is the same everywhere: wrap the MCP call with middleware that already knows “who” is calling.
The list_my_gifts tool with a scope check
Now let’s look at the tool implementation itself. Suppose we use a TypeScript SDK for MCP, and we have something like:
// lib/mcpServer.ts (excerpt)
import { createMcpServer } from "@modelcontextprotocol/sdk";
import { requireScope } from "./requireScope";
export const mcpServer = createMcpServer<{ user: any }>();
mcpServer.registerTool(
"list_my_gifts",
{
title: "List my gifts",
description: "Shows your saved gift ideas.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
async (_input, ctx) => {
requireScope(ctx.user, ["gifts:read"]);
const gifts = await loadGiftsForUser(ctx.user.sub);
return {
content: [{ type: "text", text: `Found ${gifts.length} gifts` }],
structuredContent: { gifts },
};
}
);
We do three key steps:
- require gifts:read before running the main logic;
- use ctx.user.sub as the user identifier (from the token);
- return data for this user only.
This way your tool stops being a “generic API” and becomes personalized—bound to the identity from the Auth Server.
7. Flow recap: from 401 to a successful call
To solidify everything, here’s a mini-diagram of the flow your protected MCP server now implements.
sequenceDiagram
participant ChatGPT
participant MCP as MCP Server (3000)
participant AS as Auth Server (4000)
ChatGPT->>MCP: POST /api/mcp (no Authorization)
MCP-->>ChatGPT: 401 + WWW-Authenticate: Bearer resource_metadata=...
ChatGPT->>MCP: GET /.well-known/oauth-protected-resource
MCP-->>ChatGPT: { resource, authorization_servers, scopes_supported }
ChatGPT->>AS: GET /authorize?scope=gifts:read&resource=...
AS-->>ChatGPT: redirect with ?code=XYZ
ChatGPT->>AS: POST /token (code + code_verifier)
AS-->>ChatGPT: { access_token, scope, ... }
ChatGPT->>MCP: POST /api/mcp Authorization: Bearer token
MCP->>MCP: verify JWT (iss, aud, exp, scope)
MCP-->>ChatGPT: tool result for this user
Note the resource parameter in the requests to the Auth Server: it is copied into the token’s aud and must match the resource in .well-known/oauth-protected-resource.
8. A small practical check with curl
For peace of mind, you can make two requests manually.
First—attempt to call the MCP without a token:
curl -i http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
We expect to see status 401 and our WWW-Authenticate with resource_metadata and scope="gifts:read".
Second—with a valid token (obtained from the Auth Server):
curl -i http://localhost:3000/api/mcp \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
Now, if abc123 is a valid JWT with the right iss, aud="http://localhost:3000" and scope includes gifts:read, you’ll get the tool’s JSON response, with the current user’s gifts in structuredContent.gifts.
9. Common mistakes when configuring MCP Server as a protected resource
Below is a set of pitfalls that often occur when implementing what we’ve just covered: .well-known, WWW-Authenticate, token verification, and scope checks.
Mistake #1: unsynchronized resource and audience.
A frequent case is publishing one resource value in .well-known/oauth-protected-resource while the Auth Server issues a different aud in tokens. As a result, jwtVerify drops the token even if the signature and lifetime are fine. It’s especially easy to break when you change the MCP server’s domain/port and forget to update either .well-known or the Auth Server config. In our example, it’s the same string http://localhost:3000 in the resource field of .well-known and in EXPECTED_AUD inside verifyAccessToken. It’s worth defining a single RESOURCE_ID constant and using it in both places to avoid divergence.
Mistake #2: missing WWW-Authenticate on 401.
Developers sometimes return 401 or 403 without the WWW-Authenticate header. From a browser’s point of view that might be okay, but ChatGPT and MCP Jam won’t know where to get a token or which scopes are required. They’ll consider your MCP server “broken” and won’t show the linking UI to the user. The minimum required is WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource". It’s even better to include scope="..." to make the flow clearer. Our unauthorized() helper ensures this header is always present on 401.
Mistake #3: trusting the token without verifying the signature and iss.
Especially early on, it’s tempting: “It’s a token from my Auth Server—let’s just JSON.parse(atob(..)) and that’s it.” Don’t do this: you’d then accept any token with the expected shape, even a forged one. The correct approach is to load keys via jwks_uri and verify the signature and iss/aud with a library (jose, jsonwebtoken, etc.). Only after that should you trust claim contents.
Mistake #4: mixing token verification with business logic.
Sometimes token checks get smeared across tool code: one tool checks scope, another doesn’t; somewhere you forget to check aud; elsewhere you even accept a user id from a tool argument. This leads to strange bugs and potential vulnerabilities. Keep a clear separation: the HTTP middleware handles the token (signature, iss, aud, lifetime), and inside the tool you rely on ctx.user as the “truth,” augmenting it only with business checks (for example, role/tenant).
Mistake #5: scopes_supported doesn’t match the scopes you actually use.
Another common case: you publish one set of scopes in .well-known/oauth-protected-resource, the Auth Server uses another, and your tools check a third. ChatGPT/MCP Jam form the authorization request based on the published scopes_supported, and then your server complains that the needed scope is missing. Try to minimize the number of scopes and treat them as a single source of truth—for example, via a TypeScript enum used both to generate .well-known and to configure clients in the Auth Server.
Mistake #6: relying only on Apps SDK securitySchemes and forgetting server-side checks.
Apps SDK lets you describe securitySchemes for tools (noauth, oauth2, scopes), and ChatGPT will show the correct UX. But those annotations don’t automatically make your server secure. Even if a tool is declared as requiring an OAuth token, your MCP server must still verify the token, issuer, audience, and scopes on every request. Otherwise one could bypass checks by hitting the MCP URL directly.
Mistake #7: forgetting about short token lifetimes and expiration handling.
If access tokens live too long, you reduce security; if they’re too short-lived but your server can’t gracefully handle expiration, users will constantly hit errors. The right model is a short-lived access token plus readiness for the MCP server to return 401 with WWW-Authenticate when exp is in the past. The client (ChatGPT) will then repeat the OAuth flow and refresh the token.
GO TO FULL VERSION