An MCP Server for Your Service|INEMA.CLUB-PRO

In this lesson you will

  1. 1Who issues (authorization server) and who checks (your MCP server)
  2. 2401 with WWW-Authenticate and the RFC 9728 document
  3. 3Scope for the whole server and scope per tool (403 insufficient_scope)
  4. 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.

01From 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.

02The 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')

03No token, read token, reservation token

The block generates two random tokens just for this run and calls the server three times.

Terminal · tokens
cd ~/projetos/formacao-webmcp-mcp-v72/kit
export TOKEN_LEITURA=$(openssl rand -hex 16) TOKEN_RESERVA=$(openssl rand -hex 16)
node http-auth.mjs & PID=$!; sleep 1
echo "## sem token"
curl -s -o /dev/null -D - -X POST http://127.0.0.1:3334/mcp \
  -H 'Content-Type: application/json' -d '{}' | grep -i -e '^HTTP' -e '^www-authenticate'
echo "## metadados do recurso (RFC 9728)"
curl -s http://127.0.0.1:3334/.well-known/oauth-protected-resource/mcp; echo
echo "## token de leitura"; TOKEN=$TOKEN_LEITURA node teste-auth.mjs
echo "## token de reserva"; TOKEN=$TOKEN_RESERVA node teste-auth.mjs
kill $PID; wait $PID 2>/dev/null
File · kit/teste-auth.mjs
// teste-auth.mjs — calls the server with a token (http-auth.mjs already running) and shows what each token can do.
// Usage: TOKEN=<token> node teste-auth.mjs
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const URL_MCP = new URL(`http://127.0.0.1:${process.env.PORTA ?? 3334}/mcp`);
const cliente = new Client({ name: 'teste-auth', version: '1.0.0' },
  { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } });
cliente.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirmar: true } }));
await cliente.connect(new StreamableHTTPClientTransport(URL_MCP,
  { requestInit: { headers: { Authorization: `Bearer ${process.env.TOKEN}` } } }));

const busca = await cliente.callTool({ name: 'buscar_turmas', arguments: { termo: 'html' } });
console.log(`buscar_turmas: ${busca.isError ? 'erro' : 'ok'}`);
try {
  const r = await cliente.callTool({ name: 'reservar_vaga', arguments: { turma_id: 'html-01', email: 'aluno@exemplo.com' } });
  console.log(`reservar_vaga: ${r.content[0].text}`);
} catch (e) {
  console.log(`reservar_vaga: recusado (${String(e.message).slice(0, 90)})`);
}
await cliente.close();
Example from the repository
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.

04Secrets 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.

05Practice

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.

06Lesson quiz

Question 1 of 4

In MCP OAuth, what is the role of your MCP server?skill

07In 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.

Going further

Previous lessonNext lesson