Build an MCP server with Laravel (and use it to publish this post)

by Daniel Coulbourne February 18, 2026

Every MCP server tutorial you'll find is written in Python or TypeScript. If you're a Laravel developer, you've been left out of the conversation — until now.

Laravel's official laravel/mcp package lets you build MCP servers that expose your application's functionality directly to AI assistants like Claude. No REST API design, no authentication tokens to manage, no SDK to maintain. Just PHP classes that describe what your app can do.

I built an MCP server for this blog in about 20 minutes. Then I used it to write, revise, and publish the post you're reading right now.

What is MCP?

The Model Context Protocol is an open standard that lets AI assistants interact with external systems through "tools" — structured actions with defined inputs and outputs. Think of it as giving an AI hands instead of just a mouth.

Without MCP, an AI can tell you how to create a blog post. With MCP, it can actually create one.

The protocol works over two transports: stdio (for local tools like Claude Code) and HTTP (for remote access). Laravel supports both out of the box.

What we're building

A blog management MCP server with five tools:

Tool What it does
ListPosts Browse posts, filter by published/draft status
GetPost Retrieve a full post by slug, including markdown body
CreatePost Create a new draft post
UpdatePost Edit specific fields on an existing post
PublishPost Set published_at to now — make it live

By the end, Claude Code will be able to manage your blog without you ever touching a browser.

Step 1: Install laravel/mcp

composer require laravel/mcp
php artisan vendor:publish --tag=ai-routes

This creates routes/ai.php, where you'll register your MCP servers. Laravel already handles loading this file — no extra configuration needed.

Step 2: Create the server

php artisan make:mcp-server BlogServer

The server class is just a container that names your server, describes what it does, and lists its tools:

<?php

namespace App\Mcp\Servers;

use App\Mcp\Tools\CreatePostTool;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use App\Mcp\Tools\PublishPostTool;
use App\Mcp\Tools\UpdatePostTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;

#[Name('Blog Server')]
#[Version('1.0.0')]
#[Instructions('Manage blog posts on thunk.dev. You can list, view, create, update, and publish posts. Posts are created as drafts and must be explicitly published.')]
class BlogServer extends Server
{
    protected array $tools = [
        ListPostsTool::class,
        GetPostTool::class,
        CreatePostTool::class,
        UpdatePostTool::class,
        PublishPostTool::class,
    ];
}

The #[Instructions] attribute is important — it tells the AI assistant what this server is for and how to use it. Think of it like a system prompt for your tools.

Step 3: Build the tools

Each tool is a class with two methods: schema() defines the inputs, handle() does the work. Generate them with Artisan:

php artisan make:mcp-tool CreatePostTool
php artisan make:mcp-tool PublishPostTool
# ... and so on for each tool

CreatePostTool

This is the most interesting one. It accepts a title, markdown body, and author ID, then creates a draft post:

#[Description('Create a new blog post as a draft. Use the publish-post tool to publish it.')]
class CreatePostTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'markdown_body' => 'required|string',
            'excerpt' => 'nullable|string|max:500',
            'slug' => 'nullable|string|max:255|unique:posts,slug',
            'meta' => 'nullable|array',
            'user_id' => 'required|integer|exists:users,id',
        ]);

        $post = Post::create([
            'title' => $validated['title'],
            'slug' => $validated['slug'] ?? Str::slug($validated['title']),
            'markdown_body' => $validated['markdown_body'],
            'excerpt' => $validated['excerpt'] ?? null,
            'meta' => $validated['meta'] ?? null,
            'user_id' => $validated['user_id'],
            'published_at' => null,
        ]);

        return Response::text(json_encode([
            'id' => $post->id,
            'title' => $post->title,
            'slug' => $post->slug,
            'status' => 'draft',
            'created_at' => $post->created_at->toIso8601String(),
        ], JSON_PRETTY_PRINT));
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'title' => $schema->string()
                ->description('The title of the blog post.')
                ->required(),

            'markdown_body' => $schema->string()
                ->description('The full markdown content of the post.')
                ->required(),

            'excerpt' => $schema->string()
                ->description('A short summary of the post.'),

            'slug' => $schema->string()
                ->description('URL slug for the post. Auto-generated from title if omitted.'),

            'meta' => $schema->object()
                ->description('Optional metadata object for the post.'),

            'user_id' => $schema->integer()
                ->description('The ID of the author user.')
                ->required(),
        ];
    }
}

A few things to notice:

  • Validation is just Laravel validation. The $request->validate() call works exactly like a form request. If validation fails, MCP returns a structured error to the AI — it knows what it did wrong and can fix it.
  • The schema describes inputs for the AI. The description() calls aren't comments — they're instructions the AI reads to understand what each field does.
  • Posts are always created as drafts. published_at is explicitly set to null. Publishing is a separate, intentional action. You don't want an AI accidentally making something live.

PublishPostTool

The payoff tool. Simple, but with guardrails:

