v2.4.0
Added
UploadImageToolMCP tool (upload-image): uploads an image from a fetchable URL or base64 data, sniffs the actual bytes to whitelistjpeg/png/gif/webp(nosvg), enforces a configurable max size, and returns a storage path, public URL, and ready-to-paste markdown snippet. Shares thecreateGate ability andposts:createtoken ability withCreatePostTool. Theurlfetch is scheme-restricted (http/httpsonly) with no host allowlist — an accepted risk given the caller is already an authenticated, admin-scoped identity; see the SSRF note in MCP Tools.featured_imageparam onCreatePostToolandUpdatePostTool, accepting a path returned byupload-image. Validated against the configured uploads disk and directory to reject path injection (including traversal-shaped values);nullon update clears it.- New top-level
uploadsconfig key (disk,directory,max_bytes), defaulting to the samepublicdisk /inkdirectory the Filament featured-image field already uses.max_bytesdefaults to 3MB (≈4MB base64-encoded), chosen to stay safely under a common 5-8M PHPpost_max_sizefloor — see the coordination note below and the comment inconfig/ink.php. blog.previewis now also registered whenfeatures.mcpis enabled (previously onlyfeatures.public_routes), soGeneratePreviewUrlToolworks pre-launch, with the rest of the public blog dark. See UPGRADING.md for theroute:cacheimplication.- Rendered post images now automatically get
loading="lazy" decoding="async"— on both render paths:Post::toHtml()(behind<x-ink::post-body>, the schema extractors andtableOfContents()) and the newPost::toSafeHtml()used by the shippedink::pages.show/ink::pages.previewviews. An attribute the author already declared wins: a hand-written<img loading="eager">in post markdown keepseagerand gains no duplicate. No config, no opt-out. Post::toSafeHtml(): renders post markdown with ink's own hardened options (html_input => strip,allow_unsafe_links => false) rather than the host'smarkdownconfig. The two shipped page views used to inline this option set in Blade; it now lives on the model, so both views share one renderer. Use it in a host-owned view when you want the package's sanitising defaults; use$post->renderedContent()/<x-ink::post-body>when you want your ownmarkdownconfig to govern. See Frontend Setup → Helpers on the Post model for the difference — the two renderers are not equivalent.
Fixed
UpdatePostToolsilently no-oped on every call: validated data was read from a variable never assigned inrun()'s scope, soPost::update([])ran with nothing dirty while the tool reported a normal-looking (but stale) success payload. Introduced in 2.2.0'sresolveRecord()/run()refactor.UpdateCategoryToolhad the same scope bug, but surfaced as a masked "An internal server error occurred." instead of a no-op, sinceblog_categories.nameisNOT NULL.GeneratePreviewUrlToolthrew an uncaughtRouteNotFoundException(masked as the same generic internal error) whenblog.previewwasn't registered. It now guards withRoute::has()and returns an actionable error; combined with the routing fix above, this should not trip in a correctly booted app.featured_image's uploads-directory confinement was bypassable: it only checked that the value started with the directory prefix as a string, but Flysystem's local disk normalizes..segments by default, so a value likeink/x/../../other/secret.pngpassed the prefix check yet resolved to a file outside the uploads directory (and some traversal shapes threw an uncaughtPathTraversalDetected, masked as a generic internal error, instead of a clean validation failure). The path is now rejected outright if it contains any traversal segment, and the disk existence check is wrapped to convert any remaining Flysystem exception into a clean validation error.- A non-string
featured_image(a JSON number, boolean, or array — laravel/mcp does not validatetools/callarguments against the tool's schema before invoking it) threw an uncaughtTypeErrorin the same confinement check, masked the same way. It's now rejected with a clean validation error. UploadImageTool's size cap was enforced only after the full body was already in memory (a fully base64-decoded payload, or a fully downloaded URL response), defeating the point of a cap against a large/malicious payload. Base64 payloads are now rejected by their encoded length beforebase64_decode()runs; URL fetches now send aHEADrequest first and reject an oversizedContent-Lengthbefore the body is ever downloaded, and the body itself is now read in bounded chunks via a streamed request rather than buffered whole, so a server that omits or understatesContent-Lengthstill can't force a full download past the cap.
Coordination note: post_max_size
The base64 data upload path is bound by PHP's post_max_size (and any webserver/proxy
body-size limit) before Laravel ever boots — base64 inflates the binary size by ~4/3, and
an oversized request body is rejected by PHP itself, returning a raw PHP warning wrapped in
an HTTP 200 instead of a clean JSON-RPC error. No app-level config, including max_bytes,
can intercept this. ink.uploads.max_bytes defaults to 3MB for exactly this reason; the
url upload path is unaffected, since the image bytes never travel through the MCP request
body. See MCP Tools → Images.