Security and Trust Between Site and Agent|INEMA.CLUB-PRO

Lesson 5 · 20 min

Secure MCP server: audience, minimum scope, handle tied to the user, and secret kept out of the repo

You built the server's OAuth in course 1. Here is the other side: six attacks from the MCP security list (revision 2026-07-28) against the same server, written two ways. You see what the official SDK blocks on its own and what it only blocks if you configure it.

Start the lesson
A small server in a rack with a physical padlock and a network cable plugged in, on a dark desk under neutral light.

In this lesson you will

  1. 1Host and Origin: DNS rebinding against a local server
  2. 2Token only in the header, with an expiry and for this server (audience)
  3. 3Minimum scope at the door, and the action's scope only when it is requested
  4. 4No session in MCP 2026-07-28: the state handle is not a login

Goal: run 6 attacks against a weak MCP server and a secure one, and separate what the SDK blocks on its own from what depends on your configuration.

01The five gates of a request

A request to the server passes through these gates, in this order. Click each one.

Click each block in the diagram to see what it does.

Checked against the code of @modelcontextprotocol/server 2.3.1 (dist/) and the security_best_practices page of revision 2026-07-28.

02Six attacks, weak server

The same server with the common mistakes: it accepts a token in the URL, does not check the audience, does not ask for a scope on the action, and uses a sequential handle with no owner. Tokens are generated on the fly; nothing in the repository.

Terminal · weak
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/atacar-mcp.mjs fraco; echo "saída: $?"
Example from the repository
== @modelcontextprotocol/server 2.3.1 · MCP 2026-07-28 · servidor: fraco
bloqueado 1 · Host de fora (DNS rebinding) → HTTP 401 (o token barrou; servidor sem token cairia)
PASSOU    2 · token na query string → aceito
PASSOU    3 · token de outro recurso → aceito
PASSOU    4 · escopo de leitura confirma agendamento → agendado: m-101 para ana
PASSOU    5 · handle de outro usuário → agendado: m-102 para ana
bloqueado 6 · token vencido → invalid_token: Token has expired
passaram: 4 de 6
saída: 1

Attack 1 stopped at the token: a local server with no token would fall. The SDK always blocks 6. The four in the middle depend on you.

03The same six, secure server

The differences are marked with seguro in the server file.

