O MCP não faz upload de arquivos. Veja como deixar agentes de IA fazerem mesmo assim

ai-agents

Escrever conteúdo por um servidor MCP funciona surpreendentemente bem. Você pede ao agente uma entrada para o registro de alterações, ele lê as três últimas, escreve uma no mesmo tom e devolve um link.

Aí você pede para ele adicionar a captura de tela que está na sua pasta de downloads, e ele bate num muro.

As chamadas de ferramenta do MCP levam argumentos em JSON. A especificação atual não tem tipo de arquivo, nem mensagem de upload, nem um jeito de o cliente entregar bytes ao servidor. O grupo de trabalho File Uploads tem um rascunho (SEP-2631) que propõe isso, mas ele não está na especificação e nenhum cliente o implementa ainda.

Então, se você quer que um agente coloque imagens no conteúdo que escreve, precisa projetar a transferência por conta própria. Acabei de fazer isso no servidor MCP do Kookee. A seguir: o que tentei, o que lancei e os detalhes que fizeram diferença.

Por que as respostas óbvias não funcionam

Base64 num argumento de ferramenta. Os argumentos de ferramenta são gerados pelo modelo, token por token. Uma captura de tela de 100 KB vira cerca de 136 KB em base64, o que dá dezenas de milhares de tokens de saída antes de qualquer coisa ser guardada. Pior: em geral o modelo nem tem os bytes. Ele vê uma imagem que você colou na conversa, mas não consegue reproduzir os bytes dela; estaria inventando.

“Me mande uma URL que eu busco.” Importar no servidor transforma a sua API num proxy que baixa qualquer coisa que um agente indicar, inclusive o que uma prompt injection convencer o agente a indicar. Endereços internos, endpoints de metadados da nuvem, redirecionamentos para os dois: uma superfície de SSRF que você depois defende com DNS pinning, limites de redirecionamento e content sniffing. E, de qualquer forma, o arquivo no seu notebook não tem URL.

Aumentar o limite do corpo da requisição. O limite de corpo num endpoint MCP é aplicado antes da autenticação. Aumentá-lo para uploads aumenta também para toda requisição anônima.

O formato que funciona: pré-assinar, enviar, confirmar

Os navegadores resolveram isso anos atrás. Um painel não passa a imagem pelo servidor da API: o servidor assina um upload de uso único, o navegador envia os bytes direto para o object storage e depois avisa o servidor que terminou.

Um agente com shell é um navegador com curl. Então a versão MCP tem os mesmos três passos:

Plain Text
create_upload  { fileName, mimeType, size }  →  fileId, POST pré-assinado, comando curl pronto
curl … -F 'file=@"./diagram.png"'            →  204 do storage
confirm_upload { fileId, alt }               →  URL pública + um snippet que incorpora o arquivo

Os bytes nunca passam pelo servidor MCP. As chamadas de ferramenta continuam com poucas centenas de bytes, o limite de corpo fica onde estava, e os uploads vão para o mesmo storage, com as mesmas cotas e a mesma limpeza dos arquivos adicionados pelo painel.

O custo, sendo honesto: é preciso um agente que execute comandos. Claude Code, Cursor no modo agente e Codex CLI executam. Um app de chat que não roda comandos na sua máquina não consegue enviar arquivos, então a descrição da ferramenta orienta o agente a dizer isso e mandar o usuário para o painel, em vez de improvisar.

É também para onde o protocolo está indo. O rascunho SEP-2631 autoriza um upload, devolve um destino pré-assinado, faz o cliente enviar os bytes e passa para a ferramenta uma referência ao arquivo. O mesmo formato, com o cliente rodando o curl no lugar do modelo. A metade do servidor que você construir hoje continua valendo.

Quatro detalhes que decidem se é seguro

1. Não confie no “pronto”

Em geral, o servidor confia na chamada de confirmação de um navegador: se ela diz que o upload terminou, ele acredita. Um agente confirma com a mesma tranquilidade depois de um curl que falhou, e aí incorpora uma imagem que não existe.

Por isso, a confirmação pergunta direto ao storage — um HeadObject na chave — e recusa quando não há nada lá. O tamanho declarado também fica fixado na política de upload. Isso é questão de dinheiro: uma política de POST pré-assinado aceita qualquer tamanho até o seu limite por arquivo, a menos que você assine um content-length-range exato, enquanto as cotas de storage costumam contar o tamanho declarado. Declare um byte, envie 25 MB, e a sua cota nunca percebe. Assine o tamanho exato, e o storage recusa a diferença com um 400.

2. O content type que você assina é o que a sua CDN serve

Cada campo de um POST pré-assinado vira uma condição de correspondência exata na política, inclusive Content-Type. Isso é bom — quem envia não consegue mudá-lo — e significa que o tipo que você assinou é exatamente o que a sua CDN pública vai servir.

Por isso, o tipo MIME que o agente envia precisa estar numa lista de permissão antes de você assiná-lo. Senão, um agente, ou uma prompt injection que ele leu três chamadas de ferramenta atrás, pode publicar text/html na origem da sua CDN. O Kookee aceita exatamente os tipos que o painel aceita, e nada mais.

3. Escape o comando gerado como se ele fosse hostil

