MCP Tools
The package includes 14 Model Context Protocol tools for full blog management via AI agents.
Post Tools
| Tool | Type | Ability | Description |
|---|---|---|---|
ListPostsTool | Read-only | posts:read | List posts with filters (status, category, search, pagination) |
GetPostTool | Read-only | posts:read | Get post by ID or slug |
CreatePostTool | Write | posts:create | Create post (markdown content, auto-slug, auto-sanitize, optional featured_image) |
UpdatePostTool | Idempotent | posts:update | Update post fields (partial updates, including featured_image) |
DeletePostTool | Write | posts:delete | Soft delete a post |
RestorePostTool | Write | posts:delete | Restore a soft-deleted post |
GeneratePreviewUrlTool | Read-only | posts:read | Generate 1-hour signed preview URL |
UploadImageTool | Write | posts:create | Upload an image (from a URL or base64 data) for use as a featured image or embedded in content |
Category Tools
| Tool | Type | Ability | Description |
|---|---|---|---|
ListCategoriesTool | Read-only | categories:read | List categories with post count |
GetCategoryTool | Read-only | categories:read | Get category by ID or slug |
CreateCategoryTool | Write | categories:create | Create category (auto-slug) |
UpdateCategoryTool | Idempotent | categories:update | Update category name |
DeleteCategoryTool | Write | categories:delete | Soft delete a category |
RestoreCategoryTool | Write | categories:delete | Restore a soft-deleted category |
Setup
Install laravel/mcp (a suggestion, not a hard requirement), then turn the feature on:
'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:
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.
| Tool | Gate ability | Token ability |
|---|---|---|
ListPostsTool | viewAny | posts:read |
GetPostTool | view | posts:read |
GeneratePreviewUrlTool | view | posts:read |
CreatePostTool | create | posts:create |
UpdatePostTool | update | posts:update |
UploadImageTool | create | posts:create |
DeletePostTool | delete | posts:delete |
RestorePostTool | restore | posts:delete |
ListCategoriesTool | viewAny | categories:read |
GetCategoryTool | view | categories:read |
CreateCategoryTool | create | categories:create |
UpdateCategoryTool | update | categories:update |
DeleteCategoryTool | delete | categories:delete |
RestoreCategoryTool | restore | categories: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.
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.'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": ""
}
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.
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.