Junho 17, 2026

Intermediário

Dando a uma IA a sua própria porta de entrada no meu servidor: construindo um servidor MCP protegido por OAuth

Configurei algo no meu servidor que permite que um assistente de IA execute comandos reais nele. Agora posso perguntar, em inglês simples, "is the blog container healthy?" e ele realmente vai lá e verifica, em vez de eu abrir um terminal e digitar tudo manualmente.

Este post mostra exatamente como eu construí isso, com o código real. Ainda explicarei cada parte em linguagem simples primeiro, para que, mesmo que você nunca tenha ouvido falar de "nginx" ou "reverse proxy", você consiga acompanhar. Depois, mostrarei os arquivos reais rodando na minha máquina agora.

O modelo mental de 30 segundos

Quatro ideias, e tudo faz sentido:

  1. Um servidor é um computador que roda o tempo todo. O meu é uma VPS (Virtual Private Server) alugada.
  2. MCP (Model Context Protocol) é uma linguagem compartilhada que permite que uma IA fale com ferramentas externas de forma segura. Sem isso, a IA só pode conversar. Com isso, ela pode realmente fazer coisas.
  3. Um servidor MCP é um pequeno programa que escrevi e que fica entre a IA e minha máquina. A IA pede para ele fazer algo, e ele faz o trabalho. A IA nunca toca na máquina diretamente.
  4. Uma fechadura na porta para que apenas eu (através da IA) possa entrar. Ninguém mais na internet pode enviar comandos.

Aqui está o formato. Uma solicitação da IA chega pela internet, atinge um recepcionista (nginx) que verifica quem tem permissão para entrar, e é passada para o meu pequeno programa Node.js.

Claude (cloud)
     |  HTTPS
     v
[ nginx-proxy container ]  <- TLS, routing, IP allow-list
     |  plain HTTP, internal only
     v
[ ops-mcp Node.js process ]  <- the MCP server + its own OAuth server
     |
     v
   bash on the VPS

Um detalhe que vale a pena notar de antemão: meu servidor MCP não é um Docker container. Ele roda nativamente no host como um processo Node.js simples gerenciado pelo systemd. Todo o resto na minha máquina (o blog, o banco de dados, MinIO) roda em containers, mas este fala com o host diretamente porque seu trabalho principal é executar comandos do host.

Parte 1: O servidor em si (index.mjs)

Este único arquivo é todo o servidor MCP. Ele faz dois trabalhos ao mesmo tempo: é o servidor MCP que expõe uma ferramenta, e é seu próprio servidor de login OAuth 2.1. Isso parece muita coisa, mas o SDK oficial do MCP faz o trabalho pesado.

Um glossário rápido antes do código:

  • Express é uma pequena biblioteca Node.js para lidar com solicitações web.
  • OAuth 2.1 é o estilo de login "Entrar com o Google". Em vez de colar uma senha toda vez, você recebe um passe temporário (um token) que prova que você tem permissão.
  • Um bearer token é esse passe. "Bearer" significa apenas "quem possuir este token tem permissão".

Aqui está o bloco de configuração no topo. Note que ele se vincula ao 172.17.0.1, que é o endereço da bridge interna do Docker. Isso significa que o programa é acessível pelo container nginx, mas não é exposto diretamente à internet pública.

const PORT   = process.env.OPS_PORT || 8002;
const HOST   = process.env.OPS_HOST || "172.17.0.1"; // internal only, not public
const ISSUER = process.env.OPS_ISSUER || "https://mcp.mikkaiser.com";
const LOGIN_PASSWORD = process.env.OPS_LOGIN_PASSWORD; // the one password you type once
const ACCESS_TTL_MS  = 1000 * 60 * 60; // access tokens live 1 hour
const RESOURCE_URL   = new URL(`${ISSUER}/mcp`);

if (!LOGIN_PASSWORD || LOGIN_PASSWORD.length < 12) {
  console.error("Refusing to start: set OPS_LOGIN_PASSWORD (12+ chars).");
  process.exit(1);
}

O servidor mantém um pequeno arquivo de estado no disco para que ele se lembre de clientes registrados e tokens após reinicializações. Nenhum banco de dados é necessário.

const db = existsSync(STATE_FILE)
  ? JSON.parse(readFileSync(STATE_FILE, "utf8"))
  : { clients: {}, tokens: {}, refresh: {} };
const save = () => writeFileSync(STATE_FILE, JSON.stringify(db));
const rand = (n = 32) => randomBytes(n).toString("hex");

O fluxo de login

Quando a IA se conecta pela primeira vez, ela ainda não me conhece. Então, três coisas acontecem, todas tratadas automaticamente pelo SDK mais um pouco de código de integração:

  1. Dynamic Client Registration: a IA registra a si mesma e obtém um ID. Nenhuma configuração manual do meu lado.
  2. Authorize: sou enviado para uma página de senha única no meu navegador.
  3. Token exchange: assim que digito a senha, a IA recebe um passe que pode reutilizar.

