MCP no puede subir archivos. Así pueden hacerlo los agentes de IA de todos modos

ai-agents

Escribir contenido a través de un servidor MCP funciona sorprendentemente bien. Le pides a tu agente una entrada del registro de cambios, lee las tres últimas, redacta una con el mismo tono y te pasa un enlace.

Luego le pides que añada la captura de pantalla que tienes en la carpeta de descargas, y se topa con un muro.

Las llamadas a herramientas de MCP llevan argumentos JSON. La especificación actual no tiene un tipo de archivo, ni un mensaje de subida, ni ninguna forma de que un cliente le pase bytes a un servidor. El grupo de trabajo File Uploads tiene un borrador (SEP-2631) que propone una, pero no está en la especificación y ningún cliente lo implementa todavía.

Así que, si quieres que un agente ponga imágenes en el contenido que escribe, tienes que diseñar la transferencia tú mismo. Yo acabo de hacerlo para el servidor MCP de Kookee. Esto es lo que probé, lo que lancé y los detalles que resultaron importantes.

Por qué las respuestas obvias no funcionan

Base64 en un argumento de herramienta. Los argumentos de las herramientas los genera el modelo, token a token. Una captura de 100 KB son unos 136 KB en base64, es decir, decenas de miles de tokens de salida antes de guardar nada. Peor aún: normalmente el modelo ni siquiera tiene los bytes. Puede ver una imagen que pegaste en la conversación, pero no puede reproducir sus bytes; se los estaría inventando.

«Mándame una URL y la descargo». La importación desde el servidor convierte tu API en un proxy que descarga lo que un agente le señale, incluida cualquier cosa que una inyección de prompt le convenza de pedir. Direcciones internas, endpoints de metadatos de la nube, redirecciones a ambos: una superficie de SSRF que luego defiendes con DNS pinning, límites de redirecciones y content sniffing. Y, de todos modos, el archivo de tu portátil no tiene URL.

Ampliar el límite del cuerpo de la petición. El límite del cuerpo en un endpoint MCP se aplica antes de la autenticación. Ampliarlo para las subidas lo amplía también para todas las peticiones anónimas.

El flujo que funciona: prefirmar, subir, confirmar

Los navegadores resolvieron esto hace años. Un panel no hace pasar una imagen por su servidor de API: el servidor firma una subida de un solo uso, el navegador envía los bytes directamente al almacenamiento de objetos y luego avisa al servidor de que ha terminado.

Un agente con shell es un navegador con curl. Así que la versión MCP son los mismos tres pasos:

Plain Text
create_upload  { fileName, mimeType, size }  →  fileId, POST prefirmado, comando curl listo para usar
curl … -F 'file=@"./diagram.png"'            →  204 del almacenamiento
confirm_upload { fileId, alt }               →  URL pública + un fragmento que incrusta el archivo

Los bytes nunca pasan por el servidor MCP. Las llamadas a herramientas se quedan en unos cientos de bytes, el límite del cuerpo sigue donde estaba, y las subidas acaban en el mismo almacenamiento, con las mismas cuotas y la misma limpieza que los archivos añadidos desde el panel.

El coste, dicho con honestidad: hace falta un agente que pueda ejecutar comandos. Claude Code, Cursor en modo agente y Codex CLI pueden. Una app de chat que no puede ejecutar comandos en tu máquina no puede subir archivos, así que la descripción de la herramienta le indica al agente que lo diga y mande al usuario al panel, en vez de improvisar.

Además, es hacia donde va el protocolo. El borrador SEP-2631 autoriza una subida, devuelve un destino prefirmado, hace que el cliente suba los bytes y le pasa a la herramienta una referencia al archivo. El mismo flujo, con el cliente ejecutando el curl en lugar del modelo. La parte del servidor que construyas hoy te seguirá sirviendo.

Cuatro detalles que deciden si es seguro

1. No te fíes de «hecho»

La llamada de confirmación de un navegador suele darse por buena: si dice que la subida terminó, el servidor se lo cree. Un agente confirmará igual de tranquilo después de un curl que falló, y luego incrustará una imagen que no existe.

Por eso confirmar consulta directamente al almacenamiento —un HeadObject sobre la clave— y se niega si no hay nada. El tamaño declarado también queda fijado en la política de subida. Es una cuestión de dinero: una política de POST prefirmado acepta cualquier tamaño hasta tu límite por archivo salvo que firmes un content-length-range exacto, mientras que las cuotas de almacenamiento suelen contar el tamaño declarado. Declara un byte, sube 25 MB, y tu cuota ni se entera. Firma el tamaño exacto, y el almacenamiento rechaza la diferencia con un 400.

2. El tipo de contenido que firmas es el que sirve tu CDN

Cada campo de un POST prefirmado se convierte en una condición de coincidencia exacta de la política, Content-Type incluido. Eso está bien —quien sube el archivo no puede cambiarlo— y significa que el tipo que firmaste es exactamente el que servirá tu CDN pública.

Así que el tipo MIME que envía el agente tiene que estar en una lista de permitidos antes de firmarlo. Si no, un agente, o una inyección de prompt que leyó hace tres llamadas a herramientas, puede publicar text/html en el origen de tu CDN. Kookee acepta exactamente los tipos que acepta el panel, y nada más.

