If you've used Claude Code, Cursor, or any AI coding assistant recently, you've probably noticed they're getting better at doing things, not just generating text. The bridge that makes this possible is MCP: the Model Context Protocol.
I've built two MCP servers that I use daily. One manages the blog you're reading right now. The other is a personal knowledge base called Acervo. Both are Laravel apps, and both let me manage content directly from Claude Code without ever opening a browser.
Here's how to build your own.
What is MCP?
MCP is an open protocol created by Anthropic that standardizes how AI models interact with external tools and data. Your server declares what it can do (tools, resources, prompts), and the AI client calls those capabilities when relevant.
The protocol uses JSON-RPC over stdio or HTTP. The AI decides when to call your tools based on the user's intent. You just define what's available.
The laravel/mcp Package
Laravel has an official MCP package that handles all the protocol plumbing: JSON-RPC transport, capability negotiation, tool registration. You define servers, tools, and resources as classes, and the package wires everything up.
Installation
composer require laravel/mcp
Publish the routes file where you'll register your MCP servers:
php artisan vendor:publish --tag=ai-routes
This creates routes/ai.php. The package supports both stdio (for local clients like Claude Code) and HTTP with SSE (for remote clients).
Creating a Server
A server is the central point that exposes your tools, resources, and prompts to AI clients. Generate one with Artisan:
php artisan make:mcp-server SiteServer
This creates a class in app/Mcp/Servers:
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\Posts\CreatePostTool;
use App\Mcp\Tools\Posts\ListPostsTool;
use App\Mcp\Tools\Posts\PublishPostTool;
use App\Mcp\Tools\Posts\ShowPostTool;
use App\Mcp\Tools\Posts\UpdatePostTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('jorgecortes.dev')]
#[Version('1.0.0')]
#[Instructions('Manage content on jorgecortes.dev. Create, update, list, and publish blog posts.')]
class SiteServer extends Server
{
protected array $tools = [
ListPostsTool::class,
ShowPostTool::class,
CreatePostTool::class,
UpdatePostTool::class,
PublishPostTool::class,
];
protected array $resources = [
//
];
}
Register the server in routes/ai.php:
use App\Mcp\Servers\SiteServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/site', SiteServer::class)
->middleware('auth:sanctum');
Your server is now reachable at /mcp/site and protected by Sanctum authentication.
Defining Tools
Tools are the core of any MCP server. Each tool is a class with a description, input schema, and a handle method. Generate one with:
php artisan make:mcp-tool ListPostsTool
Here's a real tool from my blog's MCP server that lists posts:
<?php
namespace App\Mcp\Tools\Posts;
use App\Enums\PostStatus;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('List blog posts, optionally filtered by status.')]
class ListPostsTool extends Tool
{
public function handle(Request $request): Response
{
$status = $request->get('status', 'all');
$limit = $request->get('limit', 10);
$query = Post::query()->latest('published_at');
if ($status !== 'all') {
$query->where('status', PostStatus::from($status));
}
$posts = $query->limit($limit)->get()->map(fn (Post $post) => [
'id' => $post->id,
'title' => $post->title,
'status' => $post->status->value,
'published_at' => $post->published_at?->toIso8601String(),
'url' => $post->url,
]);
return Response::text($posts->toJson(JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string()
->description('Filter by status: draft, published, or all. Default: all'),
'limit' => $schema->integer()
->description('Max posts to return. Default: 10'),
];
}
}
And a tool for creating posts:
<?php
namespace App\Mcp\Tools\Posts;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Str;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new blog post. Body should be markdown.')]
class CreatePostTool extends Tool
{
public function handle(Request $request): Response
{
$post = Post::create([
'title' => $request->get('title'),
'body' => $request->get('body'),
'status' => $request->get('status', 'draft'),
'user_id' => $request->user()->id,
]);
if ($tags = $request->get('tags')) {
$tagIds = collect($tags)->map(fn (string $name) => Tag::firstOrCreate(
['slug' => Str::slug($name)],
['name' => $name, 'slug' => Str::slug($name)],
)->id);
$post->tags()->sync($tagIds);
}
return Response::text(json_encode([
'id' => $post->id,
'title' => $post->title,
'status' => $post->status->value,
'url' => $post->url,
], JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()
->description('Post title.')
->required(),
'body' => $schema->string()
->description('Post body in markdown.')
->required(),
'status' => $schema->string()
->description('Post status: draft or published. Default: draft'),
'tags' => $schema->array()
->description('Array of tag names to attach.'),
];
}
}
The pattern is consistent: define parameters with types in schema(), handle the logic in handle(). Return Response::text() for success or Response::error() for failures.
Resources
Resources let AI clients read structured data from your server without calling a tool. They're useful for context the AI might need, like available tags or site config.
Generate a resource with:
php artisan make:mcp-resource TagListResource
<?php
namespace App\Mcp\Resources;
use App\Models\Tag;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;
#[Description('All available blog tags with post counts.')]
#[Uri('tags://list')]
class TagListResource extends Resource
{
public function handle(Request $request): Response
{
$tags = Tag::withCount('posts')->get();
return Response::text($tags->toJson(JSON_PRETTY_PRINT));
}
}
Register resources in your server's $resources array, the same way you register tools. Resources are read-only by design. If the AI needs to change something, that's a tool.
Authentication
For remote deployments, lock down your MCP server with middleware on the route:
// routes/ai.php
use App\Mcp\Servers\SiteServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/site', SiteServer::class)
->middleware('auth:sanctum');
Create a Sanctum token for your MCP client:
$token = $user->createToken('mcp-agent');
For stdio transport (local use with Claude Code), authentication is typically unnecessary. The server runs as a subprocess on your machine.
For production servers that need OAuth support, the package also integrates with Laravel Passport.
Connecting to Claude Code
For remote servers using HTTP transport, add your server to the project's .mcp.json:
{
"mcpServers": {
"my-blog": {
"type": "http",
"url": "https://your-app.com/mcp/site",
"headers": {
"Authorization": "Bearer your-token"
}
}
}
}
For local development with stdio transport:
{
"mcpServers": {
"my-blog-local": {
"command": "php",
"args": ["artisan", "mcp:start", "site"]
}
}
}
Restart Claude Code, and your tools appear automatically. When I ask Claude to "create a draft post about MCP servers," it calls my CreatePostTool directly. No copy-pasting, no browser switching.
Testing
The laravel/mcp package includes a testing API that lets you call tools directly on your server:
use App\Mcp\Servers\SiteServer;
use App\Mcp\Tools\Posts\CreatePostTool;
use App\Mcp\Tools\Posts\ListPostsTool;
use App\Models\Post;
it('lists published posts', function () {
$published = Post::factory()->count(3)->published()->create();
Post::factory()->draft()->create(['title' => 'Draft Only']);
$response = SiteServer::actingAs($this->user)->tool(ListPostsTool::class, [
'status' => 'published',
]);
$response->assertOk();
$response->assertSee($published->first()->title);
$response->assertDontSee('Draft Only');
});
it('creates a draft post', function () {
$response = SiteServer::actingAs($this->user)->tool(CreatePostTool::class, [
'title' => 'My Test Post',
'body' => '# Hello World',
]);
$response->assertOk();
$this->assertDatabaseHas('posts', [
'title' => 'My Test Post',
'status' => 'draft',
]);
});
SiteServer::actingAs($user) authenticates the request. ->tool(ToolClass::class, [...]) calls the tool with arguments. The assertion methods (assertOk, assertSee, assertHasErrors) verify behavior without hitting any MCP client.
Test the security boundaries harder than the happy paths:
it('returns error for non-existent post', function () {
$response = SiteServer::actingAs($this->user)->tool(UpdatePostTool::class, [
'id' => 99999,
'title' => 'Nope',
]);
$response->assertHasErrors();
});
Deployment Considerations
A few things I've learned running MCP servers in production:
Keep tools focused. Each tool should do one thing. CreatePostTool creates a post. UpdatePostTool updates one. Don't build a god-tool that does everything based on a mode parameter.
Write clear descriptions. The AI reads your tool descriptions to decide when to use them. Vague descriptions lead to wrong tool calls. Be specific about what each parameter does and what the tool returns.
Return useful data. After creating or updating a record, return the ID and key fields. The AI needs confirmation that the operation succeeded, and the user wants to see what happened.
Think about idempotency. AI clients might retry failed calls. If your create tool gets called twice with the same data, what happens? Consider adding duplicate detection where it matters. You can annotate tools with #[IsIdempotent] or #[IsReadOnly] to signal their behavior to clients.
Stdio for development, HTTP for production. Stdio is simpler: no auth, no CORS, no deployment. Use it locally. Switch to HTTP when you need remote access or want to share the server across machines.
If you want to learn more about how AI coding assistants use MCP for development context, check out my posts on What is Laravel Boost and Setting Up Laravel Boost with Claude Code.
This Works in Practice
The post you're reading was created through an MCP tool. I manage my blog entirely from Claude Code: drafting, editing, tagging, publishing. My knowledge base works the same way: I save notes, search across sources, and retrieve context without leaving the terminal.
MCP turns your Laravel app into an AI-native API. The laravel/mcp package handles the protocol. You just write the tools that matter for your domain.
The code is standard Laravel. The deployment is standard Laravel. The only new thing is the interface, and it's a good one.
Thanks for reading, and see you in the next one.