laravel 13 mcp server tutorial Binaryboxtuts

How to Build an MCP Server in Laravel 13 (Step-by-Step Tutorial)

Avatar photoPosted by

Introduction:

Hi there! In this blog, I will be showing you how to build an MCP server in Laravel 13 using the official Laravel MCP package. By the end, AI tools like Cursor, Claude Code, or Claude Desktop will be able to read and change data in your own Laravel app. We will build a small to-do list server that an AI assistant can use to list tasks, add tasks, and mark them as done.

In my AI Agents Crash Course, we built an agent inside Laravel. This time we go the other way: we make our Laravel app a toolbox that any AI agent can plug into.

What is MCP?

MCP (Model Context Protocol) is an open standard that lets AI applications talk to outside systems in one common way. Think of it like a USB-C port for AI: you build an MCP server once, and any MCP-aware client can connect to it. An MCP server can expose three kinds of things:

  • Tools: actions the AI can run, like “create a task”. This is the same idea as function calling.
  • Resources: read-only data the AI can load as context, like live stats or docs.
  • Prompts: reusable prompt templates the user can pick from the client.

Laravel MCP (laravel/mcp) is Laravel’s first-party package for building MCP servers. It gives you Artisan generators, JSON Schema helpers, Laravel validation, and testing helpers, so building a server feels like writing normal Laravel code. If AI agents are new to you, read What Is an AI Agent? first.

Prerequisite:

  • PHP >= 8.3 and Composer
  • A Laravel 13 project (a fresh one is fine, or reuse the one from my Laravel 13 CRUD tutorial)
  • A database (SQLite, the Laravel 13 default, works great)
  • An MCP client to try it with, such as Cursor, Claude Code, or Claude Desktop (optional, we can also test with the MCP Inspector)

Step 1: Install Laravel MCP

Inside your Laravel 13 project, install the package with Composer:

composer require laravel/mcp

Then publish the routes/ai.php file. This is where we will register our MCP servers, just like routes/web.php is for web routes:

php artisan vendor:publish --tag=ai-routes

You don’t need to load this file anywhere. The package picks up routes/ai.php automatically.

Step 2: Create the Task Model and Migration

Our AI assistant needs something to manage, so let’s create a simple Task model with a migration:

php artisan make:model Task -m

Open the new migration file in database/migrations/ and update it:

database/migrations/xxxx_xx_xx_xxxxxx_create_tasks_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('priority')->default('medium');
            $table->boolean('is_completed')->default(false);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

Next, update the model. I’m using the #[Fillable] attribute that Laravel 13 supports, and casting is_completed to a boolean:

app/Models/Task.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['title', 'priority', 'is_completed'])]
class Task extends Model
{
    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'is_completed' => 'boolean',
        ];
    }
}

Run the migration:

php artisan migrate

Step 3: Create the MCP Tools

Tools are the most important part of an MCP server. Each tool is a class with a description (so the AI knows when to use it), a schema (the arguments it accepts), and a handle method (the code that runs). Let’s generate three tools:

php artisan make:mcp-tool ListTasksTool
php artisan make:mcp-tool CreateTaskTool
php artisan make:mcp-tool CompleteTaskTool

These files are created in app/Mcp/Tools/.

3.1 List Tasks Tool

app/Mcp/Tools/ListTasksTool.php

<?php

namespace App\Mcp\Tools;

use App\Models\Task;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[Name('list-tasks')]
#[Description('Lists tasks from the to-do list. Can filter by status: pending, completed, or all.')]
#[IsReadOnly]
class ListTasksTool extends Tool
{
    /**
     * Handle the tool request.
     */
    public function handle(Request $request): Response
    {
        $status = $request->get('status', 'pending');

        $tasks = Task::query()
            ->when($status === 'pending', fn ($query) => $query->where('is_completed', false))
            ->when($status === 'completed', fn ($query) => $query->where('is_completed', true))
            ->latest()
            ->get(['id', 'title', 'priority', 'is_completed']);

        if ($tasks->isEmpty()) {
            return Response::text("No {$status} tasks found.");
        }

        return Response::text($tasks->toJson(JSON_PRETTY_PRINT));
    }

