Essentials

MCP Tools

14 MCP tools for AI agent blog management.

The package includes 14 Model Context Protocol tools for full blog management via AI agents.

Post Tools

ToolTypeAbilityDescription
ListPostsToolRead-onlyposts:readList posts with filters (status, category, search, pagination)
GetPostToolRead-onlyposts:readGet post by ID or slug
CreatePostToolWriteposts:createCreate post (markdown content, auto-slug, auto-sanitize, optional featured_image)
UpdatePostToolIdempotentposts:updateUpdate post fields (partial updates, including featured_image)
DeletePostToolWriteposts:deleteSoft delete a post
RestorePostToolWriteposts:deleteRestore a soft-deleted post
GeneratePreviewUrlToolRead-onlyposts:readGenerate 1-hour signed preview URL
UploadImageToolWriteposts:createUpload an image (from a URL or base64 data) for use as a featured image or embedded in content

Category Tools

ToolTypeAbilityDescription
ListCategoriesToolRead-onlycategories:readList categories with post count
GetCategoryToolRead-onlycategories:readGet category by ID or slug
CreateCategoryToolWritecategories:createCreate category (auto-slug)
UpdateCategoryToolIdempotentcategories:updateUpdate category name
DeleteCategoryToolWritecategories:deleteSoft delete a category
RestoreCategoryToolWritecategories:deleteRestore a soft-deleted category

Setup

Install laravel/mcp (a suggestion, not a hard requirement), then turn the feature on:

config/ink.php
'features' => [
    'mcp' => true,
],

'mcp' => [
    'path' => '/mcp/blog',
    'guard' => null,                    // null uses the default guard
    'middleware' => ['auth:sanctum'],
],

That registers BlogServer — all 14 tools — at the configured path. Nothing is exposed until you opt in, and enabling the flag without laravel/mcp installed simply yields no route rather than an error.

Enabling mcp also registers the signed /blog/preview/{post} route — used by GeneratePreviewUrlTool and Post::getUrl() — even when features.public_routes is off, so an agent can preview a draft or scheduled post in a browser before the public blog launches. The rest of the public routes stay dark.

Prefer to route it yourself? Leave the flag off and register the shipped server:

routes/ai.php
use Relaticle\Ink\Mcp\BlogServer;

Mcp::web('/mcp/blog', BlogServer::class)->middleware('auth:sanctum');

Authorization

Tools authorize through your application's Gate. Register a policy for Relaticle\Ink\Models\Post and Relaticle\Ink\Models\Category:

Gate::policy(Post::class, PostPolicy::class);
Gate::policy(Category::class, CategoryPolicy::class);

With no policy registered the Gate denies and every tool returns This action is unauthorized. — the tools fail closed.

Each tool maps to one Gate ability and one Sanctum token ability. The two are separate axes: the Gate decides what an identity may do, the token ability what a credential may do, so a token can be scoped more narrowly than the person holding it.

ToolGate abilityToken ability
ListPostsToolviewAnyposts:read
GetPostToolviewposts:read
GeneratePreviewUrlToolviewposts:read
CreatePostToolcreateposts:create
UpdatePostToolupdateposts:update
UploadImageToolcreateposts:create
DeletePostTooldeleteposts:delete
RestorePostToolrestoreposts:delete
ListCategoriesToolviewAnycategories:read
GetCategoryToolviewcategories:read
CreateCategoryToolcreatecategories:create
UpdateCategoryToolupdatecategories:update
DeleteCategoryTooldeletecategories:delete
RestoreCategoryToolrestorecategories:delete

Instance-target tools resolve the record before authorizing, so a denial never doubles as an existence check.

Callers on another guard

If your staff authenticate on a guard other than the default — a separate admin model, for instance — point ink.mcp.guard at it. BlogTool resolves the caller from that guard.

Author attribution

CreatePostTool writes author_id, which is NOT NULL. By default the caller is used when it is already an ink.author_model instance. When it is not, supply the mapping in a service provider's boot():

use Relaticle\Ink\Ink;

Ink::resolveAuthorUsing(fn (SystemAdministrator $admin): ?User =>
    User::firstWhere('email', $admin->email));

