Inhalte über einen MCP-Server zu schreiben, funktioniert erstaunlich gut. Du bittest deinen Agenten um einen Changelog-Eintrag, er liest die letzten drei, entwirft einen im selben Ton und gibt dir einen Link.
Dann bittest du ihn, den Screenshot aus deinem Downloads-Ordner einzufügen, und er läuft gegen eine Wand.
MCP-Tool-Aufrufe transportieren JSON-Argumente. Die aktuelle Spezifikation kennt keinen Dateityp, keine Upload-Nachricht und keinen Weg, auf dem ein Client einem Server Bytes übergibt. Die File Uploads Working Group hat einen Entwurf (SEP-2631), der so etwas vorschlägt, aber er steht nicht in der Spezifikation, und noch kein Client setzt ihn um.
Wenn ein Agent also Bilder in die Inhalte bringen soll, die er schreibt, musst du die Übertragung selbst entwerfen. Ich habe das gerade für den MCP-Server von Kookee getan. Hier steht, was ich ausprobiert und was ich gebaut habe und welche Details sich als wichtig herausgestellt haben.
Warum die naheliegenden Lösungen nicht funktionieren
Base64 in einem Tool-Argument. Tool-Argumente erzeugt das Modell Token für Token. Ein Screenshot mit 100 KB ergibt etwa 136 KB Base64, also Zehntausende Output-Tokens, bevor überhaupt etwas gespeichert ist. Schlimmer noch: Meist hat das Modell die Bytes gar nicht. Ein Bild, das du in die Unterhaltung eingefügt hast, kann es sehen, aber die Bytes dieses Bildes kann es nicht wiedergeben, es würde sie erfinden.
„Schick mir eine URL, ich hole die Datei.“ Ein serverseitiger Import macht deine API zu einem Proxy, der alles herunterlädt, worauf ein Agent zeigt, auch alles, wozu eine Prompt Injection ihn überredet. Interne Adressen, Metadaten-Endpunkte der Cloud, Weiterleitungen auf beides: eine SSRF-Angriffsfläche, die du dann mit DNS-Pinning, Redirect-Limits und Content-Sniffing verteidigst. Und die Datei auf deinem Laptop hat ohnehin keine URL.
Das Limit für den Request-Body anheben. Das Body-Limit eines MCP-Endpunkts greift vor der Authentifizierung. Hebst du es für Uploads an, hebst du es auch für jede anonyme Anfrage an.
Das Muster, das funktioniert: Presign, Upload, Bestätigung
Browser haben das vor Jahren gelöst. Ein Dashboard streamt ein Bild nicht durch seinen API-Server: Der Server signiert einen einmaligen Upload, der Browser schickt die Bytes per POST direkt an den Object Storage und meldet dem Server danach, dass er fertig ist.
Ein Agent mit Shell ist ein Browser mit curl. Die MCP-Version besteht also aus denselben drei Schritten:
create_upload { fileName, mimeType, size } → fileId, presigned POST, fertiger curl-Befehl
curl … -F 'file=@"./diagram.png"' → 204 vom Storage
confirm_upload { fileId, alt } → öffentliche URL + Snippet, das die Datei einbettet
Die Bytes laufen nie durch den MCP-Server. Tool-Aufrufe bleiben bei ein paar hundert Bytes, das Body-Limit bleibt, wo es war, und Uploads landen im selben Storage, mit denselben Kontingenten und derselben Bereinigung wie Dateien, die im Dashboard hinzugefügt werden.
Der ehrliche Preis: Es braucht einen Agenten, der Befehle ausführen kann. Claude Code, Cursor im Agent-Modus und Codex CLI können das. Eine Chat-App, die auf deinem Rechner keine Befehle ausführen kann, kann auch nichts hochladen. Deshalb weist die Tool-Beschreibung den Agenten an, das offen zu sagen und den Nutzer ins Dashboard zu schicken, statt zu improvisieren.
In diese Richtung entwickelt sich auch das Protokoll. Der Entwurf SEP-2631 autorisiert einen Upload, gibt ein vorsigniertes Ziel zurück, lässt den Client die Bytes hochladen und übergibt dem Tool eine Referenz auf die Datei. Dasselbe Muster, nur führt der Client das curl aus statt des Modells. Die Server-Hälfte, die du heute baust, bleibt also nutzbar.
Vier Details, die über die Sicherheit entscheiden
1. Trau keinem „fertig“
Dem Bestätigungsaufruf eines Browsers vertraut man meist: Meldet er, dass der Upload fertig ist, glaubt der Server das. Ein Agent bestätigt aber genauso fröhlich nach einem gescheiterten curl und bettet dann ein Bild ein, das es nicht gibt.
Deshalb fragt die Bestätigung direkt beim Storage nach, per HeadObject auf den Key, und lehnt ab, wenn dort nichts liegt. Außerdem ist die angegebene Größe in der Upload-Policy festgeschrieben. Dabei geht es um Geld: Eine Presigned-POST-Policy akzeptiert jede Größe bis zu deinem Limit pro Datei, solange du keine exakte content-length-range signierst, während Storage-Kontingente meist die angegebene Größe zählen. Gib ein Byte an, lade 25 MB hoch, und dein Kontingent merkt nichts. Signierst du die exakte Größe, lehnt der Storage die Abweichung mit einem 400 ab.
2. Der signierte Content-Type ist der, den dein CDN ausliefert
Jedes Feld in einem Presigned POST wird zu einer Bedingung der Policy, die exakt passen muss, Content-Type eingeschlossen. Das ist gut, denn der Uploader kann ihn nicht ändern. Es heißt aber auch: Der Typ, den du signiert hast, ist genau der, den dein öffentliches CDN ausliefert.
Deshalb muss der MIME-Typ, den der Agent schickt, auf einer Allowlist stehen, bevor du ihn signierst. Sonst kann ein Agent, oder eine Prompt Injection, die er drei Tool-Aufrufe vorher gelesen hat, text/html auf der Origin deines CDN veröffentlichen. Kookee akzeptiert genau die Typen, die auch das Dashboard akzeptiert, und sonst nichts.
3. Maskiere den erzeugten Befehl, als wäre er feindselig
Der Server gibt dem Agenten einen fertigen Befehl, denn wer ein Modell einen Multipart-POST aus einem JSON-Objekt zusammenbauen lässt, handelt sich subtile Fehler ein. Dieser Befehl enthält einen Dateinamen, den der Agent gewählt hat, signiert in einem Formularfeld, und genau dort liegen die Fallen.
Bei curl -F ist ein Wert, der mit @ oder < beginnt, kein Wert, sondern die Anweisung, eine Datei zu lesen. Ein Dateiname wie @notes.png in einem signierten Feld würde curl eine Datei von der Festplatte lesen lassen. Jedes signierte Feld geht deshalb als --form-string raus, das immer wörtlich genommen wird, und der Dateiteil kommt zuletzt, weil S3 Felder ignoriert, die danach folgen:
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 ');
Der lokale Pfad ist die andere Hälfte. Er landet in den einfachen Anführungszeichen der Shell und im doppelt gequoteten Dateinamen von curl. Ein Pfad mit ' sprengt also das Quoting der Shell, einer mit " oder \ das von curl. Statt einer dritten Escaping-Ebene, bei der sich das Modell vertun kann, gilt eine einfache Regel: Enthält der Pfad eines dieser Zeichen, kopiere die Datei an einen schlichten temporären Pfad und lade die Kopie hoch.
Dateinamen werden im Handler validiert, nicht nur im Schema. Kookee lehnt Schrägstriche und Steuerzeichen ab, auch den C1-Bereich von U+0080 bis U+009F, den eine naive Prüfung auf „unter 0x20“ glatt durchlässt. Und schreib die Regex im Schema in reinem ASCII: Das Muster wird ohne das Flag u ins JSON Schema des Clients exportiert, also lesen clientseitige Validatoren eine Unicode-Klasse wie \p{Cc} falsch und lehnen dann jeden Namen ab, der ein p oder ein c enthält.
4. Sorge dafür, dass der Agent verknüpft, was er hochlädt
Dieser Punkt hat mich selbst erwischt.
Im Editor wird ein hochgeladenes Bild zusammen mit seiner Datei-ID eingefügt, und an dieser ID erkennt das System, dass ein Eintrag die Datei nutzt. Eine Datei, auf die nichts verweist, entfernt die Garbage Collection nach ein paar Tagen. So verschwinden Uploads, die jemand auf halbem Weg aufgegeben hat.
Gib einem Agenten eine URL, und er schreibt, was jedes Modell schreibt: . Keine ID. Heute wird das Bild problemlos angezeigt, die Datei wird aber nie verknüpft, und drei Tage später verschwindet sie aus einem veröffentlichten Artikel. Nirgends ein Fehler: stiller, verzögerter Datenverlust.
Deshalb gibt confirm_upload ein Snippet mit der ID zurück, und die Schreib-Tools lehnen Markdown ab, das die URL eines Uploads ohne ID einführt, mit einer Fehlermeldung, die dem Agenten sagt, er soll das Snippet einfügen.
Meine erste Version dieser Sperre lehnte jede URL aus Kookees CDN ab, die ohne ID kam. Damit blockierte sie auch völlig harmlose Schreibvorgänge: einen Beitrag duplizieren oder übersetzen, dessen Bild per URL eingefügt worden war, oder einen Kommentar, der auf ein PDF verlinkt, das ein anderer Eintrag schon nutzt. Der Agent bekam einen Fehler für Markdown, das er unverändert kopiert hatte, ohne eine ID, die er überhaupt hätte ergänzen können. Die Lösung: die Datei nachschlagen und nur dann ablehnen, wenn sie noch mit nichts verknüpft ist. Das ist der einzige Fall, in dem tatsächlich Daten verloren gehen.
Das gilt allgemein: Eine Prüfung, die für einen Agenten geschrieben ist, sollte auf den Fehlerfall zielen, nicht auf die Form der Eingabe. Mit einer Regel, die strenger ist als das Problem, kann ein Agent nicht verhandeln.
Wie das aussieht
Lade ./diagram.png hoch, setz es mit einem kurzen Alt-Text unter die Überschrift Architektur und nimm ./cover.png als Titelbild.
Der Agent ruft zweimal create_upload auf, führt zwei curl-Befehle aus, bestätigt beide, fügt das Diagramm mit edit_entry_content ein und setzt das Titelbild mit update_entry. Im Text steht dann ein Block wie dieser, der sich Byte für Byte zurücklesen lässt und um den herum du wie gewohnt weiterschreiben kannst:
<p><img src="https://cdn.kookee.dev/…/diagram.png" alt="Ablauf der Anfrage vom Agenten zum Storage" data-id="…"/></p>
Wenn du deinem MCP-Server Uploads beibringst
Nutze Presigning. Schick niemals Datei-Bytes durch einen MCP-Tool-Aufruf.
Bestätige, indem du beim Storage nachfragst, und signiere die angegebene Größe als exakte content-length-range.
Prüfe MIME-Typen gegen eine Allowlist, bevor du signierst. Der signierte Typ ist der, den dein CDN ausliefert.
Schick signierte Felder mit
--form-string, setz den Dateiteil ans Ende und sag dem Agenten, was er mit problematischen Pfaden tun soll.Validiere Dateinamen im Handler und schreib Muster im Schema in reinem ASCII.
Begrenze Presigns per Rate Limit. Uploads, die nie bestätigt werden, entgehen meist den Storage-Kontingenten.
Gib ein Snippet zurück, das die Datei verknüpft, und lehne die unverknüpfte Form ab, aber nur bei Dateien, die sonst tatsächlich verloren gingen.
Schreib in die Tool-Beschreibung, dass der Upload eine Shell braucht, damit reine Chat-Clients es dem Nutzer sagen, statt auf halbem Weg zu scheitern.
Der MCP-Server von Kookee nimmt jetzt Bilder und Dateien von Claude Code, Cursor und anderen Agenten an, die Befehle ausführen können. Die Einrichtung beschreibt der Artikel Bilder und Dateien von einem KI-Assistenten hochladen, das größere Bild der Beitrag Produktinhalte für KI-Agenten aufbereiten.