DEV Community

Cover image for Putting an MCP server inside your Laravel app
Nasrul Hazim
Nasrul Hazim

Posted on

Putting an MCP server inside your Laravel app

TL;DR

  • An MCP server lets an AI assistant call your app's real actions instead of guessing at your database.
  • The clean way to add one to Laravel: a small Tool base class, a server that takes over one endpoint, and tools that reuse your existing policies + validation.
  • Result: list_contacts, pipeline_summary, my_tasks_today — named, authorised, testable operations, not raw SQL exposed to a model.

I spent today wiring an MCP server into a Laravel app — not a separate service, the app itself as the tool provider. Running it server-side (not on your laptop) changes what it's good for. Here's the structure I landed on, minus anything app-specific.

What MCP actually buys you

MCP (Model Context Protocol) is a standard way for an AI client to discover and call tools you expose. The win isn't magic — it's governance. Instead of handing a model database access and hoping, you expose a fixed menu of operations, each one:

  • named and described, so the model knows what it does,
  • validated on the way in,
  • and run through the same authorisation as your UI.

The model can only do what your tools allow. That's the whole point.

The shape: a base tool + a server

Every tool shares a contract — a name, a schema for its input, and a handle(). A small base class carries the boilerplate:

abstract class Tool
{
    abstract public function name(): string;

    abstract public function description(): string;

    /** @return array<string, mixed> JSON schema for the arguments */
    abstract public function schema(): array;

    /** @return array<string, mixed> the tool result */
    abstract public function handle(array $arguments): array;
}
Enter fullscreen mode Exit fullscreen mode

A concrete tool stays tiny — and crucially, it goes through your normal stack:

final class ListContacts extends Tool
{
    public function name(): string
    {
        return 'list_contacts';
    }

    public function description(): string
    {
        return 'List contacts the current user is allowed to see.';
    }

    public function schema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'search' => ['type' => 'string'],
                'limit'  => ['type' => 'integer', 'maximum' => 100],
            ],
        ];
    }

    public function handle(array $arguments): array
    {
        Gate::authorize('viewAny', Contact::class);

        $data = validator($arguments, [
            'search' => ['nullable', 'string'],
            'limit'  => ['nullable', 'integer', 'max:100'],
        ])->validate();

        return Contact::query()
            ->when($data['search'] ?? null, fn ($q, $s) => $q->search($s))
            ->limit($data['limit'] ?? 25)
            ->get()
            ->toArray();
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice there's no new permission model. The tool leans on the same Gate the web UI uses. One source of truth for "who can see what."

The server "takes over" one endpoint

The MCP server is a thin dispatcher: it registers the tools, answers the discovery call (what tools exist), and routes an invocation to the right handle().

final class Server
{
    /** @var array<string, Tool> */
    private array $tools = [];

    public function register(Tool $tool): void
    {
        $this->tools[$tool->name()] = $tool;
    }

    public function call(string $name, array $arguments): array
    {
        $tool = $this->tools[$name]
            ?? throw new UnknownToolException($name);

        return $tool->handle($arguments);
    }
}
Enter fullscreen mode Exit fullscreen mode

Point one route at it and you have a governed AI surface for the whole app. Register tools in a service provider, exactly like the driver/registry pattern — add a tool, it shows up; nothing else changes.

Why each tool being small matters: testing

Because a tool is just a class with handle(), you test it like any other unit — no AI client in the loop:

it('lists contacts for an authorised user', function () {
    actingAs($user = User::factory()->create());
    Contact::factory()->count(3)->create();

    $result = (new ListContacts())->handle(['limit' => 2]);

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

it('blocks an unauthorised user', function () {
    actingAs(User::factory()->withoutPermissions()->create());

    expect(fn () => (new ListContacts())->handle([]))
        ->toThrow(AuthorizationException::class);
});
Enter fullscreen mode Exit fullscreen mode

Takeaway

Exposing an app to an AI assistant isn't "give it the database." It's the opposite: a small, fixed, authorised set of tools, each reusing the policies and validation you already trust. The base-class + registry structure keeps it boring to extend and easy to test — which is exactly what you want on the surface an autonomous agent is allowed to touch.

Top comments (0)