Appearance
Laravel MCP tools that act with the user's permissions
Connecting an AI agent to a Laravel app is easy now. With laravel/mcp you write a server, a few tools, a route, and Claude, Cursor or ChatGPT can call them. The part that takes thought comes next. Who is the agent acting as?
The easy answer is one admin key in an .env file, and then every tool runs as the admin. That works until an author connects an agent to "fix the typos in my posts" and it can edit everyone's posts. It also fails when somebody pastes a read-only key into a client and the client still lists a tool that deletes things.
The better answer is the one Laravel already gives you for every other request: the agent acts as the user who connected it, with that user's policies. A token decides what kind of key it is (read only, or read and write), and the policies decide what this user may do with each record. This post builds that with laravel/mcp and Sanctum, and the full example is tested.
The server and the route
php
// app/Mcp/Servers/BlogServer.php
namespace App\Mcp\Servers;
use App\Mcp\Tools\ListPosts;
use App\Mcp\Tools\UpdatePost;
use Laravel\Mcp\Server;
class BlogServer extends Server
{
protected string $name = 'Blog';
protected string $version = '1.0.0';
protected string $instructions = 'You act as the user whose token you hold. Read a post before you change it.';
protected array $tools = [
ListPosts::class,
UpdatePost::class,
];
}php
// routes/ai.php
use App\Mcp\Servers\BlogServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/blog', BlogServer::class)
->middleware(['auth:sanctum', 'throttle:60,1']);There is no web group here: no session and no CSRF, only a token on every request. A user makes their own token on a profile page, and the abilities decide what kind of key it is:
php
$readOnly = $user->createToken('Claude', ['posts:read'])->plainTextToken;
$readWrite = $user->createToken('Claude', ['posts:read', 'posts:write'])->plainTextToken;The client then connects with that token:
bash
claude mcp add --transport http blog https://example.com/mcp/blog --header "Authorization: Bearer <token>"A tool that reads
A reading tool shows what this user may see, not what the database holds. Here that means the published posts plus the user's own drafts:
php
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class ListPosts extends Tool
{
protected string $description = 'List the published posts and your own drafts, newest first.';
public function schema(JsonSchema $schema): array
{
return [
'search' => $schema->string()->description('Words in the title'),
];
}
public function handle(Request $request): Response
{
$user = $request->user();
$posts = Post::query()
->where(fn ($query) => $query->where('status', 'published')->orWhere('user_id', $user->id))
->when($request->get('search'), fn ($query, $search) => $query->where('title', 'like', "%{$search}%"))
->latest()
->limit(20)
->get(['id', 'title', 'status']);
return Response::json($posts);
}
}$request->user() is the Sanctum user of the HTTP request. #[IsReadOnly] tells the client that the tool changes nothing, and some clients then run it without asking.
A tool that writes
The writing tool does three checks, and each one has a different job:
php
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
#[IsIdempotent]
class UpdatePost extends Tool
{
protected string $description = 'Change the title or the body of a post.';
public function schema(JsonSchema $schema): array
{
return [
'id' => $schema->integer()->min(1)->required(),
'title' => $schema->string(),
'body' => $schema->string(),
];
}
// 1. A read-only token never sees this tool.
public function shouldRegister(Request $request): bool
{
return $request->user()?->tokenCan('posts:write') ?? false;
}
public function handle(Request $request): Response
{
$user = $request->user();
// 2. The same check again, for code that calls the tool another way.
if (! $user->tokenCan('posts:write')) {
return Response::error('Your token is read-only.');
}
$data = $request->validate([
'id' => ['required', 'integer'],
'title' => ['sometimes', 'string', 'max:200'],
'body' => ['sometimes', 'string'],
]);
$post = Post::find($data['id']);
if ($post === null) {
return Response::error("There is no post {$data['id']}.");
}
// 3. The policy: may this user edit this post?
if ($user->cannot('update', $post)) {
return Response::error('You may not edit this post: it belongs to another author.');
}
$post->update(Arr::only($data, ['title', 'body']));
return Response::json($post->only('id', 'title', 'status'));
}
}shouldRegister() hides the tool from the list. That matters more than it looks: a model plans with the tools it can see, so a read-only agent should never plan an edit it will not be allowed to make. laravel/mcp also refuses a call to a tool that is not listed, so a client cannot call it by remembering its name.
The check at the top of handle() looks redundant, and over MCP it is. It is there for the day the same tool class is handed to a model by another road, such as an in-app chat built on the Laravel AI SDK, where nobody filtered the list first.
The policy is the check that matters per record. It is the same PostPolicy your controllers already use:
php
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->is_editor;
}An author's agent edits the author's posts, and an editor's agent edits any post. You wrote no new rule for the agent, which is the whole point.
Refusals the model can read
Every "no" here is a Response::error() with a sentence, not an exception and not a 403. The model gets the sentence back as the tool's result. Then it can tell the user "that post belongs to another author" or try something else, instead of retrying a call it cannot win. A failed validate() comes back the same way, with the validator's messages.
Write those sentences for the person who will read them in the chat, because that is who sees them in the end.
Test it like any other code
laravel/mcp has testing helpers on the server class, and Sanctum::actingAs() sets the token's abilities:
php
use App\Mcp\Servers\BlogServer;
use App\Mcp\Tools\ListPosts;
use App\Mcp\Tools\UpdatePost;
use Laravel\Sanctum\Sanctum;
it('hides the write tool from a read-only token', function () {
Sanctum::actingAs(User::factory()->create(), ['posts:read']);
BlogServer::tools()
->assertRegistered(ListPosts::class)
->assertNotRegistered(UpdatePost::class);
});
it('lets an author edit only their own posts', function () {
$ann = User::factory()->create();
$own = Post::factory()->for($ann)->create();
$other = Post::factory()->create(['title' => 'His']);
Sanctum::actingAs($ann, ['posts:read', 'posts:write']);
BlogServer::tool(UpdatePost::class, ['id' => $own->id, 'title' => 'Better title'])
->assertOk()
->assertSee('Better title');
BlogServer::tool(UpdatePost::class, ['id' => $other->id, 'title' => 'Hijacked'])
->assertHasErrors(['You may not edit this post: it belongs to another author.']);
expect($other->fresh()->title)->toBe('His');
});A few more rules worth copying
- Rate-limit by user, not by IP.
auth:sanctumruns beforethrottle, so the limiter counts each user's calls. An agent in a loop then slows down only that user. - Make drafts by default. If a tool creates records, give it a
statusargument that defaults to draft, and say in the server's$instructionsto publish only when asked. - Never delete for good. Give the agent a trash or a status, not
forceDelete(). An agent that misunderstood a request is then one restore away from fine. - Log what the agent did, and as whom. Your activity log already records the user, so the agent's changes show up under the person who connected it.
- Revoke means stop. Deleting the token ends the agent's access on its next call, and nothing else needs to change.
Where this comes from
This is the shape of the MCP server in kitloom, a Laravel CMS where every installed module brings its own agent tools. Each tool declares a permission and whether it writes, and the server applies the three checks above. The pattern does not need kitloom, though. It needs laravel/mcp, Sanctum and the policies you already have.