O servidor entrega ao agente um comando pronto para rodar, porque pedir a um modelo que monte um POST multipart a partir de um objeto JSON é pedir um erro sutil. Esse comando contém um nome de arquivo escolhido pelo agente, assinado num campo do formulário, e é aí que estão as armadilhas.

Com curl -F, um valor que começa com @ ou < não é um valor: é uma instrução para ler um arquivo. Um nome de arquivo como @notes.png num campo assinado faria o curl ler um arquivo do disco. Por isso, todo campo assinado vai como --form-string, que é sempre literal, e a parte do arquivo vai por último, porque o S3 ignora os campos que vêm depois dela:

TypeScript
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;

const command = [
  `curl -sS --fail -o /dev/null -w '%{http_code}\\n' ${shellQuote(uploadUrl)}`,
  ...Object.entries(fields).map(([name, value]) => `--form-string ${shellQuote(`${name}=${value}`)}`),
  `-F 'file=@"<LOCAL_FILE_PATH>"'`,
].join(' \\\n  ');

O caminho local é a outra metade. Ele vai parar dentro das aspas simples do shell e dentro do nome de arquivo entre aspas duplas do curl, então um caminho com ' quebra o shell, e um com " ou \ quebra o curl. Em vez de uma terceira camada de escape para o modelo errar, a regra é simples: se o caminho tiver um desses caracteres, copie o arquivo para um caminho temporário simples e envie a cópia.

Os nomes de arquivo são validados no handler, não só no schema. O handler recusa barras e caracteres de controle, inclusive a faixa C1, de U+0080 a U+009F, que uma verificação ingênua de “abaixo de 0x20” deixa passar direto. E mantenha a regex do schema em ASCII puro: o padrão é exportado para o JSON Schema do cliente sem a flag u, então uma classe Unicode como \p{Cc} é mal interpretada pelos validadores do lado do cliente, que passam a rejeitar qualquer nome com p ou c.

4. Faça o agente vincular o que ele envia

Foi este que me pegou.

No editor, uma imagem enviada é inserida junto com o id do arquivo, e é pelo id que o sistema sabe que uma entrada usa o arquivo. Um arquivo que nada referencia é removido pela coleta de lixo depois de alguns dias. É isso que limpa os uploads que as pessoas abandonam no meio.

Dê uma URL a um agente e ele escreve o que todo modelo escreve: ![alt](https://cdn…/diagram.png). Sem id. A imagem aparece normalmente hoje, o arquivo nunca é vinculado e, três dias depois, some de um artigo publicado. Nenhum erro em lugar nenhum — perda de dados silenciosa e com atraso.

Por isso, confirm_upload devolve um snippet que leva o id, e as ferramentas de escrita recusam um markdown que traga a URL de um upload sem ele, com um erro que manda o agente colar o snippet.

A primeira versão dessa proteção recusava toda URL da CDN do Kookee que viesse sem id. Com isso, também bloqueava escritas perfeitamente seguras: duplicar ou traduzir um post cuja imagem tinha sido colada por URL, ou um comentário com link para um PDF que outra entrada já usava. O agente recebia um erro por um markdown que tinha copiado sem mudar nada, sem nenhum id que pudesse acrescentar. A correção foi consultar o arquivo e recusar só quando ele ainda não está vinculado a nada, que é o único caso em que dados se perdem de verdade.

A lição vale em geral: uma proteção escrita para um agente deve mirar a falha, não o formato da entrada. Um agente não consegue negociar com uma regra mais rígida que o problema.

Como fica na prática

Envie ./diagram.png, coloque a imagem abaixo do título Arquitetura com um texto alternativo curto e use ./cover.png como capa.

O agente chama create_upload duas vezes, roda dois comandos curl, confirma os dois, insere o diagrama com edit_entry_content e define a capa com update_entry. O corpo passa a ter um bloco como este, que volta igual byte por byte na leitura e pode ser editado ao redor como qualquer outro:

HTML
<p><img src="https://cdn.kookee.dev/…/diagram.png" alt="Fluxo da requisição do agente até o storage" data-id="…"/></p>

Se você está adicionando uploads ao seu servidor MCP

  • Pré-assine. Nunca passe bytes de arquivo por uma chamada de ferramenta MCP.

  • Confirme perguntando ao storage e assine o tamanho declarado como um content-length-range exato.

  • Use uma lista de permissão de tipos MIME antes de assinar; o tipo assinado é o que a sua CDN serve.

  • Envie os campos assinados com --form-string, ponha a parte do arquivo por último e diga ao agente o que fazer com caminhos problemáticos.

  • Valide os nomes de arquivo no handler; mantenha os padrões do schema em ASCII.

  • Limite a taxa de pré-assinaturas. Uploads que nunca são confirmados costumam escapar das cotas de storage.

  • Devolva um snippet que vincula o arquivo e recuse a forma sem vínculo — mas só para arquivos que de fato se perderiam.

  • Diga na descrição da ferramenta que o upload precisa de um shell, para que clientes só de chat avisem o usuário em vez de falhar no meio.


O servidor MCP do Kookee agora recebe imagens e arquivos do Claude Code, do Cursor e de outros agentes que executam comandos. Veja Enviar imagens e arquivos a partir de um assistente de IA para a configuração, ou Como preparar o conteúdo do seu produto para agentes de IA para o panorama completo.