3. Entrecomilla el comando generado como si fuera hostil

El servidor le da al agente un comando listo para ejecutar, porque pedirle a un modelo que monte un POST multipart a partir de un objeto JSON es buscarse un error sutil. Ese comando contiene un nombre de archivo que eligió el agente, firmado en un campo del formulario, y ahí están las trampas.

Con curl -F, un valor que empieza por @ o < no es un valor: es una instrucción para leer un archivo. Un nombre de archivo como @notes.png en un campo firmado haría que curl leyera un archivo del disco. Por eso cada campo firmado sale como --form-string, que siempre es literal, y la parte del archivo va al final, porque S3 ignora los campos que vienen después de ella:

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  ');

La ruta local es la otra mitad. Acaba dentro de las comillas simples del shell y dentro del nombre de archivo entre comillas dobles de curl, así que una ruta que contenga ' rompe el shell y una que contenga " o \ rompe curl. En vez de una tercera capa de escapado en la que el modelo se pueda equivocar, la regla es sencilla: si la ruta contiene uno de esos caracteres, copia el archivo a una ruta temporal sin caracteres raros y sube la copia.

Los nombres de archivo se validan en el handler, no solo en el esquema. Kookee rechaza barras y caracteres de control, incluido el rango C1, de U+0080 a U+009F, que una comprobación ingenua de «menor que 0x20» deja pasar sin más. Y mantén la regex del esquema en ASCII puro: el patrón se exporta al JSON Schema del cliente sin el flag u, así que los validadores del lado del cliente malinterpretan una clase Unicode como \p{Cc} y luego rechazan cualquier nombre que contenga una p o una c.

4. Haz que el agente vincule lo que sube

En esta caí yo.

En el editor, una imagen subida se inserta junto con su id de archivo, y el id es lo que le dice al sistema que una entrada usa ese archivo. Un archivo al que nada hace referencia se borra automáticamente a los pocos días. Así se limpian las subidas que la gente abandona a medias.

Dale una URL a un agente y escribirá lo que escribe cualquier modelo: ![alt](https://cdn…/diagram.png). Sin id. La imagen se ve bien hoy, el archivo nunca queda vinculado y tres días después desaparece de un artículo publicado. No hay ningún error en ninguna parte: pérdida de datos silenciosa y diferida.

Por eso confirm_upload devuelve un fragmento que lleva el id, y las herramientas de escritura rechazan el Markdown que introduce la URL de una subida sin él, con un error que le dice al agente que pegue el fragmento.

Mi primera versión de esa protección rechazaba todas las URL de la CDN de Kookee que llegaban sin id. Así también bloqueaba escrituras perfectamente seguras: duplicar o traducir un artículo cuya imagen se había pegado por URL, o un comentario que enlazaba a un PDF que otra entrada ya usaba. El agente recibía un error por un Markdown que había copiado sin cambios, sin ningún id que pudiera añadir. La solución fue buscar el archivo y rechazar solo si todavía no está vinculado a nada, que es el único caso que de verdad pierde datos.

La lección vale en general: una protección escrita para un agente debe apuntar al fallo, no a la forma de lo que recibe. Un agente no puede negociar con una regla más estricta que el problema.

Cómo se ve

Sube ./diagram.png, ponlo bajo el encabezado Arquitectura con un texto alternativo corto y usa ./cover.png como portada.

El agente llama dos veces a create_upload, ejecuta dos comandos curl, confirma ambos, inserta el diagrama con edit_entry_content y pone la portada con update_entry. El cuerpo contiene entonces un bloque como este. Al leer la entrada vuelve idéntico, byte a byte, y el texto de alrededor se edita como el de cualquier otro bloque:

HTML
<p><img src="https://cdn.kookee.dev/…/diagram.png" alt="Flujo de la petición del agente al almacenamiento" data-id="…"/></p>

Si vas a añadir subidas a tu propio servidor MCP

  • Prefirma. Nunca pases bytes de archivos por una llamada a herramienta de MCP.

  • Confirma preguntando al almacenamiento, y firma el tamaño declarado como un content-length-range exacto.

  • Pon los tipos MIME en una lista de permitidos antes de firmar; el tipo firmado es el que sirve tu CDN.

  • Envía los campos firmados con --form-string, pon la parte del archivo al final y dile al agente qué hacer con las rutas problemáticas.

  • Valida los nombres de archivo en el handler; mantén en ASCII los patrones del esquema.

  • Limita la frecuencia de las prefirmas. Las subidas que nunca se confirman suelen escapar a las cuotas de almacenamiento.

  • Devuelve un fragmento que vincule el archivo, y rechaza la forma sin vincular, pero solo para archivos que de verdad se perderían.

  • Indica en la descripción de la herramienta que subir archivos requiere un shell, para que los clientes que solo chatean se lo digan al usuario en vez de fallar a medias.


El servidor MCP de Kookee ya sube imágenes y archivos desde Claude Code, Cursor y otros agentes que pueden ejecutar comandos. Consulta Subir imágenes y archivos desde un asistente de IA para la configuración, o Cómo preparar el contenido de tu producto para los agentes de IA para el panorama general.