Stu Mason
Stu Mason
Guide

Building MCP Servers in Laravel: Give Claude Access to Your App

Stuart Mason7 min read

MCP — Model Context Protocol — is one of those things that sounds like hype until you actually build with it. Then it clicks. It's a standardised way for AI assistants to interact with your applicatio

Building MCP Servers in Laravel: Give Claude Access to Your App

MCP — Model Context Protocol — is one of those things that sounds like hype until you actually build with it. Then it clicks. It's a standardised way for AI assistants to interact with your application: reading data, running queries, calling functions, and understanding your domain.

Laravel has first-party support via the laravel/mcp package, and having built several MCP servers now, I can tell you it's one of the more interesting things to land in the Laravel ecosystem recently.

What MCP Actually Is

In simple terms: MCP is a protocol that lets AI assistants (Claude, primarily) talk to your application. Instead of you copying data into a prompt, you give Claude tools it can call and resources it can read. Claude decides when and how to use them based on the conversation.

An MCP server exposes three things:

  1. Tools — Functions Claude can call. "Search the database," "create a record," "run a report."
  2. Resources — Data Claude can read. "Here's the schema," "here's the current configuration."
  3. Prompts — Pre-built prompt templates with your domain knowledge baked in.

Setting It Up

Install the package:

composer require laravel/mcp

Publish the config and register your server in routes/ai.php:

use Illuminate\Support\Facades\Route;
use Laravel\Mcp\Facades\Mcp;

Mcp::web();

That's it for the basic setup. Your MCP server is now available over HTTP streaming. The Mcp::web() call registers the necessary routes.

Building Tools

Tools are where MCP gets interesting. A tool is a function that Claude can call with parameters. Here's a real example — a tool that searches articles:

use Laravel\Mcp\Facades\Mcp;

Mcp::tool(
    name: 'search-articles',
    description: 'Search published articles by keyword. Returns title, slug, and excerpt.',
    fn: function (string $query, int $limit = 10): array {
        return Article::query()
            ->published()
            ->where(function ($q) use ($query) {
                $q->where('title', 'like', "%{$query}%")
                    ->orWhere('body', 'like', "%{$query}%");
            })
            ->limit($limit)
            ->get()
            ->map(fn (Article $article) => [
                'title' => $article->title,
                'slug' => $article->slug,
                'excerpt' => Str::limit(strip_tags($article->body), 200),
                'published_at' => $article->published_at->toDateString(),
            ])
            ->all();
    },
);

Claude sees the tool name, description, and parameter types. When a user asks "what articles have you written about Laravel?", Claude calls this tool with query: "Laravel" and uses the results to form a response.

The description matters enormously. Claude uses it to decide when to call the tool. Be specific about what the tool does and what it returns. Vague descriptions lead to Claude using tools at the wrong times.

A More Complex Tool Example

Here's a tool from DevTrends that analyses GitHub activity patterns:

Mcp::tool(
    name: 'developer-activity',
    description: 'Get a developer activity summary including recent commits, languages used, and contribution patterns. Use this when asked about what someone has been working on or their development patterns.',
    fn: function (string $period = '30d'): array {
        $days = match ($period) {
            '7d' => 7,
            '30d' => 30,
            '90d' => 90,
            default => 30,
        };

        $since = now()->subDays($days);

        $commits = Commit::query()
            ->where('committed_at', '>=', $since)
            ->with('repository')
            ->latest('committed_at')
            ->get();

        $byRepo = $commits->groupBy('repository.name')
            ->map(fn ($group) => [
                'commits' => $group->count(),
                'latest' => $group->first()->committed_at->toDateString(),
            ])
            ->sortByDesc('commits');

        $byLanguage = $commits->groupBy('repository.primary_language')
            ->map->count()
            ->sortDesc();

        return [
            'period' => $period,
            'total_commits' => $commits->count(),
            'repositories' => $byRepo->all(),
            'languages' => $byLanguage->all(),
            'most_active_day' => $commits
                ->groupBy(fn ($c) => $c->committed_at->format('l'))
                ->map->count()
                ->sortDesc()
                ->keys()
                ->first(),
        ];
    },
);

This gives Claude rich context about development patterns. When someone asks "what have you been working on this month?", Claude calls this tool, gets the data, and synthesises a natural response.

Exposing Resources

Resources are read-only data that Claude can access for context. Unlike tools, resources aren't "called" — they're available for Claude to read when it needs background information.

use Laravel\Mcp\Facades\Mcp;