    /**
     * Get the tool's input schema.
     *
     * @return array<string, \Illuminate\JsonSchema\Types\Type>
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'status' => $schema->string()
                ->enum(['pending', 'completed', 'all'])
                ->description('Which tasks to return.')
                ->default('pending'),
        ];
    }
}

The #[IsReadOnly] annotation tells the client this tool doesn’t change anything, so some clients can run it without asking you for approval first. I also set the tool name with #[Name('list-tasks')]. Without it, the name is generated from the class name, and a short, clear name makes it easier for the AI to pick the right tool.

3.2 Create Task Tool

app/Mcp/Tools/CreateTaskTool.php

<?php

namespace App\Mcp\Tools;

use App\Models\Task;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;

#[Name('create-task')]
#[Description('Creates a new task in the to-do list.')]
class CreateTaskTool extends Tool
{
    /**
     * Handle the tool request.
     */
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'priority' => ['nullable', 'in:low,medium,high'],
        ], [
            'title.required' => 'You must provide a task title, for example "Write the weekly report".',
            'priority.in' => 'Priority must be one of: low, medium, or high.',
        ]);

        $task = Task::create([
            'title' => $validated['title'],
            'priority' => $validated['priority'] ?? 'medium',
        ]);

        return Response::text("Task #{$task->id} \"{$task->title}\" was created with {$task->priority} priority.");
    }

    /**
     * Get the tool's input schema.
     *
     * @return array<string, \Illuminate\JsonSchema\Types\Type>
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'title' => $schema->string()
                ->description('A short title for the task.')
                ->required(),

            'priority' => $schema->string()
                ->enum(['low', 'medium', 'high'])
                ->description('How important the task is.')
                ->default('medium'),
        ];
    }
}

Here we use normal Laravel validation with $request->validate(). If validation fails, the error messages are sent back to the AI as an error result, so it can fix the arguments and try again. That’s why I wrote custom messages that explain exactly what is expected. Never trust tool arguments blindly, since they come from a model.

3.3 Complete Task Tool

app/Mcp/Tools/CompleteTaskTool.php

<?php

namespace App\Mcp\Tools;

use App\Models\Task;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;

#[Name('complete-task')]
#[Description('Marks a task as completed using its ID.')]
#[IsIdempotent]
class CompleteTaskTool extends Tool
{
    /**
     * Handle the tool request.
     */
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'id' => ['required', 'integer'],
        ]);

        $task = Task::find($validated['id']);

        if (! $task) {
            return Response::error("Task #{$validated['id']} does not exist. Call list-tasks to see valid IDs.");
        }

        $task->update(['is_completed' => true]);

        return Response::text("Task #{$task->id} \"{$task->title}\" is now completed.");
    }

    /**
     * Get the tool's input schema.
     *
     * @return array<string, \Illuminate\JsonSchema\Types\Type>
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'id' => $schema->integer()
                ->description('The ID of the task to complete.')
                ->required(),
        ];
    }
}

When the task doesn’t exist, we return Response::error() with a hint to call list-tasks. Helpful error messages are a big part of making AI tools work well.

Step 4: Create a Resource and a Prompt

Tools are enough for most servers, but let’s also add a resource (live task stats) and a prompt (a “plan my day” template) so you can see all three building blocks:

php artisan make:mcp-resource TaskStatsResource
php artisan make:mcp-prompt PlanMyDayPrompt

app/Mcp/Resources/TaskStatsResource.php

<?php

namespace App\Mcp\Resources;

use App\Models\Task;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;

#[Name('task-stats')]
#[Description('Live statistics about the to-do list: total, pending, completed, and high-priority tasks.')]
#[Uri('tasks://stats')]
#[MimeType('application/json')]
class TaskStatsResource extends Resource
{
    /**
     * Handle the resource request.
     */
    public function handle(Request $request): Response
    {
        return Response::text(json_encode([
            'total' => Task::count(),
            'pending' => Task::where('is_completed', false)->count(),
            'completed' => Task::where('is_completed', true)->count(),
            'high_priority_pending' => Task::where('is_completed', false)
                ->where('priority', 'high')
                ->count(),
        ], JSON_PRETTY_PRINT));
    }
}

