E13 · Weak server
5 minRun the weak block and note which four attacks got through.
Done when: passaram: 4 de 6 and saída: 1.
Lesson 5 · 20 min
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
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.
A request to the server passes through these gates, in this order. Click each one.
Checked against the code of @modelcontextprotocol/server 2.3.1 (dist/) and the security_best_practices page of revision 2026-07-28.
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.
cd ~/projetos/formacao-webmcp-seguranca-v72/kit node scripts/atacar-mcp.mjs fraco; echo "saída: $?"
== @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.
The differences are marked with seguro in the server file.
cd ~/projetos/formacao-webmcp-seguranca-v72/kit node scripts/atacar-mcp.mjs seguro; echo "saída: $?"
// 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)); } };
}== @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).
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.
expectedResource (audience), the Host and Origin guards, the server on 127.0.0.1, and the scope per tool. A local server that only your agent uses: stdio, which doesn't even open a port.
A random handle (randomBytes) tied to the token's user. The spec: "MUST NOT treat possession of a state handle as authentication".
Pass the client's token on to another API (token passthrough, forbidden by the spec) or keep a secret in a file in the repository. The client also keeps credentials per issuer and doesn't reuse them with another authorization server (a change in the 2026-07-28 version).
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.
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: $?"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.
Attack, defense, and your repository.
Run the weak block and note which four attacks got through.
Done when: passaram: 4 de 6 and saída: 1.
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.
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.
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
Question 2 of 4
Bruno sends the code for Ana's draft and confirms the booking. Which MCP 2026-07-28 rule does this break?
Question 3 of 4
Which of these does the SDK do without you configuring anything?
Question 4 of 4
Why does the kit's server ask only for mentorias:ler to get in?
Choose an answer. The explanation appears after you try.