Mcp::resource(
    uri: 'context://tech-stack',
    name: 'Tech Stack',
    description: 'The technologies and frameworks used across projects',
    fn: fn () => json_encode([
        'primary' => [
            'backend' => 'Laravel 12 (PHP 8.4)',
            'frontend' => 'React 19 + Inertia v2',
            'styling' => 'Tailwind CSS v4',
            'database' => 'PostgreSQL / MySQL',
            'testing' => 'Pest v4',
        ],
        'deployment' => [
            'hosting' => 'Coolify (self-hosted)',
            'ci' => 'GitHub Actions',
            'monitoring' => 'Laravel Telescope + Horizon',
        ],
    ]),
);

Resources are great for providing context that doesn't change often: your tech stack, your service descriptions, your team structure.

Prompts

Prompts are pre-built templates that combine your domain knowledge with Claude's capabilities:

Mcp::prompt(
    name: 'project-estimate',
    description: 'Generate a rough project estimate based on requirements',
    fn: function (string $requirements): string {
        return <<<PROMPT
        You are a senior Laravel developer with 16 years of experience.
        Based on the following requirements, provide a rough time estimate
        broken down by major feature area. Be realistic — include time for
        testing, deployment setup, and documentation.

        Requirements:
        {$requirements}

        Provide the estimate in days, with a range (optimistic to pessimistic).
        PROMPT;
    },
);

Organising Tools in Service Providers

For anything beyond a simple server, I organise tools into dedicated classes and register them in a service provider:

// app/Mcp/Tools/SearchArticlesTool.php
final class SearchArticlesTool
{
    public function __construct(
        private readonly ArticleRepository $articles,
    ) {}

    public function __invoke(string $query, int $limit = 10): array
    {
        return $this->articles
            ->search($query, $limit)
            ->map(fn (Article $a) => ArticleData::fromModel($a))
            ->all();
    }
}
// app/Providers/McpServiceProvider.php
public function boot(): void
{
    Mcp::tool(
        name: 'search-articles',
        description: 'Search published articles by keyword.',
        fn: app(SearchArticlesTool::class),
    );

    // ... more tools
}

This keeps the routes/ai.php file clean and lets you inject dependencies properly.

Testing MCP Servers

One of the best things about Laravel's MCP implementation is that it's testable. You can test your tools like any other feature:

it('searches articles by keyword', function () {
    Article::factory()
        ->published()
        ->create(['title' => 'Building with Laravel']);

    Article::factory()
        ->published()
        ->create(['title' => 'React Components']);

    $result = Mcp::call('search-articles', [
        'query' => 'Laravel',
    ]);

    expect($result)
        ->toHaveCount(1)
        ->and($result[0]['title'])
        ->toBe('Building with Laravel');
});

it('limits search results', function () {
    Article::factory()
        ->published()
        ->count(20)
        ->create(['title' => 'Laravel Article']);

    $result = Mcp::call('search-articles', [
        'query' => 'Laravel',
        'limit' => 5,
    ]);

    expect($result)->toHaveCount(5);
});

Test your tools the way you'd test any feature: happy paths, edge cases, and error conditions.

Security Considerations

MCP servers expose your application's data and functionality. Think carefully about what you expose:

Scope your tools. Don't create a "run any SQL query" tool. Create specific tools for specific actions. "Search articles" is fine. "Execute arbitrary database query" is not.

Respect authorisation. If a tool accesses user-specific data, check permissions. MCP requests can carry authentication context — use it.

Limit data exposure. Don't return entire Eloquent models. Use DTOs or explicit array structures that only include the fields you want to expose. You don't want Claude accidentally revealing a user's email address or internal IDs.

Rate limit. MCP tools can be called frequently. Apply rate limiting to prevent abuse, especially on tools that hit the database.

Mcp::tool(
    name: 'search-articles',
    description: 'Search published articles.',
    fn: function (string $query): array {
        // Only return public fields
        return Article::query()
            ->published()  // Only published — never drafts
            ->select(['title', 'slug', 'excerpt', 'published_at'])
            ->where('title', 'like', "%{$query}%")
            ->limit(10)    // Always limit results
            ->get()
            ->toArray();
    },
);

What I've Learned From Production

A few things I've learned from running MCP servers in production:

Tool descriptions are your API docs. Claude reads them to decide what to call. If your description is vague, Claude will use the tool at the wrong time. If it's too narrow, Claude won't use it when it should. Spend time on descriptions.

Fewer, better tools beat many mediocre ones. Five well-designed tools that cover the key use cases are better than twenty tools that overlap and confuse the model.

Return structured data, not prose. Claude is great at turning structured data into natural language. Give it arrays and objects, not pre-formatted strings. Let Claude do the formatting.

Test with real conversations. Unit tests verify the tools work. But you also need to actually use the MCP server in Claude to verify the experience is good. Sometimes a tool that works perfectly in tests produces awkward conversational results because the description misleads the model.

MCP is still early, but it's already changed how I think about building applications. Your app isn't just for humans any more — it's an API for AI assistants too. Build accordingly.


I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.