The #[Uri] attribute gives the resource its address (tasks://stats), and #[MimeType] tells the client we return JSON.

app/Mcp/Prompts/PlanMyDayPrompt.php

<?php

namespace App\Mcp\Prompts;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;

#[Name('plan-my-day')]
#[Description('Asks the AI to plan your day using your pending tasks.')]
class PlanMyDayPrompt extends Prompt
{
    /**
     * Handle the prompt request.
     *
     * @return array<int, \Laravel\Mcp\Response>
     */
    public function handle(Request $request): array
    {
        $hours = $request->get('hours', 8);

        return [
            Response::text('You are a friendly productivity assistant. Be concise and practical.')->asAssistant(),
            Response::text("I have {$hours} hours of work time today. Use the list-tasks tool to get my pending tasks, then build a schedule that starts with high-priority tasks."),
        ];
    }

    /**
     * Get the prompt's arguments.
     *
     * @return array<int, \Laravel\Mcp\Server\Prompts\Argument>
     */
    public function arguments(): array
    {
        return [
            new Argument(
                name: 'hours',
                description: 'How many hours you can work today.',
                required: false,
            ),
        ];
    }
}

A prompt returns a list of messages. asAssistant() marks a message as coming from the assistant, and the rest are treated as user messages.

Step 5: Create the MCP Server

The server is the entry point that groups our tools, resources, and prompts together. Generate it:

php artisan make:mcp-server TaskServer

Open app/Mcp/Servers/TaskServer.php and register everything we built:

app/Mcp/Servers/TaskServer.php

<?php

namespace App\Mcp\Servers;

use App\Mcp\Prompts\PlanMyDayPrompt;
use App\Mcp\Resources\TaskStatsResource;
use App\Mcp\Tools\CompleteTaskTool;
use App\Mcp\Tools\CreateTaskTool;
use App\Mcp\Tools\ListTasksTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;

#[Name('Task Server')]
#[Version('1.0.0')]
#[Instructions('This server manages a simple to-do list. Use list-tasks to see tasks, create-task to add one, and complete-task to mark a task as done.')]
class TaskServer extends Server
{
    protected array $tools = [
        ListTasksTool::class,
        CreateTaskTool::class,
        CompleteTaskTool::class,
    ];

    protected array $resources = [
        TaskStatsResource::class,
    ];

    protected array $prompts = [
        PlanMyDayPrompt::class,
    ];
}

The #[Instructions] text is sent to the client when it connects. Use it to explain what your server is for and how the tools fit together.

Step 6: Register the Server Routes

Open routes/ai.php and register the server. Laravel MCP supports two kinds of servers:

  • Web servers (Mcp::web) are reached over HTTP. Use these for remote clients or a deployed app.
  • Local servers (Mcp::local) run as an Artisan command over standard input/output. Use these for AI tools on your own machine, like Cursor or Claude Code.

routes/ai.php

<?php

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

Mcp::web('/mcp/tasks', TaskServer::class);

Mcp::local('tasks', TaskServer::class);

I registered the same server both ways so you can try either one. For a real app, keep only the one you need.

Step 7: Test the Server

7.1 Using the MCP Inspector

Laravel MCP comes with a command that launches the official MCP Inspector, a browser UI where you can click through your tools, resources, and prompts. It needs Node.js installed, because it runs the inspector with npx:

# Local server named "tasks"
php artisan mcp:inspector tasks

# Web server (run "php artisan serve" first)
php artisan mcp:inspector mcp/tasks

7.2 Using cURL

Under the hood, MCP is just JSON-RPC. Start your app with php artisan serve, then call the create-task tool directly:

curl -X POST http://127.0.0.1:8000/mcp/tasks \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create-task","arguments":{"title":"Write the weekly report","priority":"high"}}}'

