Um MCP remoto para o blog: a arquitetura e o deploy no Cloudflare Workers
Paulo Roberto Bolsanello·23 Set 2026·13 min de leitura
Este blog roda em hospedagem compartilhada, PHP 7.2, sem SSH, só FTP. Publicar conteúdo por ali já era resolvido por uma skill do Claude Code que sobe um script PHP temporário via FTP, roda uma vez e apaga em seguida. Funciona bem no terminal, mas não existe terminal dentro do claude.ai web, onde há um Project dedicado a este blog. Para usar as mesmas ferramentas de lá, sem abrir um terminal, a solução foi um servidor MCP remoto. Este texto é sobre como ele foi construído e, principalmente, como foi o deploy no Cloudflare Workers.
A skill de terminal (publicar-conteudo) resolve um problema real: o blog não tem formulário de API, só telas HTML autenticadas por sessão de cookie, e a produção só é acessível por FTP. A skill sobe um script PHP temporário, chama os Repositories do próprio blog diretamente, confirma o resultado e apaga o script na sequência. Nunca publica direto, sempre cria como rascunho para revisão humana depois.
O limite dessa abordagem é o transporte. Um servidor MCP local conversa com o cliente por stdio, a entrada e saída padrão de um processo. Isso exige um processo rodando na mesma máquina, o que só existe dentro do Claude Code no terminal. O claude.ai web não inicia processo nenhum na máquina de ninguém, então as Skills de projeto e servidores stdio simplesmente não aparecem lá. Para usar as mesmas ferramentas de dentro de um Project no navegador, era preciso um servidor MCP remoto: HTTP público, com autenticação própria, que qualquer cliente MCP compatível (incluindo o claude.ai) consiga conectar pela rede.
A decisão: tirar o MCP de dentro do PHP
A primeira ideia óbvia seria implementar o protocolo MCP inteiro, com OAuth incluso, dentro do próprio PHP do blog. Foi descartada rápido. A hospedagem é compartilhada, travada em PHP 7.2, sem SSH e sem log agregado. Implementar um fluxo OAuth 2.1 completo (autorização, PKCE, emissão e revogação de token) e a camada de transporte HTTP do MCP nesse ambiente significa manter, sozinho, uma superfície de segurança grande demais para o tipo de hospedagem disponível.
A alternativa foi separar as duas responsabilidades. O protocolo MCP e o OAuth rodam em um lugar desenhado para isso: o Cloudflare Workers tem um template oficial (remote-mcp-github-oauth) que já resolve OAuth via GitHub, PKCE, cookies assinados e aprovação de client, testado e mantido pela própria Cloudflare. O blog continua fazendo só o que já sabe fazer bem: guardar posts e projetos em MySQL através dos Repositories existentes. Entre os dois, uma API JSON pequena e nova, só para esse propósito.
A arquitetura: Worker fino, regra de negócio no PHP
O desenho final tem duas peças com deploys completamente independentes:
claude.ai (Project) --OAuth via GitHub--> Worker (Cloudflare)
|
| fetch() + X-Blog-Api-Key
v
paulorb.dev/api/mcp/* (PHP, produção)
|
v
Repositories existentes --> MySQL
O Worker não guarda conteúdo nem lógica de negócio nenhuma. Cada tool MCP é, por baixo, uma chamada HTTP para um endpoint específico da API nova. Toda a regra (forçar rascunho, sanitizar HTML, gerar thumbnail, validar campo obrigatório) continua onde já estava, nos Repositories do PHP. Essa divisão importa por dois motivos. Primeiro, elimina duplicação: a mesma regra de "post sempre entra como draft" já existia na skill de terminal, e não precisou ser reescrita em TypeScript. Segundo, limita o que um bug no Worker pode causar: mesmo que ele tivesse uma falha, a pior coisa que conseguiria fazer é chamar uma rota da API com um payload malformado, que a API já valida e rejeita.
A API nova no blog: /api/mcp/*
O novo McpController (namespace App\Controllers\Api) se autentica no próprio construtor, seguindo o mesmo padrão já usado pelos controllers do admin, só que com uma trilha de autenticação separada da sessão por cookie:
public function __construct()
{
Auth::requireMcpApiKey();
$this->categories = new CategoryRepository();
$this->posts = new PostRepository();
$this->projects = new ProjectRepository();
$this->media = new MediaRepository();
}
Auth::requireMcpApiKey() lê o header X-Blog-Api-Key e compara com uma chave guardada só no .env de produção, usando hash_equals() em vez de === para não abrir brecha de timing attack. Sem sessão, sem cookie, sem Auth::check(): se o header não bater, a resposta é 401 e a tentativa fica registrada em log, sem gravar a chave errada em texto claro.
As rotas cobrem categorias, posts e projetos, sempre reaproveitando os mesmos Repositories que o admin já usa:
Rota
Função
GET /api/mcp/categories
lista categorias existentes
POST /api/mcp/categories
cria categoria (devolve a existente se já houver uma com o mesmo nome)
GET /api/mcp/posts
lista posts, com filtro por status, busca por título e paginação
GET /api/mcp/posts/{id}
busca um post completo, com conteúdo, categorias e tags
POST /api/mcp/posts
cria post novo
POST /api/mcp/posts/{id}
edita um post existente
POST /api/mcp/projects
cria projeto novo
POST /api/mcp/projects/{id}
edita um projeto existente
POST /api/mcp/media/from-url
baixa uma imagem de uma URL e registra na biblioteca de mídia
Duas regras se repetem em todo endpoint que grava dado. A primeira é o status forçado: createPost() e createProject() escrevem 'status' => 'draft' direto no array passado ao Repository, ignorando qualquer valor de status que venha no corpo da requisição. É a mesma trava que já existia na skill de terminal, só que antes só documentada em texto e agora validada no servidor: nenhum cliente MCP, nem o Worker, nem um agente mal configurado, consegue publicar direto.
A segunda é sobre edição. updatePost() e updateProject() aceitam atualização parcial (só os campos enviados mudam, o resto mantém o valor atual) e nunca tocam em slug nem em status, mesmo se o corpo mandar um valor diferente:
$this->posts->updateForAdmin($postId, [
'title' => $title,
// Nunca muda o slug numa edicao via MCP - evita quebrar um
// link ja publicado/compartilhado so porque o titulo mudou.
'slug' => $existing['slug'],
// ...
// Preserva o status atual sempre - editar nunca publica nem
// despublica, mesmo que o body mande um status diferente.
'status' => $existing['status'],
]);
Cada resposta, sucesso ou erro, passa por um único ponto de saída (jsonResponse()) que também grava uma linha de auditoria: timestamp, verbo, rota e se deu certo. Sem conteúdo sensível, só o suficiente para reconstruir o histórico de uso depois se precisar.
attach_image_from_url e o cuidado com SSRF
O endpoint mais delicado é /api/mcp/media/from-url, porque ele faz o servidor baixar uma URL escolhida por quem está do outro lado da conversa com o modelo. Sem cuidado, isso é uma porta clássica para SSRF (Server-Side Request Forgery): pedir para o próprio servidor buscar um endereço interno que, de fora, ninguém alcançaria, como 127.0.0.1 ou um endereço de metadados de nuvem.
A defesa fica isolada em App\Core\RemoteImageFetcher. Primeiro passo, resolver o hostname e recusar qualquer IP fora da faixa pública, com filter_var() e as flags FILTER_FLAG_NO_PRIV_RANGE e FILTER_FLAG_NO_RES_RANGE. Só https é aceito. Até aqui é a validação óbvia, mas ela sozinha tem um furo: nada impede que o mesmo hostname responda um IP público na hora da validação e um IP interno alguns milissegundos depois, na hora de baixar de verdade. É o ataque conhecido como DNS rebinding, e ele existe justamente porque a resolução de DNS e a conexão de rede normalmente são dois passos independentes.
A correção é fixar o IP já validado na própria conexão do cURL, em vez de deixar o cURL resolver o hostname de novo:
CURLOPT_RESOLVE força o cURL a usar o IP informado para aquele host e porta, mantendo o cabeçalho Host e o SNI do TLS com o hostname original, o que preserva a validação do certificado. O DNS não é consultado de novo no momento da conexão, então não existe janela entre validar e baixar.
Depois do download, mais três checagens antes de aceitar o arquivo: limite de tamanho conferido byte a byte durante o próprio download (não depois, via Content-Length, que pode mentir), mime type real dos bytes baixados via mime_content_type() contra uma lista de três valores aceitos (image/jpeg, image/png, image/webp), e permissão do arquivo final ajustada para 0644: o arquivo temporário criado por tempnam() nasce com permissão 0600, e sem esse ajuste a imagem baixada por essa rota podia não ser servida em produção, dependendo de como o processo web e o PHP-FPM estão mapeados na hospedagem compartilhada.
O Worker no Cloudflare: OAuth do GitHub e as tools
O Worker nasceu do template oficial remote-mcp-github-oauth da Cloudflare, num repositório próprio, paulodm145/paulorb-blog-mcp no GitHub, separado do blog. Ele usa três peças prontas do ecossistema: @modelcontextprotocol/sdk para o protocolo em si, agents/mcp (a classe McpAgent) para hospedar o servidor dentro de um Durable Object, o mecanismo do Cloudflare para manter estado persistente e consistente por instância, e @cloudflare/workers-oauth-provider, que implementa OAuth 2.1 com PKCE por cima do fluxo de login do GitHub.
Do template, só duas coisas foram escritas do zero: o cliente HTTP e o registro das tools. O cliente (src/blog-api.ts) é fino de propósito, uma função só (callBlogApi) que faz fetch() para https://paulorb.dev/api/mcp/* com o header X-Blog-Api-Key, e nunca lança exceção para um erro esperado da API:
export type BlogApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
async function callBlogApi<T>(
env: BlogApiEnv,
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<BlogApiResult<T>> {
let response: Response;
try {
response = await fetch(env.BLOG_API_BASE_URL + path, {
method,
headers: { "X-Blog-Api-Key": env.BLOG_API_KEY, /* ... */ },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (networkError) {
return { ok: false, error: `Falha de rede ao chamar a API do blog: ${String(networkError)}` };
}
// ...devolve { ok: false, error } pra qualquer status != 2xx,
// nunca deixa a excecao subir sem contexto.
}
Cada tool, registrada com server.tool(nome, descricao, schemaZod, handler), chama uma função desse cliente e devolve o resultado como conteúdo MCP, formatando erro como texto (isError: true) em vez de deixar uma exceção derrubar a chamada inteira. Ao todo são nove:
list_categories, create_category
list_posts, get_post, create_post, update_post
create_project, update_project
attach_image_from_url
A allowlist é o único controle de acesso, e cobre todas as nove, não só uma: init() confere se this.props.login (o username do GitHub devolvido pelo OAuth) está na lista de ALLOWED_GITHUB_USERNAMES, uma variável de ambiente do Worker, não hardcoded no código: é só o login público do GitHub, mas mantê-lo fora do TypeScript evita expor o username num commit e permite trocar quem tem acesso sem editar código, antes de registrar qualquer tool. Fora dessa lista, o connector autoriza, mas nenhuma ferramenta aparece. Faz sentido para o modelo de ameaça aqui: servidor de uso pessoal, ninguém além do dono do blog deveria conseguir usar.
O deploy: wrangler, KV namespace e secrets
O deploy no Cloudflare Workers usa a CLI wrangler, e a maior parte dos passos é ação que só quem tem a conta consegue fazer, não algo que um agente automatiza sozinho.
wrangler login abre o navegador para autorizar a CLI na conta Cloudflare. Depois disso, os comandos seguintes ficam autenticados na máquina, sem precisar repetir o login a cada um.
O OAuth Provider guarda estado (tokens emitidos, aprovações de client) num KV namespace, o armazenamento chave-valor do Cloudflare. Ele precisa existir antes do primeiro deploy:
npx wrangler kv namespace create OAUTH_KV
O comando devolve um id, que entra no wrangler.jsonc em kv_namespaces. O restante da configuração fixa o nome do Worker, o main (src/index.ts), a classe BlogMcp como Durable Object (com new_sqlite_classes, o backend de armazenamento por instância), e as variáveis não sensíveis (BLOG_API_BASE_URL, ALLOWED_GITHUB_USERNAMES):
Antes do deploy, falta o OAuth de verdade. Em github.com/settings/developers, um novo OAuth App aponta a Authorization callback URL para https://paulorb-blog-mcp.<subdominio>.workers.dev/callback. O subdomínio exato só existe depois do primeiro wrangler deploy, então o caminho prático é subir um placeholder, fazer o primeiro deploy, e editar a callback URL do GitHub App com a URL final, o GitHub permite trocar depois.
Nenhum segredo entra em código nem em wrangler.jsonc. Cada um vai para o Worker por wrangler secret put, criptografado at-rest pela própria Cloudflare:
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
openssl rand -hex 32 | npx wrangler secret put COOKIE_ENCRYPTION_KEY
echo "<a mesma chave configurada no .env de producao do blog>" | npx wrangler secret put BLOG_API_KEY
BLOG_API_KEY é o mesmo valor dos dois lados: o mesmo que a API PHP compara com hash_equals(). Gerado uma vez, nunca commitado em lugar nenhum, só existe como secret do Worker e como variável de ambiente no .env de produção do blog.
Com os secrets no lugar, wrangler dev local e o MCP Inspector (npx @modelcontextprotocol/inspector) confirmam o fluxo OAuth completo e cada tool chamando a API real de produção antes do deploy definitivo:
npm run deploy
O resultado é uma URL pública fixa, https://paulorb-blog-mcp.<subdominio>.workers.dev/mcp, servida por um Durable Object que a própria Cloudflare distribui e escala. Sem servidor para manter no ar, sem patch de sistema operacional, sem SSH: a mesma limitação que motivou tirar isso do PHP em primeiro lugar deixa de existir do lado do Worker.
Conectando no claude.ai e testando
Dentro do Project do blog em claude.ai, em Settings → Connectors → Add custom connector, a URL do Worker entra como conector, e a primeira autorização passa pelo mesmo fluxo OAuth do GitHub: login, aprovação, checagem da allowlist. Depois disso, as nove tools ficam disponíveis na conversa como qualquer outra ferramenta MCP.
O teste em produção seguiu a ordem natural: primeiro leitura (list_categories, list_posts), depois escrita reversível (create_category), por último as que gravam conteúdo (create_post, attach_image_from_url), sempre conferindo que tudo entrava como rascunho. O log de auditoria da API registrou a sequência real desse teste, linha a linha:
2026-09-22T13:52:16 OK POST /api/mcp/posts
2026-09-22T13:52:16 OK POST /api/mcp/posts/161
2026-09-22T13:52:17 OK POST /api/mcp/projects
2026-09-22T13:52:17 OK GET /api/mcp/categories
2026-09-22T13:52:17 OK POST /api/mcp/media/from-url
As linhas DENIED intercaladas nesse mesmo log são de propósito: parte do teste incluiu mandar a chave errada e pedir um id que não existe, só para confirmar que a API rejeita do jeito esperado em vez de vazar alguma exceção não tratada.
Este texto foi escrito e publicado como rascunho usando exatamente esse servidor: as chamadas de list_categories e list_posts que trouxeram os dados citados aqui vieram do mesmo paulorb-blog-mcp.workers.dev descrito nas seções acima. O código-fonte completo está no repositório paulodm145/paulorb-blog-mcp, com um README cobrindo as tools, as variáveis de ambiente e o passo a passo de deploy.
Referências
ANTHROPIC. Introducing the Model Context Protocol. Anthropic, 2024. Disponível em: https://www.anthropic.com/news/model-context-protocol. Acesso em: 23 set. 2026.
Se você vem do jQuery e quer modernizar seus projetos com Alpine.js, esse tutorial é pra você. Vamos mostrar como sair de ações comuns com jQuery e aplicá-las com Alpine.js, e no final, criar um mini projeto real com…
O Laravel é um dos frameworks de desenvolvimento web mais populares e poderosos em uso atualmente. Ele tem muitas características que tornam a programação web mais fácil e eficiente. Uma dessas características é o…