#[IsIdempotent]
#[Description('Publish a draft blog post by setting its published_at timestamp to now.')]
class PublishPostTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'slug' => 'required|string',
        ]);

        $post = Post::query()->where('slug', $validated['slug'])->first();

        if (! $post) {
            return Response::error("No post found with slug '{$validated['slug']}'.");
        }

        if ($post->published_at) {
            return Response::text("Post '{$post->title}' is already published (published at {$post->published_at->toIso8601String()}).");
        }

        $post->update(['published_at' => now()]);

        return Response::text(json_encode([
            'id' => $post->id,
            'title' => $post->title,
            'slug' => $post->slug,
            'status' => 'published',
            'published_at' => $post->published_at->toIso8601String(),
        ], JSON_PRETTY_PRINT));
    }
}

The #[IsIdempotent] attribute tells the AI this tool is safe to retry — publishing an already-published post just returns an informational message instead of an error.

ListPostsTool

Read-only tools get the #[IsReadOnly] attribute, signaling to the AI that calling them has no side effects:

#[IsReadOnly]
#[Description('List blog posts with optional filtering by publication status.')]
class ListPostsTool extends Tool
{
    public function handle(Request $request): Response
    {
        $status = $request->get('status', 'all');
        $limit = $request->get('limit', 20);

        $query = Post::query()->latest();

        if ($status === 'published') {
            $query->published();
        } elseif ($status === 'draft') {
            $query->whereNull('published_at');
        }

        $posts = $query->take($limit)->get()->map(fn (Post $post) => [
            'id' => $post->id,
            'title' => $post->title,
            'slug' => $post->slug,
            'status' => $post->published_at ? 'published' : 'draft',
            'published_at' => $post->published_at?->toIso8601String(),
        ]);

        return Response::text(json_encode($posts, JSON_PRETTY_PRINT));
    }
}

The GetPostTool and UpdatePostTool follow the same patterns — validate inputs, query the model, return JSON. Nothing surprising, which is the point.

Step 4: Register and connect

In routes/ai.php, register the server for both local and remote access:

use App\Mcp\Servers\BlogServer;
use Laravel\Mcp\Facades\Mcp;

Mcp::local('blog', BlogServer::class);

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

Mcp::local() registers the server for stdio transport — this is what Claude Code uses when running locally via php artisan mcp:serve blog.

Mcp::web() exposes it as an HTTP endpoint for remote access, protected by bearer token auth.

To connect Claude Code, add the server to your .mcp.json:

{
    "mcpServers": {
        "blog": {
            "type": "stdio",
            "command": "php",
            "args": ["artisan", "mcp:serve", "blog"]
        }
    }
}

Restart Claude Code, and your blog tools appear alongside its built-in tools.

Step 5: Test it

laravel/mcp ships with a testing helper that lets you call tools directly in Pest:

use App\Mcp\Servers\BlogServer;
use App\Mcp\Tools\CreatePostTool;
use App\Mcp\Tools\PublishPostTool;
use App\Models\Post;
use App\Models\User;

test('create post creates a draft post', function () {
    $user = User::factory()->create();

    $response = BlogServer::tool(CreatePostTool::class, [
        'title' => 'My New Post',
        'markdown_body' => '# Content here',
        'excerpt' => 'A short summary',
        'user_id' => $user->id,
    ]);

    $response->assertOk();
    $response->assertSee('My New Post');
    $response->assertSee('draft');

    $this->assertDatabaseHas('posts', [
        'title' => 'My New Post',
        'slug' => 'my-new-post',
        'published_at' => null,
    ]);
});

test('publish post sets published_at', function () {
    Post::factory()->unpublished()->create([
        'slug' => 'publish-me',
    ]);

    $response = BlogServer::tool(PublishPostTool::class, [
        'slug' => 'publish-me',
    ]);

    $response->assertOk();
    $response->assertSee('published');

    $post = Post::query()->where('slug', 'publish-me')->first();
    expect($post->published_at)->not->toBeNull();
});

test('publish post handles already published post', function () {
    Post::factory()->create(['slug' => 'already-published']);

    $response = BlogServer::tool(PublishPostTool::class, [
        'slug' => 'already-published',
    ]);

    $response->assertOk();
    $response->assertSee('already published');
});

BlogServer::tool() simulates exactly what an AI assistant does when it calls your tool — same validation, same response format. Your tests verify the AI's experience, not just your code.

About this post

This post was drafted, revised, and published using the MCP server described above.

I opened Claude Code and said "draft a blog post about how we built the blog MCP server." Claude used the CreatePostTool to save a draft directly to the database. We went back and forth — I gave feedback, Claude called UpdatePostTool to revise. When it was ready, I said "publish it," and Claude called PublishPostTool to set published_at to now.

No browser. No admin panel. No copy-pasting markdown between a chat window and a CMS. The AI had direct, structured access to the blog, and the blog had guardrails (drafts by default, validation, idempotent publish) to keep things safe.

That's the real value of MCP. It's not about building chatbots — it's about making your existing Laravel application a first-class tool for AI assistants. Your Eloquent models, your validation rules, your business logic — they all just work.

Get started

The whole server is five tool classes, one server class, and two lines of route registration. If you've built a Laravel controller, you can build an MCP server.

about the author
Daniel Coulbourne

Daniel Coulbourne

partner/dev

Philly, PA

Daniel is a longtime developer, speaker, and podcaster in the Laravel community. He loves solving the absolute gnarliest, most painful programming problems you can find.

tags
development
laravel
ai
mcp