Register it in a provider, never in config — a closure in a config file breaks config:cache. If no author resolves, the tool reports the misconfiguration instead of guessing.

Images

MCP's tools/call arguments are plain JSON — there is no binary channel for tool inputs — so UploadImageTool accepts either a fetchable url or base64-encoded data (exactly one of the two). The image type is detected by sniffing the actual bytes, never trusted from a filename or extension; only jpeg, png, gif and webp are accepted (no svg — it's a stored-XSS vector). Size is capped by ink.uploads.max_bytes — a HEAD request rejects an oversized url by its Content-Length before the body is downloaded where the server advertises one; the body itself is still measured against the cap either way.

SSRF stance (accepted risk): the url fetch is scheme-restricted (http/https only) but has no host allowlist — it can reach any address your app server can reach, including internal/private ones (e.g. a cloud metadata endpoint). This is an accepted risk, not an oversight: the caller is already an authenticated, admin-scoped identity trusted to publish content (upload-image shares create-post-tool's create Gate ability and posts:create token ability), not an anonymous or low-privilege one. If your deployment needs a stricter boundary — blocking link-local and RFC1918 ranges, for example — enforce it at the network layer in front of the app; there is no host-allowlist config in the package itself.
config/ink.php
'uploads' => [
    'disk' => 'public',       // matches the Filament featured-image field's disk
    'directory' => 'ink',     // matches its directory too — panel and MCP uploads share a home
    'max_bytes' => 3 * 1024 * 1024,
],
max_bytes and PHP's post_max_size (base64 uploads only): the base64 data 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, so an oversized payload is rejected by PHP itself: the client gets a raw PHP warning wrapped in an HTTP 200, not a clean JSON-RPC error, and no app-level check (this one included) can catch it. The 3MB default (≈4MB base64-encoded) stays safely under a common 5-8M post_max_size floor; raise both together if you need a higher cap, keeping post_max_size comfortably above max_bytes * 4/3. The url path has no such ceiling — the image is fetched by this server's own HTTP client, not carried in the MCP request body at all — so prefer it for anything larger than a few MB.

The tool returns a path (pass it as featured_image to create-post-tool / update-post-tool), a public url, and a ready-to-paste markdown snippet for in-content images:

{
  "path": "ink/01hz...webp",
  "url": "https://example.test/storage/ink/01hz...webp",
  "markdown": "![A dashboard screenshot](https://example.test/storage/ink/01hz...webp)"
}

featured_image on create-post-tool / update-post-tool only accepts a path upload-image itself produced — it's validated against the uploads disk and directory, so an agent can't point it at an arbitrary file. Pass featured_image: null on update-post-tool to clear it. Rendering assumes the public disk with storage:link run — the six asset('storage/…') call sites documented in the README are unchanged by this feature. Rendered post images get loading="lazy" and decoding="async" automatically on both render paths (Post::toHtml() and Post::toSafeHtml()), leaving any attribute the author declared by hand alone; there is no automatic resizing or srcset generation yet, so an oversized source image still costs bandwidth and LCP — keep uploads reasonably sized until a resizing pipeline ships.

Scheduling

A post with status: "published" and a future published_at is scheduled, not published immediately: Post::published() is evaluated at query time, so the post appears on the public index/show/feed/sitemap the moment the clock passes published_at, with no queue or command involved. Its signed preview URL (generate-preview-url-tool) keeps working right up until then.

Content format

The tools store the markdown you send, exactly as the Filament editor does; they do not convert it to HTML. Rendering — and therefore HTML sanitisation — happens at read time through your application's markdown configuration.

Posts created by ink 2.1 and earlier hold rendered HTML. The convert_html_post_content_to_markdown migration converts them and prints the ids of any row it could not convert cleanly, so you can review those by hand.

Extending

Custom blog tools should extend Relaticle\Ink\Mcp\BlogTool rather than Laravel\Mcp\Server\Tool. Its handle() is final and performs authorization before delegating, so a new tool cannot ship without it. Declare ability(), tokenAbility(), model() and run(); override resolveRecord() when the tool operates on one record.