The server is a laravel/mcp server class. Extend it, override tools(), and add, replace, or remove tools on the registry it receives. Your tools run behind the same authentication and Access MCP check as the built-in ones.
Extend the server#
namespace AppMcp;
use AppMcpToolsEntriesList as SlimEntriesList;
use AppMcpToolsNewsletterSend;
use DanielgnhStatamicMcpServer;
use DanielgnhStatamicMcpToolRegistry;
use DanielgnhStatamicMcpToolsAssetsUpload;
use DanielgnhStatamicMcpToolsEntriesList;
class StatamicServer extends Server
{
protected function tools(ToolRegistry $tools): void
{
$tools->add(NewsletterSend::class);
$tools->replace(EntriesList::class, SlimEntriesList::class);
$tools->remove(AssetsUpload::class);
}
} Then point the config at it.
// config/statamic/mcp.php
'server' => AppMcpStatamicServer::class, Don’t mount a second Mcp::web() route for Statamic tools. The addon’s route already carries the auth middleware and permission gate.
A complete tool#
namespace AppMcpTools;
use DanielgnhStatamicMcpToolsTool;
use IlluminateContractsJsonSchemaJsonSchema;
use LaravelMcpRequest;
use LaravelMcpResponse;
use LaravelMcpServerAttributesDescription;
use LaravelMcpServerAttributesName;
#[Name('newsletter_send')]
#[Description('Send the newsletter draft to every subscriber.')]
class NewsletterSend extends Tool
{
#[Override]
public function schema(JsonSchema $schema): array
{
return [
'draft_id' => $schema->string()->description('Entry id of the newsletter draft.')->required(),
];
}
public function shouldRegister(Request $request): bool
{
return $this->writesEnabled();
}
protected function execute(Request $request): Response
{
$this->ensureWritesEnabled();
$this->ensurePermission($this->user($request), 'edit newsletter entries');
$validated = $request->validate(['draft_id' => 'required|string']);
// ...
return $this->json(['sent' => true]);
}
} What the base class gives you#
Extend Danielgnh\StatamicMcp\Tools\Tool and implement execute(Request $request): Response. Throw Danielgnh\StatamicMcp\Tools\ToolException anywhere in it, and the message becomes a tool error response instead of a 500.
| Helper | What it does |
|---|---|
user($request) | The acting Statamic user, in token and OAuth mode alike. Throws a tool error when no user is authenticated. |
can($user, $permission) | Statamic’s permission check. Super users pass. |
ensurePermission($user, $permission) | The throwing form, which names the missing permission and the remedy. |
ensureExposed($type, $handle) | Honors the resources allowlist. A handle that exists but isn’t exposed reads as not found, on purpose. |
exposedHandles($type) | The handles that exist and pass the allowlist. |
writesEnabled(), ensureWritesEnabled() | The read_only switch. |
deletesEnabled(), ensureDeletesEnabled() | The deletes switch, with read_only checked first. |
json($data) | A compact JSON text response. |
notFound($what, $given, $available) | The same not-found shape the built-in tools return. |
A plain Laravel\Mcp\Server\Tool works too. It runs behind the addon’s middleware without these helpers.
When something goes wrong in a tool#
A ToolException message goes to the agent. Anything unexpected reaches your exception handler, and with APP_DEBUG off the agent gets An internal server error occurred. With it on, laravel/mcp gives the agent the raw message instead.
A custom fieldtype that throws a plain Exception on a bad value gives the agent the generic error. Throw InvalidArgumentException to keep a message that names the field.
Your tools and read_only#
read_only hides the built-in write tools because each one implements shouldRegister(). The switch knows nothing about your tools, so a tool that writes needs the same two lines the built-in ones have.
shouldRegister()returning$this->writesEnabled()hides the tool fromtools/list.$this->ensureWritesEnabled()at the top ofexecute()is the second check, for code that reaches the tool without going through the tool list. Over HTTP, a client with an old list getsTool [x] not found.first.
Read tools need neither. Mark them #[IsReadOnly] so clients can tell.
Replacing and removing tools#
replace() swaps one class for another in place. The usual replacement is a subclass of the built-in tool. Override execute() or schema(), redeclare #[Description] if the wording changes, and keep the name, which laravel/mcp reads from the parent class.
The built-in tools keep their internals private, so a change deeper than execute() means copying the class. Owning a copy beats overriding a private helper.
remove() drops a tool for good. Removing a class that isn’t registered is a no-op, and replacing one that isn’t registered throws.
Tool names must be unique on the server. add() dedupes by class, not by name, so if two classes carry the same #[Name], tools/call reaches the first one and tools/list shows both. That’s what replace() is for.
More than 50 tools#
The addon serves up to 50 tools on one page, because some clients never send a cursor. If your server grows past that, raise $defaultPaginationLength and $maxPaginationLength on your class.
Test it#
laravel/mcp’s test harness runs against your server class. Act as a Statamic user and call the tool by class.
use AppMcpStatamicServer;
use AppMcpToolsNewsletterSend;
use StatamicFacadesUser;
$user = User::make()->email('[email protected]')->makeSuper();
StatamicServer::actingAs($user)
->tool(NewsletterSend::class, ['draft_id' => $draft->id()])
->assertOk()
->assertSee('"sent":true'); The harness honors shouldRegister(), so a hidden tool answers “not found”. To pin the in-handler re-check, call handle() on a fresh instance directly.
What’s public API#
A minor or patch release keeps these working: Server, ToolRegistry, the Tool base class with the helpers listed above, ToolException, and the built-in tool classes, which you can replace, remove, or subclass by name. Other classes under src/, the traits in Tools/Concerns included, are internal.