Terminal · secure
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/atacar-mcp.mjs seguro; echo "saída: $?"
File · kit/mcp/servidor.mjs
// servidor.mjs — MCP server over HTTP (revision 2026-07-28, official SDK v2 2.3.1) in two ways:
//   seguro → as lesson 5 says;   fraco → common mistakes, so the attacks have something to hit.
// Fictional mentoring service, data in memory. It calls no model or paid service.
// The tokens are NOT stored here: the caller passes the token table (in the kit, generated on the fly with crypto.randomBytes).
import { createServer as criarHttp } from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { McpServer, createMcpHandler, requireBearerAuth, requireScopes, getOAuthProtectedResourceMetadataUrl, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import * as z from 'zod/v4';

const MENTORIAS = [
  { id: 'm-101', titulo: 'Revisar as ferramentas da sua página', vagas: 2 },
  { id: 'm-102', titulo: 'Servidor MCP com token', vagas: 1 }
];

export function criarServidorMcp({ seguro }) {
  const rascunhos = new Map();       // handle → { dono, mentoria }
  let seq = 0;
  // seguro: random, unpredictable handle; fraco: sequential (r1, r2…), easy to guess
  const novoHandle = () => seguro ? randomBytes(16).toString('hex') : `r${++seq}`;
  const usuario = ctx => ctx.http?.authInfo?.extra?.usuario;       // comes from the verified token, never from the client

  return function criar() {
    const server = new McpServer({ name: 'mentorias', version: '1.0.0' });

    server.registerTool('ver_mentorias', {
      description: 'Lista as mentorias com vaga. Só lê.',
      inputSchema: z.object({}),
      annotations: { readOnlyHint: true }
    }, async () => ({ content: [{ type: 'text', text: MENTORIAS.map(m => `${m.id} · ${m.titulo} · ${m.vagas} vagas`).join('\n') }] }));

    server.registerTool('criar_rascunho', {
      description: 'Começa um agendamento e devolve o código do rascunho, que vai nas próximas chamadas.',
      inputSchema: z.object({ mentoria: z.string().regex(/^m-[0-9]{3}$/) }),
      annotations: { readOnlyHint: false }
    }, async ({ mentoria }, ctx) => {
      const h = novoHandle();
      rascunhos.set(h, { dono: usuario(ctx), mentoria });
      return { content: [{ type: 'text', text: h }] };
    });

    server.registerTool('ver_rascunho', {
      description: 'Mostra um rascunho de agendamento pelo código.',
      inputSchema: z.object({ rascunho: z.string().max(64) }),
      annotations: { readOnlyHint: true }
    }, async ({ rascunho }, ctx) => {
      const r = rascunhos.get(rascunho);
      // MCP 2026-07-28 has no session: having the handle is NOT being authenticated. Check the owner.
      if (!r || (seguro && r.dono !== usuario(ctx))) return { content: [{ type: 'text', text: 'rascunho não encontrado' }], isError: true };
      return { content: [{ type: 'text', text: `rascunho de ${r.dono} para ${r.mentoria}` }] };
    });

    server.registerTool('confirmar_agendamento', {
      description: 'Confirma o agendamento de um rascunho. Tem consequência: ocupa uma vaga.',
      inputSchema: z.object({ rascunho: z.string().max(64) }),
      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
      // seguro: this scope only appears when the action is requested (step-up); fraco: any token works
      ...(seguro && { scopeChallenge: requireScopes('mentorias:agendar') })
    }, async ({ rascunho }, ctx) => {
      const r = rascunhos.get(rascunho);
      if (!r || (seguro && r.dono !== usuario(ctx))) return { content: [{ type: 'text', text: 'rascunho não encontrado' }], isError: true };
      rascunhos.delete(rascunho);
      return { content: [{ type: 'text', text: `agendado: ${r.mentoria} para ${r.dono}` }] };
    });

    return server;
  };
}

// Starts the HTTP server on 127.0.0.1. tokens = { <token>: { usuario, escopos, recurso?, expira? } }
export async function subir({ seguro, tokens, porta = 0 }) {
  const http = criarHttp();
  await new Promise(ok => http.listen(porta, '127.0.0.1', ok));
  const URL_MCP = new URL(`http://127.0.0.1:${http.address().port}/mcp`);
  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 => igual(k, token));
      if (!chave) throw new OAuthError(OAuthErrorCode.InvalidToken, 'token desconhecido');
      const t = tokens[chave];
      return { token, clientId: t.usuario, scopes: t.escopos, expiresAt: t.expira ?? Math.floor(Date.now() / 1000) + 600,
        resource: new URL(t.recurso ?? URL_MCP), extra: { usuario: t.usuario } };
    }
  };
  const portao = requireBearerAuth({
    verifier,
    requiredScopes: ['mentorias:ler'],                                    // minimum scope to get in
    resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(URL_MCP),
    ...(seguro && { expectedResource: URL_MCP })                          // seguro: the token must be FOR this server
  });
  const mcp = createMcpHandler(criarServidorMcp({ seguro }));
  const app = toNodeHandler({
    async fetch(request) {
      if (!seguro) {
        // COMMON MISTAKE: accepting the token in the URL (it ends up in logs, history and Referer). The spec forbids it.
        const t = new URL(request.url).searchParams.get('access_token');
        if (t && !request.headers.get('authorization')) {
          const h = new Headers(request.headers); h.set('authorization', `Bearer ${t}`);
          request = new Request(request, { headers: h });
        }
      }
      const auth = await portao(request);
      if (auth instanceof Response) return auth;                            // 401 / 403 with WWW-Authenticate
      return mcp.fetch(request, { authInfo: auth });
    }
  });
  const hostOk = localhostHostValidation(), origemOk = localhostOriginValidation();
  http.on('request', (req, res) => {
    // seguro: refuses an outside Host/Origin (DNS rebinding) BEFORE anything else
    if (seguro && (!hostOk(req, res) || !origemOk(req, res))) return;
    void app(req, res);
  });
  return { url: URL_MCP, fechar: async () => { await mcp.close?.(); await new Promise(f => http.close(f)); } };
}
Example from the repository
== @modelcontextprotocol/server 2.3.1 · MCP 2026-07-28 · servidor: seguro
bloqueado 1 · Host de fora (DNS rebinding) → HTTP 403 (Host recusado antes do token)
bloqueado 2 · token na query string → invalid_token: Missing Authorization header
bloqueado 3 · token de outro recurso → invalid_token: Token was not issued for this resource
bloqueado 4 · escopo de leitura confirma agendamento → Insufficient scope: required "mentorias:agendar"
bloqueado 5 · handle de outro usuário → rascunho não encontrado
bloqueado 6 · token vencido → invalid_token: Token has expired
passaram: 0 de 6
saída: 0

Each refusal comes from a different place: Host (the Node adapter's guard), token and audience (the SDK, with expectedResource), scope (requireScopes), handle (your code).

04Who owns what

1. The SDK does

Reads the token from the header only, rejects a token that has no expiry or has expired, and responds 403 with the missing scope when the tool declares requireScopes.

05Secrets outside the repository

A minimal checker (it does not replace gitleaks or GitHub's secret scanning). The second command plants a fake key in a rehearsal folder so you can see it get flagged.

Terminal · secrets
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/segredos.mjs .; echo "saída: $?"
E=${TMPDIR:-/tmp}/ensaio-seguranca/vazou; mkdir -p $E
printf "export const API_KEY = '%s';\n" $(openssl rand -hex 16) > $E/config.mjs
node scripts/segredos.mjs $E; echo "saída: $?"
Example from the repository
70 arquivos lidos · 0 segredos
saída: 0
SEGREDO config.mjs:1: segredo atribuído no código (API_KEY = 'a…)
1 arquivo lido · 1 segredo
saída: 1

The kit passes clean; the rehearsal folder flags the key written in the code.

06Practice

Attack, defense, and your repository.

E13 · Weak server

5 min

Run the weak block and note which four attacks got through.

Done when: passaram: 4 de 6 and saída: 1.

E14 · Secure server

10 min

Run the secure block. Then, in mcp/servidor.mjs, remove expectedResource from secure mode and run it again. Undo it at the end.

Done when: passaram: 0 de 6; without expectedResource, PASSOU 3 · token de outro recurso.

E15 · Your repository

10 min

Run the checker on the repository of your server or site.

Done when: node scripts/segredos.mjs [your repository folder] → 0 segredos and saída: 0.

07Lesson quiz

Question 1 of 4

A token issued for another service from the same provider reaches your server. In SDK 2.3.1, what makes it get rejected?skill

08In three sentences

SDKHeader, expiry and declared scope: it handles those.
YouAudience, Host/Origin, a handle with an owner, and no passthrough.
SecretIn the environment, never in the repository; check before you publish.

Going further

Previous lessonNext lesson