Authorization: OAuth with iss (RFC 9207), minimum scope, secret kept out of the repo
An HTTP server on the internet without a token is an open tool for anyone. In MCP, your server is an OAuth resource server: it doesn't issue tokens, it only checks them. In this lesson you protect the kit's server, see the 401 that starts the client's login, and give each token only the scope it needs.
1Who issues (authorization server) and who checks (your MCP server)
2401 with WWW-Authenticate and the RFC 9728 document
3Scope for the whole server and scope per tool (403 insufficient_scope)
4iss (RFC 9207) and credential bound to the issuer: what belongs to the client
Goal: protect the HTTP server with a token: 401 with metadata, minimum scope per tool, and secret kept out of the repository.
01 · From the 401 to the token with the right scope
Click each step.
Click each block in the diagram to see what it does.
In SDK v2 2.3.1, iss validation and the credential bound to the issuer are client-side options (finishAuth); the server is the resource server.
02 · The server with a gate
Rehearsal: two tokens come from environment variables. In production, swap the verifier for your IdP's JWT check.
File · kit/http-auth.mjs
// http-auth.mjs — the same server over HTTP, now as an OAuth "resource server":
// no token → 401 with WWW-Authenticate; token without the scope → 403 insufficient_scope.
// REHEARSAL: the tokens come from environment variables (never from the repository). In production,
// the token is issued by your authorization server (IdP) and here you verify the JWT
// signature (or do introspection, RFC 7662), checking exp and aud.
import { createServer as criarHttp } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import {
createMcpHandler, requireBearerAuth, oauthMetadataResponse,
getOAuthProtectedResourceMetadataUrl, OAuthError, OAuthErrorCode
} from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import { createServer } from './servidor.mjs';
const PORTA = Number(process.env.PORTA ?? 3334);
const URL_MCP = new URL(`http://127.0.0.1:${PORTA}/mcp`);
const TOKENS = {
[process.env.TOKEN_LEITURA ?? '']: { clientId: 'leitor', scopes: ['turmas:ler'] },
[process.env.TOKEN_RESERVA ?? '']: { clientId: 'atendente', scopes: ['turmas:ler', 'turmas:reservar'] }
};
if (!process.env.TOKEN_LEITURA || !process.env.TOKEN_RESERVA) {
console.error('defina TOKEN_LEITURA e TOKEN_RESERVA no ambiente'); process.exit(2);
}
const igual = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
const verifier = {
async verifyAccessToken(token) {
const chave = Object.keys(TOKENS).find(k => k && igual(k, token));
if (!chave) throw new OAuthError(OAuthErrorCode.InvalidToken, 'token desconhecido');
return { token, ...TOKENS[chave], expiresAt: Math.floor(Date.now() / 1000) + 3600, resource: URL_MCP };
}
};
const portao = requireBearerAuth({
verifier,
requiredScopes: ['turmas:ler'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(URL_MCP),
expectedResource: URL_MCP
});
// RFC 9728 document that the 401 points to: tells the client which authorization server to use.
const metadados = {
oauthMetadata: {
issuer: 'https://auth.exemplo.com',
authorization_endpoint: 'https://auth.exemplo.com/authorize',
token_endpoint: 'https://auth.exemplo.com/token',
response_types_supported: ['code'],
authorization_response_iss_parameter_supported: true
},
resourceServerUrl: URL_MCP,
scopesSupported: ['turmas:ler', 'turmas:reservar']
};
const mcp = createMcpHandler(createServer);
const comPortao = {
async fetch(request) {
const doc = oauthMetadataResponse(request, metadados);
if (doc) return doc;
const auth = await portao(request);
if (auth instanceof Response) return auth; // ready-made 401 or 403
return mcp.fetch(request, { authInfo: auth });
}
};
const node = toNodeHandler(comPortao);
const hostOk = localhostHostValidation();
const origemOk = localhostOriginValidation();
const http = criarHttp((req, res) => {
if (!hostOk(req, res) || !origemOk(req, res)) return;
void node(req, res);
});
http.listen(PORTA, '127.0.0.1', () => console.error(`turmas (com token): ${URL_MCP}`));
process.on('SIGTERM', async () => { await mcp.close(); http.close(); process.exit(0); });
Excerpt · scope per tool (kit/servidor.mjs)
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
// With a token (http-auth.mjs), requires the turmas:reservar scope; without a token (stdio), it does not apply.
scopeChallenge: requireScopes('turmas:reservar')
03 · No token, read token, reservation token
The block generates two random tokens just for this run and calls the server three times.
turmas (com token): http://127.0.0.1:3334/mcp
## sem token
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="turmas:ler", resource_metadata="http://127.0.0.1:3334/.well-known/oauth-protected-resource/mcp"
## metadados do recurso (RFC 9728)
{"resource":"http://127.0.0.1:3334/mcp","authorization_servers":["https://auth.exemplo.com"],"scopes_supported":["turmas:ler","turmas:reservar"]}
## token de leitura
buscar_turmas: ok
reservar_vaga: recusado (Insufficient scope: required "turmas:reservar")
## token de reserva
buscar_turmas: ok
reservar_vaga: reservado: html-01 para aluno@exemplo.com (restam 2)
The read token gets past the server's gate (scope turmas:ler) but cannot reserve: it lacks turmas:reservar. That is the minimum scope working per tool.
04 · Secrets outside the repository
1. Environment, not code
Token, signing key, and client secret come from an environment variable or a vault. The kit refuses to start without TOKEN_LEITURA and TOKEN_RESERVA.
2. .gitignore
Put the .env file in .gitignore before the first commit. Before publishing, search: git grep -n -i "token\|secret" -- ':!*.md'.
3. Audience
expectedResource makes the server accept only tokens issued for it. Without it, a token from another service on the same IdP would get through.
4. Minimum scope
One scope per type of action. Read and reserve separate; an agent that only looks things up gets read-only.
05 · Practice
The kit's gate and your service's gate.
E15 · Three calls
5 min
Run the tokens block.
Done when:HTTP/1.1 401 Unauthorized without a token; with the read token, reservar_vaga: recusado (Insufficient scope: required "turmas:reservar"); with the reservation token, reservar_vaga: reservado.
E16 · No variable, no server
2 min
Run node http-auth.mjs; echo "saída: $?" in a terminal without the variables.
Done when:defina TOKEN_LEITURA e TOKEN_RESERVA no ambiente e saída: 2.
E17 · Your service's scopes
15 min
In mapa-mcp.md, list one scope per tool that has consequences, and add scopeChallenge: requireScopes(...) to them.
Done when:grep -c "scopeChallenge:" servidor.mjs → equal to the number of tools with consequences.
06 · Lesson quiz
Question 1 of 4
In MCP OAuth, what is the role of your MCP server?skill
Correct
The authorization server (IdP) issues the token. The MCP server checks the signature, expiry, audience, and scopes.
A token with only turmas:ler tries reservar_vaga. What is the response?
Correct
The token is valid (it passes the server's gate), but the tool requires turmas:reservar. The SDK responds 403 before running the handler.
Not this one
The token is valid (it passes the server's gate), but the tool requires turmas:reservar. The SDK responds 403 before running the handler.Review: The server with a gate
Quiz complete
You got {a} of {n} right. Get them all right and the lesson is marked as complete.
Pick an option. The explanation appears after you try.
07 · In three sentences
RoleThe MCP server checks the token; the IdP issues it.
ScopeMinimal, per tool, with a 403 when it is missing.
SecretIn the environment, never in the repository; audience checked.