DEV Community

Cover image for Connect a Laravel AI Agent to Any MCP Server: A Hands-On Guide
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Connect a Laravel AI Agent to Any MCP Server: A Hands-On Guide

Originally published at hafiz.dev


Most Laravel MCP tutorials teach you to build a server. You expose your app's tools, an AI client connects, and Claude or Cursor can query your orders table. Useful, and I've covered building an MCP server with Filament before.

This post goes the other direction. Your Laravel AI agent becomes the client. It connects out to any MCP server, GitHub, Notion, Laravel Nightwatch, or one you run locally, and uses that server's tools as if you'd written them by hand. If you've built an agent with the Laravel AI SDK already, this slots straight into the agent you have. Ask your agent to "look into the latest exception in my app" and it browses your Nightwatch issues, pulls the stack trace, and reports back. You wrote none of that integration code.

Laravel shipped this on June 9, 2026. It's the piece that turns an agent from something that only knows your code into something that can reach into every tool your team already uses. If you've been giving your agent context with Laravel Boost and MCP, this is the reverse: your agent reaching out instead of a tool reaching in. Here's how to wire it up, from a no-auth local server to a full OAuth flow, plus the gotchas that'll bite you in production.

What Actually Ships in the Box

The feature is two packages working together, and the split matters.

The MCP client lives in laravel/mcp. It knows how to connect to a server, negotiate the handshake, authenticate, and call tools. It works on its own, from a queued job or a console command, with no agent anywhere in sight.

The thin integration lives in laravel/ai. It lets an agent consume that client without changing how agents already work. You drop MCP tools into the same tools() array you already use, and the SDK wraps each one to fit the agent's tool contract.

Before any code, pin your versions. As of late June 2026:

  • laravel/mcp v0.8.1 (the 0.8 line introduced the client; earlier versions were server-only)
  • laravel/ai v0.8.1
  • Laravel 12 or 13
  • PHP 8.3 or higher

That PHP floor matters. laravel/mcp itself tolerates PHP 8.2, but laravel/ai requires 8.3, and you need both for this. If you're on 8.2, Composer will refuse to resolve. Check what you've got:

composer show laravel/mcp laravel/ai
Enter fullscreen mode Exit fullscreen mode

The Client Surface

The whole client is three verbs: connect, list tools, call a tool. Everything else stays out of your way.

use Laravel\Mcp\Client;

// A remote server over HTTP
$client = Client::web('https://mcp.example.com');

// A local server you run as a process (STDIO)
$client = Client::local('php', ['artisan', 'mcp:start']);
Enter fullscreen mode Exit fullscreen mode

Those two constructors are the two transports. web() uses streamable HTTP for remote servers you reach over the network. local() uses STDIO (standard input/output) for servers you run as a child process on the same machine. You pick by where the server lives, not by preference.

Listing and calling tools looks like this:

$tools = $client->tools();

$result = $client->callTool('search-issues', [
    'query' => 'payment failed',
]);

$result->text();             // the text content
$result->isError;            // did the tool report a failure
$result->structuredContent;  // structured payload, if the tool returned one
Enter fullscreen mode Exit fullscreen mode

The connection happens lazily on the first real call, so you don't have to manage it manually. But you can if you want:

$client->connect();
$client->ping();
$client->disconnect();
Enter fullscreen mode Exit fullscreen mode

During that connection, the client and server negotiate a protocol version and exchange capabilities, so both sides agree on the rules before any work happens. You don't write any of that. It's the handshake, and when it fails (more on that later) it's usually an auth or version mismatch underneath.

Step 1: A Local Server With No Auth

Start with the simplest possible case so you see the wiring before the complexity. Point an agent at a local MCP server (yours, or any STDIO server) with no authentication at all.

Inside your agent, the MCP tools go straight into the tools() method, right next to your hand-written tools:

use App\Ai\Tools\SendSlackMessage;
use Laravel\Mcp\Client;

public function tools(): iterable
{
    return [
        ...Client::local('php', ['artisan', 'mcp:start'])->tools(),
        new SendSlackMessage,
    ];
}
Enter fullscreen mode Exit fullscreen mode

That's the entire integration. The spread operator drops every tool from the MCP server into the array. The SDK notices they're MCP tools and wraps each one: it translates the MCP input schema into Laravel's JSON schema, calls the remote tool when the model asks for it, and normalizes whatever comes back into a result the model can read. The model has no idea which tools came from MCP and which you wrote. They all look the same to it.

This is the mental model for the whole feature. If you can get one local server connected, every other server is just a different transport and an auth layer on top.

Step 2: Bearer Token Auth

Most useful servers want to know who you are. The simpler auth path is a bearer token, a string you already hold. A GitHub personal access token is the classic example.

use Laravel\Mcp\Client;

$client = Client::web('https://api.githubcopilot.com/mcp/')
    ->withToken($token);