A página de senha é literalmente renderizada por esta função. Ela é propositalmente básica:

async authorize(client, params, res) {
  const payload = Buffer.from(JSON.stringify({
    clientId: client.client_id,
    redirectUri: params.redirectUri,
    state: params.state,
    codeChallenge: params.codeChallenge,
  })).toString("base64url");

  res.send(`<!doctype html><meta name=viewport content="width=device-width">
    <body style="font-family:system-ui;max-width:340px;margin:80px auto">
    <h3>Authorize Ops MCP</h3>
    <form method="POST" action="/approve">
      <input type="hidden" name="p" value="${payload}">
      <input name="password" type="password" placeholder="password" autofocus
             style="width:100%;padding:8px;margin:8px 0">
      <button style="width:100%;padding:8px">Authorize</button>
    </form></body>`);
}

Quando envio esse formulário, este handler verifica a senha e, se ela corresponder, gera um código de autorização de curta duração e me redireciona de volta para a IA:

app.post("/approve", (req, res) => {
  let info;
  try { info = JSON.parse(Buffer.from(req.body.p, "base64url").toString()); }
  catch { return res.status(400).send("bad request"); }

  if (req.body.password !== LOGIN_PASSWORD) {
    audit({ event: "login_failed", ip: req.ip });
    return res.status(401).send("wrong password");
  }

  const code = rand(16);
  codes.set(code, {
    clientId: info.clientId,
    codeChallenge: info.codeChallenge,
    redirectUri: info.redirectUri,
    expiresAt: Date.now() + 60_000, // code is valid for 60 seconds
  });
  audit({ event: "authorized", clientId: info.clientId });

  const url = new URL(info.redirectUri);
  url.searchParams.set("code", code);
  if (info.state) url.searchParams.set("state", info.state);
  res.redirect(url.toString());
});

Depois disso, a IA mantém um refresh token e nunca mais me solicita nada. Digito a senha exatamente uma vez, para sempre.

A ferramenta real

Esta é a parte que faz o trabalho. Ela expõe uma única ferramenta chamada run_command. Cada chamada é escrita em um log de auditoria para que eu tenha um registro de tudo o que a IA executou.

server.registerTool(
  "run_command",
  {
    title: "Run shell command",
    description: "Run an arbitrary bash command on the VPS. Returns stdout, stderr and exit code.",
    inputSchema: { command: z.string().describe("The shell command to execute") },
  },
  async ({ command }) => {
    audit({ tool: "run_command", command });
    const result = await new Promise((resolve) =>
      exec(command,
        { timeout: CMD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, shell: "/bin/bash" },
        (err, stdout, stderr) => resolve({
          exitCode: err?.code ?? 0,
          timedOut: err?.killed === true,
          stdout: stdout?.toString() ?? "",
          stderr: stderr?.toString() ?? "",
        })));
    audit({ tool: "run_command", command, exitCode: result.exitCode });
    return {
      content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
      isError: result.exitCode !== 0,
    };
  }
);

Finalmente, a conexão. O endpoint MCP é envolvido em requireBearerAuth, que rejeita qualquer solicitação que não carregue um token válido:

app.post("/mcp",
  express.json({ limit: "10mb" }),
  requireBearerAuth({
    verifier: provider,
    resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(RESOURCE_URL),
  }),
  async (req, res) => {
    const server = buildServer();
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    res.on("close", () => { transport.close(); server.close(); });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  }
);

app.listen(PORT, HOST, () => {
  console.log(`Ops MCP (OAuth) on ${HOST}:${PORT}, public ${ISSUER}/mcp`);
});

As dependências são mínimas:

{
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "express": "^5.2.1",
    "zod": "^4.4.3"
  }
}

Parte 2: Mantendo vivo (systemd)

Eu não quero ter que cuidar desse processo manualmente. systemd é a parte do Linux que inicia programas na inicialização e os reinicia se eles travarem. Descrevo meu programa em um arquivo e o Linux cuida do resto.

[Unit]
Description=Ops MCP server (OAuth) for claude.ai
After=network.target docker.service

[Service]
Type=simple
User=root
WorkingDirectory=/opt/ops-mcp
ExecStart=/usr/bin/node /opt/ops-mcp/index.mjs
Restart=on-failure
RestartSec=3
Environment=OPS_PORT=8002
Environment=OPS_HOST=172.17.0.1
Environment=OPS_ISSUER=https://mcp.mikkaiser.com
Environment=OPS_LOGIN_PASSWORD=your-strong-password-here
Environment=OPS_AUDIT_LOG=/var/log/ops-mcp/audit.log
Environment=OPS_STATE_FILE=/opt/ops-mcp/state.json

[Install]
WantedBy=multi-user.target

Uma palavra sobre User=root: essa linha define o raio de explosão. Rodar como root significa que a IA tem acesso total e irrestrito à máquina. Essa é uma escolha deliberada de alta confiança para o meu próprio servidor. Se você quiser limitar o que a IA pode tocar, rode como um usuário com menos privilégios. Depois, habilite e inicie:

