Give Laravel AI SDK Agents External Tools with MCP Servers

Connect a Laravel AI SDK agent to an MCP server. Register a client over stdio or HTTP, authenticate with bearer or OAuth, and drop its tools into your agent.

Steven Richardson
Steven Richardson
· 8 min read

Hand-writing a typed tool for every capability an agent needs gets old fast. Half the servers you'd want to call — GitHub, Notion, Nightwatch, your own internal service — already expose their tools over MCP. This is the Laravel AI SDK MCP client story: point an agent at an MCP server, let it discover the tools, and call them as if you'd written each one yourself. It's the inverse of exposing your own app as a server — here your agent is the client.

Install the Laravel AI SDK and MCP client#

The MCP client that powers this lives in laravel/mcp, not in the AI package. The Laravel team deliberately put it there so a queued job or a console command can talk to an MCP server with no agent in sight. So you need both packages: the AI SDK for the agent, and MCP for the client.

composer require laravel/ai laravel/mcp

Make sure you already have a provider configured for the AI SDK (an API key in your .env and a default provider). If you're new to building agents, my complete guide to typed function tools in the Laravel AI SDK walks through a working agent you can attach MCP tools to.

Register an MCP client for your server#

The client speaks the two transports that matter in practice: streamable HTTP for remote servers you reach over the network, and stdio for local servers you run as a process. For a remote server, use Client::web() with the URL:

use Laravel\Mcp\Client;

$client = Client::web('https://mcp.example.com');

For a local server you run yourself — including your own Laravel MCP server — use Client::local(), passing the command and its arguments:

use Laravel\Mcp\Client;

$client = Client::local('php', ['artisan', 'mcp:start']);

The connection is lazy. Nothing happens until you list or call a tool, at which point the client performs the handshake and negotiates the protocol version. If you're reaching for the same server in more than one place, register a named client in a service provider's boot method instead of constructing it each time:

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

public function boot(): void
{
    Mcp::registerClient('github', fn () => Client::web('https://mcp.example.com'));
}

Named clients resolve once per request and disconnect automatically at the end of the request lifecycle, so you don't leak connections.

Authenticate the MCP connection#

Most MCP servers worth talking to want to know who you are. For a bearer token, chain withToken(). Pass a string, or a closure that resolves the token lazily — useful when the token belongs to the currently authenticated user:

use Illuminate\Support\Facades\Auth;
use Laravel\Mcp\Client;

$client = Client::web('https://mcp.example.com')->withToken($token);

// Resolve per-request from the signed-in user:
$client = Client::web('https://mcp.example.com')->withToken(
    fn () => Auth::user()->mcpToken(),
);

For hosted servers that speak OAuth 2.1 — Nightwatch is one — configure the client with withOAuth(). The package owns the handshake, the redirects, and the PKCE details; your app only decides where the token lives:

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

Mcp::registerClient('github', fn () => Client::web('https://mcp.example.com')->withOAuth(
    clientId: config('services.github_mcp.client_id'),
    clientSecret: config('services.github_mcp.client_secret'),
));

Then register the OAuth routes for that named client in routes/ai.php. The closure receives the client name and a TokenSet once the authorization code has been exchanged, and it's your job to store the token:

use Illuminate\Support\Facades\Auth;
use Laravel\Mcp\Client\OAuth\TokenSet;
use Laravel\Mcp\Facades\Mcp;

Mcp::oAuthRoutesFor('github', function (string $client, TokenSet $token) {
    Auth::user()->update([
        'github_mcp_token' => $token->accessToken,
    ]);

    return redirect('/dashboard');
});

That call gives you two named routes, mcp.oauth.github.connect and mcp.oauth.github.callback. Point a button at the connect route to kick off the flow:

return redirect()->route('mcp.oauth.github.connect');

If the server supports dynamic client registration, you can omit clientId and clientSecret entirely and the client registers itself.

Attach the MCP tools to your agent#

Here's the part that ties it together, and it required no change to how agents already work. Your agent already has a tools() method. An MCP client's tools() method returns a collection, so you spread it into that array with the ... operator, right next to your own tools:

use App\Ai\Tools\SendSlackMessage;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Laravel\Mcp\Facades\Mcp;
use Stringable;

class SupportAgent implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You help triage support issues. Use the available tools to look things up.';
    }

    public function tools(): iterable
    {
        return [
            ...Mcp::client('github')->tools(),

            new SendSlackMessage,
        ];
    }
}

The SDK wraps each MCP tool to fit the agent's tool contract — it translates the MCP input schema into Laravel's JSON schema and normalises whatever comes back. The model has no idea which tools came from MCP and which you wrote by hand. You can mix transports freely, too:

public function tools(): iterable
{
    return [
        ...Mcp::client('github')->tools(),
        ...Client::local('php', ['artisan', 'mcp:start'])->tools(),
    ];
}

Run the agent and let it call MCP tools#

With the tools attached, you invoke the agent exactly as you always have — call prompt() and let the model decide which tools to reach for. Ask it something that only the MCP server can answer, and it will browse, call the tool, and report back:

$response = (new SupportAgent)->prompt('Look into the latest exception in my app.');

return $response->text();

When you want to call a tool yourself without the model in the loop — say, from a job or a console command — the client exposes callTool() directly. The returned ToolResult gives you the text and any structured content:

$result = Mcp::client('github')->callTool('list-issues', [
    'state' => 'open',
]);

$result->text();              // Text content of the response
$result->isError;             // Whether the tool reported an error
$result->structuredContent;   // Structured payload, if any

That structured payload is worth leaning on when you need a parseable result rather than prose — the same discipline I cover in returning structured JSON from Laravel AI SDK agents.

Cache tool discovery and handle connection failures#

Listing tools is a round trip to the server, and you do not want to pay that on every prompt — especially for a remote server behind OAuth. The tool list rarely changes, so cache it. Because the tools come back as plain data, they rehydrate cleanly and a cached tool behaves exactly like a freshly listed one:

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

public function tools(): iterable
{
    return Cache::remember('mcp.github.tools', now()->addHour(), function () {
        return Mcp::client('github')->tools();
    });
}

For a remote server, always set a timeout so a slow or unreachable endpoint doesn't hang a request or a queued job. Wrap the discovery call so a dead server degrades to the agent's native tools instead of throwing:

$client = Client::web('https://mcp.example.com')->withTimeout(10);

If your agent runs on a queue — which is where I put anything that calls an external server — pair this with sensible retries and backoff. I go deeper on that in running Laravel AI SDK agents in the background on a queue, including the client-credentials grant for auth with no user present.

Test the agent without a live MCP server#

You do not need a real server running to test an agent that uses one. The AI SDK's faking layer lets you queue the model's responses, so you script a tool call followed by a final answer and assert on the result. The key detail: an MCP tool shows up under the mcp_tools_<name> naming pattern, so a tool called search is asserted as mcp_tools_search. The tool call still flows through the MCP layer, so you're exercising the same path production uses, just without the network. My write-up on faking Laravel AI SDK agents in Pest covers the full setup.

Reuse MCP tools you already wrote#

If you've already written tools for your own Laravel MCP server, there's a nice symmetry: you can hand those exact tool classes to an agent and reuse them directly, no client connection involved. Write the tool once, expose it to outside clients and power your own agents with it. From here, wire memory into the agent so it remembers a conversation across MCP calls with conversation memory in the Laravel AI SDK, and add provider fallback and failover so a flaky model doesn't take the whole flow down with it.

FAQ#

What is MCP in the Laravel AI SDK?

MCP (Model Context Protocol) is an open standard for how AI models connect to external tools and services. In the Laravel AI SDK, an MCP client lets your agent discover and call the tools a server exposes without you hand-writing each one. The client itself lives in the laravel/mcp package, and the AI SDK adds a thin layer that lets an agent consume those tools alongside its own.

How do I connect a Laravel AI agent to an MCP server?

Register a client with Client::web('https://...') for a remote server or Client::local('php', ['artisan', 'mcp:start']) for a local one, then spread its tools() collection into your agent's tools() method using the ... operator. The SDK wraps each MCP tool automatically so the model can call it like any native tool. No change to the agent loop is required.

What is the difference between MCP tools and typed function tools?

A typed function tool is a PHP class you write in your own codebase, with a name, description, schema, and handle method that you fully control. An MCP tool is discovered at runtime from an external server — you don't write its logic, you just connect and let the model call it. Use typed tools for your own business logic and MCP tools to reuse capabilities another server already exposes; they sit side by side in the same tools() array.

Does the Laravel AI SDK support stdio and HTTP MCP servers?

Yes. The client speaks both transports: streamable HTTP for remote servers over the network via Client::web(), and standard input/output (stdio) for local servers you run as a process via Client::local(). You can mix both in a single agent, spreading tools from a remote HTTP server and a local stdio server into the same tools() array.

How do I authenticate an MCP server connection?

For a bearer token, chain withToken() on the client, passing either a string or a closure that resolves the token lazily. For servers that speak OAuth 2.1, use withOAuth() with a client ID and secret, then register the connect and callback routes with Mcp::oAuthRoutesFor() and store the returned TokenSet however you like. For background work with no user present, the client-credentials grant handles auth without a browser redirect.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.