Enter fullscreen mode Exit fullscreen mode

You can pass a closure instead of a raw string, which is what you want when the token belongs to the currently authenticated user:

$client = Client::web('https://api.githubcopilot.com/mcp/')
    ->withToken(fn () => auth()->user()->github_token);
Enter fullscreen mode Exit fullscreen mode

Drop it into the agent the same way:

public function tools(): iterable
{
    return [
        ...Client::web('https://api.githubcopilot.com/mcp/')
            ->withToken(fn () => auth()->user()->github_token)
            ->tools(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

Now your agent can search repositories, read CI logs, triage issues, and check Dependabot alerts. GitHub's server even supports a read-only mode through a request header if you'd rather the agent not change anything.

Step 3: Full OAuth (The Real-World Case)

Bearer tokens are fine when you already have a token. But hosted servers like Laravel Nightwatch use OAuth, where the user clicks a button, approves access on the provider's site, and comes back with a token your app stores. (If you're weighing Nightwatch against the alternatives, I compared Telescope vs Pulse vs Nightwatch separately.) This is the part the official announcement glosses over, so here's the whole flow.

Register a named client, usually in a service provider's boot() method:

use Laravel\Mcp\Client;
use Laravel\Mcp\Facades\Mcp;

Mcp::registerClient('nightwatch', fn () => Client::web('https://nightwatch.laravel.com/mcp')
    ->withOAuth(
        clientId: config('services.nightwatch_mcp.client_id'),
        clientSecret: config('services.nightwatch_mcp.client_secret'),
    ));
Enter fullscreen mode Exit fullscreen mode

If the server supports dynamic client registration, you can omit the ID and secret entirely and the client registers itself. Whether you can do that depends on the server, which becomes important when things break.

Next, wire the connect and callback routes in routes/ai.php. The package gives you a helper that registers both:

use Laravel\Mcp\Client\OAuth\TokenSet;
use Laravel\Mcp\Facades\Mcp;

Mcp::oAuthRoutesFor('nightwatch', function (string $client, TokenSet $token) {
    auth()->user()->update([
        'nightwatch_mcp_token' => $token->accessToken,
    ]);

    return redirect('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

This registers two named routes: mcp.oauth.nightwatch.connect and mcp.oauth.nightwatch.callback. The connect route redirects the user to the authorization server. The callback route exchanges the authorization code and runs your closure with a fresh token. You never touch the redirect URLs or the PKCE details.

Then a button in your Blade view kicks it off:

<a href="{{ route('mcp.oauth.nightwatch.connect') }}">
    Connect Nightwatch
</a>
Enter fullscreen mode Exit fullscreen mode

The flow runs in order: the user clicks the button, the package redirects them to Nightwatch, they sign in and approve, Nightwatch sends them back to your callback route, your closure runs with the token, and you store it however you like. The package owns the OAuth choreography. Your app owns where the token lives.

One thing to be clear about: there's no built-in token store. You persist $token->accessToken yourself, and you read it back when you build the client. The package handles refreshing expired tokens, but storage is on you.

Once the token's stored, the agent uses the named client:

public function tools(): iterable
{
    return [
        ...Mcp::client('nightwatch')->tools(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

Now ask the agent: "What are the most recent exceptions in production? Help me prioritize which to fix first." It browses your issues, reads stack traces, and answers. Tell it "mark issue 123 as resolved with a comment summarizing the fix," and it does that too.

How the Agent Sees MCP Tools

Here's the flow from prompt to remote tool call and back, so you know what's happening when the model decides to use a Nightwatch tool.

View the interactive diagram on hafiz.dev

The key insight is in the middle. By the time the model is choosing, native and MCP tools sit in the same list and look identical. The wrapping, the schema translation, the network call, the result normalization, all of it happens below the model's awareness.

Caching the Tool List

Listing tools is a network round trip on a remote server, and the tool list rarely changes. So cache it. Because tools come back as plain data, you wrap the call in Laravel's cache and they rehydrate cleanly:

use Illuminate\Support\Facades\Cache;

$tools = Cache::remember('mcp.nightwatch.tools', now()->addHour(), function () {
    return Mcp::client('nightwatch')->tools();
});
Enter fullscreen mode Exit fullscreen mode

Worth knowing: this isn't a dedicated client feature, despite what some descriptions imply. It's standard Laravel caching wrapped around the tools() call. The win is real though: you skip a round trip on every single prompt, which matters most for OAuth-secured remote servers where each call carries auth overhead.

Testing Without a Live Server

You don't want your test suite hitting a real MCP server over the network. Laravel AI's faking layer lets you script the model's responses while the tool call still flows through the MCP layer.

MCP tool names follow a mcp_tools_<name> pattern. A server tool called search shows up to the agent as mcp_tools_search. So you can fake a model response that calls the tool, then a final answer, and assert on the result:

use Laravel\Ai\Facades\Ai;

it('investigates exceptions through the nightwatch agent', function () {
    Ai::fake([
        Ai::toolCall('mcp_tools_search-issues', ['query' => 'payment']),
        Ai::text('Found 3 payment exceptions. The most frequent is a Stripe timeout.'),
    ]);

    $response = (new SupportAgent)->prompt('Any payment errors lately?');

    expect((string) $response)->toContain('Stripe timeout');
});
Enter fullscreen mode Exit fullscreen mode

You test the production path (the agent calling a tool, reading the result, responding) without a single network call.

The Gotchas That'll Actually Bite You

The happy path is clean. Here's what isn't, drawn from real issues developers have hit.

OAuth handshake failures are the big one. There's a documented case (laravel/boost issue #584) where connecting Nightwatch through certain agents threw 403 Forbidden ... when send initialize request before auth, and handshake decoding errors after. The reported cause: the client didn't detect that auth was required and never triggered the login flow. Two fixes came out of that thread. Run an explicit login for your agent if it supports one. Or route through the mcp-remote bridge instead of pointing directly at the URL, which is what Nightwatch's own docs recommend for several clients. If a direct connection fails the handshake, try the bridge before assuming your code is wrong.

Dynamic client registration isn't universal. Remember the OAuth step where you could omit the client ID and secret? That only works if the server supports dynamic client registration. Several servers require a pre-registered OAuth client, and trying to auto-register against them fails, often as a 403 that looks like an auth bug. Know which mode your target expects: if it doesn't do dynamic registration, you must supply clientId and clientSecret explicitly.

Token storage is your job, and it's easy to get wrong. The package refreshes tokens but doesn't store them. The common mistake is persisting the token somewhere your withToken() closure can't read it back, or not persisting it at all and wondering why every request re-prompts for auth.

Protocol version mismatches surface as handshake failures. Servers advertise different MCP spec versions. If a server only speaks a version the client doesn't negotiate, the connection fails at the handshake. The error won't always say "version," so keep it on your list of suspects when a connection won't establish.

Remote tool errors don't throw into your code. When a remote tool fails, the result carries isError rather than raising an exception in your agent loop. The wrapper normalizes the failure into something the model can read and react to. Check isError if you're handling results manually.

When to Use This (and When Not To)

Reach for an MCP client connection when the tool already exists as an MCP server and you'd otherwise be writing an API integration by hand. Nightwatch, GitHub, Notion, and a growing list of services expose MCP servers. Consuming one is a few lines; rebuilding its integration is a few days.

Skip it when a plain API call or a tool class you write yourself is simpler. If you only need one endpoint from a service, a small hand-written tool beats pulling in an entire MCP connection and its auth flow. MCP earns its place when you want many of a server's capabilities, or when the server is something like Nightwatch where the tools are rich enough to be worth the wiring.

And if you're building tools for your own app, remember you can reuse your laravel/mcp server tool classes directly in an agent with no client at all. Write the tool once, use it as a server tool for external clients and as a native agent tool internally. It's the same instinct behind making your whole Laravel app AI-agent friendly: build the capability once, expose it everywhere.

FAQ

Do I need to build an MCP server to use this?

No. This is the opposite direction. You're consuming external servers as a client. You don't expose anything. If you also want to expose your own app's tools to outside AI clients, that's the server side, which is a separate setup.

Which transports does the client support?

Two: STDIO for local servers you run as a process (Client::local(...)), and streamable HTTP for remote servers you reach over the network (Client::web(...)). You choose based on where the server runs, not preference.

Can I connect to multiple MCP servers in one agent?

Yes. Spread multiple clients into the same tools() array, mixing transports and auth freely. A local STDIO server and a remote OAuth server can both feed one agent, alongside your hand-written tools. The model treats them all identically.

How do I handle a server that needs OAuth but my client runs headless?

OAuth assumes a human approves access in a browser. For background or headless work, some servers support a client-credentials grant (no user in the loop), which the package handles. Others, like Notion's remote server, explicitly aren't built for headless agents, and you'd need a different auth path or a token issued ahead of time.

Does this work with Laravel 11?

The laravel/mcp client supports Laravel 11, 12, and 13, but laravel/ai requires Laravel 12 or 13 and PHP 8.3+. Since the agent integration needs both, you effectively need Laravel 12 or 13 to use an agent as an MCP client.

Wrapping Up

The agent-as-client direction is the quieter half of Laravel's MCP story, and the more useful one for most apps. Building a server exposes your app to AI tools. Consuming servers gives your agent reach into every tool your team already runs, with a few lines instead of a custom integration per service. Start with a local no-auth server to internalize the tools() pattern, graduate to a bearer token, then wire the full OAuth flow when you connect something like Nightwatch. And keep the troubleshooting section handy, because the handshake and OAuth edge cases are where the real time goes.

If you're building agent features into a Laravel app and want help getting the MCP layer right, let's talk.

Top comments (0)