You should get back a JSON-RPC result whose content contains the text Task #1 “Write the weekly report” was created with high priority. Try "method":"tools/list" to see all three tools with their schemas, or leave out the title to see the validation error message come back with "isError": true.

Step 8: Connect It to Cursor or Claude Code

Now for the fun part. To use the local server in Cursor, create a .cursor/mcp.json file in your project (or add it to your global Cursor MCP settings). Use the full path to your project’s artisan file:

.cursor/mcp.json

{
  "mcpServers": {
    "laravel-tasks": {
      "command": "php",
      "args": ["/path/to/your-project/artisan", "mcp:start", "tasks"]
    }
  }
}

Claude Desktop uses the same mcpServers format in its config file. If you use Claude Code, you can add the local server, or the web server while php artisan serve is running, from the terminal:

# Local (stdio) server
claude mcp add laravel-tasks -- php /path/to/your-project/artisan mcp:start tasks

# Web (HTTP) server
claude mcp add --transport http laravel-tasks http://127.0.0.1:8000/mcp/tasks

Restart the client, then try asking things like “Add a high priority task to deploy the new release”, “What tasks do I still have?”, or “Mark task 1 as done”. The AI will pick the right tool, fill in the arguments, and show you the result. You don’t need to start the local server yourself, since the client runs mcp:start for you.

Step 9: Write Automated Tests

Laravel MCP lets you call a tool straight from the server class in your tests, so you don’t need an AI model or an HTTP client. Create a test file:

tests/Feature/TaskServerTest.php

<?php

namespace Tests\Feature;

use App\Mcp\Servers\TaskServer;
use App\Mcp\Tools\CompleteTaskTool;
use App\Mcp\Tools\CreateTaskTool;
use App\Mcp\Tools\ListTasksTool;
use App\Models\Task;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class TaskServerTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_a_task(): void
    {
        $response = TaskServer::tool(CreateTaskTool::class, [
            'title' => 'Write the weekly report',
            'priority' => 'high',
        ]);

        $response->assertOk()->assertSee('Write the weekly report');

        $this->assertDatabaseHas('tasks', ['title' => 'Write the weekly report', 'priority' => 'high']);
    }

    public function test_it_lists_pending_tasks(): void
    {
        Task::create(['title' => 'Buy coffee']);
        Task::create(['title' => 'Old task', 'is_completed' => true]);

        TaskServer::tool(ListTasksTool::class, ['status' => 'pending'])
            ->assertOk()
            ->assertSee('Buy coffee')
            ->assertDontSee('Old task');
    }

    public function test_it_completes_a_task(): void
    {
        $task = Task::create(['title' => 'Deploy the app']);

        TaskServer::tool(CompleteTaskTool::class, ['id' => $task->id])
            ->assertOk()
            ->assertSee('is now completed');

        $this->assertTrue($task->fresh()->is_completed);
    }

    public function test_it_returns_an_error_for_a_missing_task(): void
    {
        TaskServer::tool(CompleteTaskTool::class, ['id' => 999])
            ->assertHasErrors();
    }
}

Run the tests:

php artisan test --filter=TaskServerTest

All four tests should pass. Now you can change your tools without worrying about breaking what the AI depends on.

Securing Your Web MCP Server

Our /mcp/tasks route is public, which is fine on your own machine but not on a live server. Anyone could add or complete tasks. Before you deploy, protect it with middleware, just like a normal route. For example, with Laravel Sanctum tokens: Mcp::web('/mcp/tasks', TaskServer::class)->middleware('auth:sanctum');. Laravel MCP also supports OAuth 2.1 through Laravel Passport, which is what many MCP clients expect for remote servers. Inside a tool, $request->user() gives you the logged-in user for permission checks.

Conclusion

And that’s it! You now have a working Laravel 13 MCP server with three tools, a resource, and a prompt, tested with PHPUnit and ready to plug into Cursor, Claude Code, or any other MCP client. The same pattern works for anything in your app: orders, users, reports, or blog posts. Just wrap the action in a tool with a clear description and good validation. If you want to see the other side, where Laravel itself runs the agent, check out my AI Agents Crash Course. Happy coding!