sudo systemctl daemon-reload
sudo systemctl enable --now ops-mcp
sudo systemctl status ops-mcp

Parte 3: O recepcionista (nginx)

Aqui está o problema que o nginx resolve. Meu servidor hospeda muitas coisas atrás de um único endereço público: o blog, o armazenamento MinIO e, agora, este servidor MCP. Quando uma solicitação chega, algo precisa decidir a qual projeto ela pertence.

Nginx (diga "engine-x") é esse recepcionista. Ele é um reverse proxy, que é um termo sofisticado para "uma recepção que encaminha cada visitante para a sala certa". Ele também lida com HTTPS (o cadeado no seu navegador) para que a conversa seja criptografada.

O ponto inteligente na minha configuração é que o nginx divide o subdomínio MCP em duas zonas com regras diferentes. As páginas de login devem ser acessíveis do meu navegador (qualquer IP), mas os endpoints sensíveis só devem ser acessíveis pelos servidores da Anthropic. Aqui está o vhost real:

# Redirect plain HTTP to HTTPS, with a passthrough for cert renewal
server {
    listen 80;
    server_name mcp.mikkaiser.com;

    location /.well-known/acme-challenge/ {
        proxy_pass http://host.docker.internal:8080;  # certbot
        proxy_set_header Host $host;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    http2 on;
    server_name mcp.mikkaiser.com;

    ssl_certificate     /etc/letsencrypt/live/mcp.mikkaiser.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mcp.mikkaiser.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Login pages: must be reachable from MY browser, so no IP restriction
    location ~ ^/(authorize|approve)$ {
        proxy_pass http://host.docker.internal:8002;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto https;
    }

    # Everything else (/mcp, /token, /register, discovery): Anthropic IPs ONLY
    location / {
        allow 160.79.104.0/21;
        deny all;

        proxy_pass http://host.docker.internal:8002;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto https;
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Duas coisas para destacar. Primeiro, host.docker.internal:8002 é como o container nginx (uma caixa) alcança o processo MCP rodando no host (fora da caixa). Segundo, aquele bloco allow ... deny all é uma parede rígida: o endpoint de comando real nem responderá a uma solicitação, a menos que ela venha da faixa de IP de saída publicada pela Anthropic.

Parte 4: O cadeado (Let's Encrypt)

O certificado HTTPS vem do Let's Encrypt, uma autoridade certificadora gratuita, via uma ferramenta chamada certbot. O bloco plain-HTTP acima encaminha silenciosamente solicitações /.well-known/acme-challenge/ para o certbot, para que ele possa provar que sou o dono do domínio e emitir (e posteriormente renovar automaticamente) o certificado.

sudo certbot --nginx -d mcp.mikkaiser.com

Juntando tudo

A configuração completa, do início ao fim, foi:

# 1. DNS: point mcp.mikkaiser.com -> <your server IP>

# 2. Install the code
sudo mkdir -p /opt/ops-mcp && cd /opt/ops-mcp
npm init -y
npm install @modelcontextprotocol/sdk express zod
sudo mkdir -p /var/log/ops-mcp

# 3. Drop in the systemd unit, then:
sudo systemctl daemon-reload
sudo systemctl enable --now ops-mcp

# 4. Add the nginx vhost, get the cert
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d mcp.mikkaiser.com

# 5. Verify OAuth discovery is public
curl -s https://mcp.mikkaiser.com/.well-known/oauth-protected-resource/mcp

Então, no Claude, adicionei um conector personalizado apontando para https://mcp.mikkaiser.com/mcp. No primeiro uso, ele me enviou para a página de senha uma vez. Desde então, ele manteve um refresh token e nunca mais perguntou.

Por que vale a pena

Antes disso, cada verificação no meu servidor significava abrir um terminal, conectar, lembrar o comando exato e ler a saída eu mesmo. Agora posso perguntar em inglês simples, até mesmo do meu celular, e o assistente passa pela porta da frente, mostra seu passe, executa o comando e lê o resultado de volta.

Se você remover o jargão, ainda são apenas as quatro ideias do início: um pequeno programa que faz o trabalho, um recepcionista que roteia o tráfego, uma fechadura para que apenas eu entre e uma IA que sabe o endereço e possui um passe. A diversão é conectá-los até que cooperem. E uma vez que o fazem, você obtém um servidor com o qual pode conversar como um colega.

Uma nota sobre segurança: este servidor pode executar qualquer comando como root. Essa é uma capacidade poderosa e arriscada. As proteções que me deixam confortável com isso são o login OAuth, a lista de permissão de IP fixada na Anthropic, o log de auditoria de cada comando e HTTPS em toda parte. Se você construir o seu, trate a senha de login como uma chave mestra e considere rodar como um usuário restrito.

Post Autor

Mikael Ribeiro

Mikael Ribeiro

Atualmente, sou professor de tecnologia da informação nos Emirados Árabes. Minha missão é capacitar a próxima geração de profissionais de software com as habilidades e o conhecimento necessários para se destacarem no cenário global.