AI SDK, MCP and Boost

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/ai 1.1.0, laravel/mcp 1.0.1 and laravel/boost 2.10.2. Three first-party packages cover the two sides of AI in a Laravel application:

  • the AI SDK calls language models from the application, through agents, tools and structured output;

  • Laravel MCP lets AI clients call into the application, as tools and resources over the Model Context Protocol;

  • Boost gives coding agents (Claude Code, Cursor, Copilot and others) guidelines, skills and an MCP server about the application itself.

This page covers only the Laravel APIs. Agent design, tool design, MCP and assisted development are explained in their own sections, which are linked below. No provider API key was available for this page, so Bookshelf talks to a small scripted "model" on 127.0.0.1:8040. The script speaks the OpenAI chat completions format: it asks for one tool call and then answers. Every request and response below really went through the SDK.

The AI SDK

composer require laravel/ai, then php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider" and php artisan migrate. This publishes config/ai.php and creates the conversation tables used by RemembersConversations. Provider keys go in .env (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY and others). Any OpenAI-compatible server (vLLM, LM Studio, a gateway, or the script here) uses the openai-compatible driver with a url:

# .env
AI_PROVIDER=openai-compatible
AI_EMBEDDINGS_PROVIDER=openai-compatible
OPENAI_COMPATIBLE_URL=http://127.0.0.1:8040
OPENAI_COMPATIBLE_MODEL=scripted-model

Each feature has its own default provider. The published config/ai.php sets default for text and separate default_for_embeddings, default_for_images, default_for_audio, default_for_reranking and others. With only default pointed at the local server, the first embeddings call still went to OpenAI (Could not connect to AI provider [openai]). Bookshelf reads both keys from the environment, which is why there are two lines above.

An agent with a tool

php artisan make:agent ReadingAdvisor --structured and php artisan make:tool SearchCatalogue generate the classes in app/Ai. The tool searches the catalogue. The agent may call it, and must answer in a fixed shape:

<?php
// app/Ai/Tools/SearchCatalogue.php
namespace App\Ai\Tools;

use App\Models\Book;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class SearchCatalogue implements Tool
{
    public function description(): Stringable|string
    {
        return 'Search the Bookshelf catalogue by author name. Returns at most five books as JSON.';
    }

    public function handle(Request $request): Stringable|string
    {
        $args = $request->validate([                                  // the schema is a hint, not a guarantee
            'author' => ['required', 'string', 'max:100'],
            'exclude' => ['nullable', 'string', 'max:200'],
        ]);

        return Book::query()
            ->whereRelation('author', 'name', 'like', '%'.$args['author'].'%')
            ->when($args['exclude'] ?? null, fn ($query, $title) => $query->where('title', '!=', $title))
            ->limit(5)
            ->get(['title', 'published_year'])
            ->toJson();
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'author' => $schema->string()->description('Part of the author name')->required(),
            'exclude' => $schema->string()->description('A title to leave out'),
        ];
    }
}
<?php
// app/Ai/Agents/ReadingAdvisor.php
namespace App\Ai\Agents;

use App\Ai\Tools\SearchCatalogue;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Stringable;

#[MaxSteps(4)]                // at most four model round trips per prompt
#[Timeout(20)]
class ReadingAdvisor implements Agent, HasStructuredOutput, HasTools
{
    use Promptable;

    public function __construct(public ?User $member = null) {}

    public function instructions(): Stringable|string
    {
        return 'You recommend one book from the Bookshelf catalogue. Only recommend books returned by the search tool.';
    }

    public function tools(): iterable
    {
        return [new SearchCatalogue];
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'title' => $schema->string()->required(),
            'reason' => $schema->string()->required(),
        ];
    }
}

Prompting it:

<?php
$response = ReadingAdvisor::make()->prompt('I loved Kindred. What should I read next?');

$response['title'];                 // array access on the structured output
$response->steps;                   // one entry per model round trip
$response->toolCalls;
$response->usage;
title: Parable of the Sower
reason: You liked Kindred, and Parable of the Sower is also by Octavia E. Butler.
class: Laravel\Ai\Responses\StructuredAgentResponse
steps: 2, tool calls: 1
usage: {"input_tokens":360,"output_tokens":60,"cache_read_input_tokens":null,"cache_write_input_tokens":null,"reasoning_tokens":null}

What goes over the wire

The scripted server saved each request body. The first one shows how the PHP classes are turned into the provider’s format. The tool becomes a JSON Schema function definition, and the output schema becomes response_format:

{
  "model": "scripted-model",
  "tool_choice": "auto",
  "tools": [{
    "type": "function",
    "function": {
      "name": "SearchCatalogue",
      "description": "Search the Bookshelf catalogue by author name. Returns at most five books as JSON.",
      "parameters": {
        "type": "object",
        "properties": {
          "author": { "description": "Part of the author name", "type": "string" },
          "exclude": { "description": "A title to leave out", "type": "string" }
        },
        "required": ["author"],
        "additionalProperties": false
      }
    }
  }],
  "messages": [
    { "role": "system", "content": "You recommend one book from the Bookshelf catalogue. Only recommend books returned by the search tool." },
    { "role": "user", "content": "I loved Kindred. What should I read next?" }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "schema_definition",
      "schema": {
        "type": "object",
        "properties": { "title": { "type": "string" }, "reason": { "type": "string" } },
        "required": ["title", "reason"],
        "additionalProperties": false
      },
      "strict": false
    }
  }
}

The model answered with a tool call. The SDK ran SearchCatalogue::handle() and sent a second request with two more messages, the model’s call and the tool’s result:

{ "role": "assistant", "tool_calls": [{ "id": "call_1", "type": "function",
    "function": { "name": "SearchCatalogue", "arguments": "{\"author\":\"Butler\",\"exclude\":\"Kindred\"}" } }] },
{ "role": "tool", "tool_call_id": "call_1",
  "content": "[{\"title\":\"Parable of the Sower\",\"published_year\":1993},{\"title\":\"Wild Seed\",\"published_year\":1980},{\"title\":\"Wild Seed\",\"published_year\":1980}]" }

Two consequences follow:

  • The tool result is model input. Whatever the tool returns, such as book titles, review text or user profiles, goes into the next prompt. A review that says "ignore your instructions" is text the model reads. Return only the fields the task needs, and treat the model’s output as untrusted. The defences are on Guardrails and Prompt Injection and Agent Guardrails and Safety.

  • The schema does not bind the model. When the script sent a call without author, the first version of the tool failed with ErrorException: Undefined array key "author". With $request->validate(), the model got The author field is required. as the tool result and could try again. strict: false in response_format means the provider does not enforce the output schema either. The #[Strict] attribute asks for strict mode where the provider supports it.

Embeddings, streaming and the rest

Str::of($text)->toEmbeddings() and Embeddings::for([...])->generate() call the embeddings endpoint. Against the script, which returns three-number toy vectors:

<?php
use Illuminate\Support\Str;
use Laravel\Ai\Embeddings;

Str::of('Kindred by Octavia E. Butler')->toEmbeddings();                                  // [0.28,0.5,0.1]
Embeddings::for(['Kindred by Octavia E. Butler', 'Dune by Frank Herbert'])->generate()->embeddings;
// [[0.28,0.5,0.1],[0.21,0,0.1]], sent as {"model":"scripted-embeddings","input":[...],"dimensions":3}

The vectors go into a vector column with the AsVector cast and are searched with whereVectorSimilarTo. The pgvector run on Redis, MongoDB and Search shows the column, the HNSW index and the query. Retrieval design (chunking, reranking, evaluation) is on RAG Systems.

The SDK covers more than this page runs:

  • ->stream() returns a response that a route can send as server-sent events, including the Vercel AI SDK protocol.

  • ->queue() runs the prompt in a job.

  • RemembersConversations stores the history in the published tables.

  • Provider tools add web search, web fetch and code execution. Sub-agents and MCP tools can be added as well.

  • Human approval can be required before a tool runs.

  • There are also images, audio, transcription, reranking, classification, files and vector stores.

  • #[Provider([...])] with several labs gives failover.

Each needs a provider that supports it. The scripted server only implements chat completions and embeddings.

Testing agents

ReadingAdvisor::fake() replaces the provider for that agent. Given arrays, it returns them as structured output. Without data, it generates values that match the schema. preventStrayPrompts() fails on any prompt without a fake. The tool is a plain class and is tested directly:

<?php
// tests/Feature/ReadingAdvisorTest.php
use App\Ai\Agents\ReadingAdvisor;
use App\Ai\Tools\SearchCatalogue;
use App\Models\{Author, Book};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Ai\Prompts\AgentPrompt;
use Laravel\Ai\Tools\Request;

uses(RefreshDatabase::class);

test('the advisor returns structured output', function () {
    ReadingAdvisor::fake([['title' => 'Wild Seed', 'reason' => 'Same author.']])->preventStrayPrompts();

    $response = ReadingAdvisor::make()->prompt('I loved Kindred.');

    expect($response['title'])->toBe('Wild Seed');
    ReadingAdvisor::assertPrompted(fn (AgentPrompt $prompt) => $prompt->contains('Kindred'));
});

test('a fake without data matches the schema', function () {
    ReadingAdvisor::fake();

    expect(ReadingAdvisor::make()->prompt('Anything?')->toArray())->toHaveKeys(['title', 'reason']);
});

test('the search tool queries the catalogue', function () {
    $butler = Author::factory()->create(['name' => 'Octavia E. Butler']);
    Book::factory()->for($butler)->create(['title' => 'Kindred']);
    Book::factory()->for($butler)->create(['title' => 'Wild Seed']);
    Book::factory()->create(['title' => 'Dune']);

    $json = (string) (new SearchCatalogue)->handle(new Request(['author' => 'Butler', 'exclude' => 'Kindred']));

    expect(collect(json_decode($json, true))->pluck('title')->all())->toBe(['Wild Seed']);
});

All three pass. Fakes test the code around the model, not the model. Whether the advisor gives good recommendations is a question for evaluations with real models (Agent Evaluation).

Laravel MCP

composer require laravel/mcp and php artisan vendor:publish --tag=ai-routes create routes/ai.php. make:mcp-server, make:mcp-tool, make:mcp-resource and make:mcp-prompt generate the classes in app/Mcp. Bookshelf exposes a search tool, a borrowing check and the loan rules:

<?php
// app/Mcp/Tools/SearchBooksTool.php
namespace App\Mcp\Tools;

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

#[IsReadOnly]
#[Description('Search the Bookshelf catalogue by title or author. Returns up to ten books.')]
class SearchBooksTool extends Tool
{
    public function handle(Request $request): ResponseFactory
    {
        $args = $request->validate(['query' => ['required', 'string', 'min:2', 'max:100']]);
        $term = '%'.$args['query'].'%';

        $books = Book::query()
            ->with('author:id,name')
            ->where('title', 'like', $term)
            ->orWhereRelation('author', 'name', 'like', $term)
            ->limit(10)
            ->get()
            ->map(fn (Book $book) => ['id' => $book->id, 'title' => $book->title, 'author' => $book->author->name]);

        return Response::structured(['books' => $books->all()]);
    }

    public function schema(JsonSchema $schema): array
    {
        return ['query' => $schema->string()->description('Part of a title or an author name')->required()];
    }
}

The generated stub declares : Response, but Response::structured() returns a ResponseFactory. The first call failed with a TypeError, which the client received as a tool error. Response::text() and Response::error() return a Response.

<?php
// app/Mcp/Tools/CheckBorrowingTool.php (excerpt)
#[IsReadOnly]
#[Description('Check whether the signed-in member may borrow a book, and why not.')]
class CheckBorrowingTool extends Tool
{
    public function shouldRegister(Request $request): bool
    {
        return $request->user() !== null;                      // hidden from anonymous clients
    }

    public function handle(Request $request): Response
    {
        $book = Book::find($request->validate(['book_id' => ['required', 'integer']])['book_id']);

        if (! $book) {
            return Response::error('No book with that id.');
        }

        $decision = Gate::forUser($request->user())->inspect('borrow', $book);    // the gate from the authorization page

        return Response::text($decision->allowed() ? "Yes, {$book->title} can be borrowed." : "No: {$decision->message()}");
    }
}

// app/Mcp/Servers/BookshelfServer.php
#[Name('Bookshelf')]
#[Version('1.0.0')]
#[Instructions('Search the Bookshelf library catalogue and check whether a member can borrow a book.')]
class BookshelfServer extends Server
{
    protected array $tools = [SearchBooksTool::class, CheckBorrowingTool::class];

    protected array $resources = [LoanRulesResource::class];       // #[Uri('bookshelf://rules/loans')]
}

// routes/ai.php
Mcp::web('/mcp/bookshelf', BookshelfServer::class)->middleware(['auth:sanctum', 'throttle:api']);
Mcp::local('bookshelf', BookshelfServer::class);

The MCP tool reuses the borrow gate from Authorization: Gates and Policies. An MCP server is another entry point into the same application, and it gets the same authorization as the web routes.

Calling it

The web server is a single POST route that speaks JSON-RPC. With curl and a Sanctum token:

$ curl -s -w ' %{http_code}\n' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
    localhost:8000/mcp/bookshelf
{"message":"Unauthenticated."} 401
$ rpc() { curl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' -d "$1" localhost:8000/mcp/bookshelf; }
$ rpc '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{…}}'
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false},"resources":{"listChanged":false},"prompts":{"listChanged":false}},"serverInfo":{"name":"Bookshelf","version":"1.0.0"},"instructions":"Search the Bookshelf library catalogue and check whether a member can borrow a book.",…}}
$ rpc '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search-books-tool","title":"Search Books Tool","description":"Search the Bookshelf catalogue by title or author. Returns up to ten books.","inputSchema":{"properties":{"query":{"description":"Part of a title or an author name","type":"string"}},"type":"object","required":["query"]},"annotations":{"readOnlyHint":true}},{"name":"check-borrowing-tool",…}],…}}
$ rpc '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search-books-tool","arguments":{"query":"Butler"}}}'
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"books\":[{\"id\":1,\"title\":\"Kindred\",…}]}"}],"isError":false,"structuredContent":{"books":[{"id":1,"title":"Kindred","author":"Octavia E. Butler"},{"id":2,"title":"Parable of the Sower","author":"Octavia E. Butler"},…]},…}}
$ rpc '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search-books-tool","arguments":{"query":"B"}}}'
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"The query field must be at least 2 characters."}],"isError":true,…}}
$ rpc '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"check-borrowing-tool","arguments":{"book_id":1}}}'
{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"Yes, Kindred can be borrowed."}],"isError":false,…}}
$ rpc '{"jsonrpc":"2.0","id":7,"method":"resources/read","params":{"uri":"bookshelf://rules/loans"}}'
{"jsonrpc":"2.0","id":7,"result":{"contents":[{"text":"Members may hold three loans at a time.\nA loan lasts 21 days and can be renewed once.","uri":"bookshelf://rules/loans","mimeType":"text/plain"}],…}}
$ rpc '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"delete-everything","arguments":{}}}'
{"jsonrpc":"2.0","id":8,"error":{"code":-32602,"message":"Tool [delete-everything] not found."}}

These results show how the protocol separates kinds of errors. A validation failure or Response::error() is a successful JSON-RPC call with isError: true, so the model sees the message and can correct itself. An unknown tool is a protocol error. The documentation says the name drops the Tool suffix (CurrentWeatherTool becomes current-weather), but laravel/mcp 1.0.1 kept it: SearchBooksTool became search-books-tool. #[Name] sets the name explicitly, and clients that are configured with tool names should rely on it.

The local server runs over stdio through php artisan mcp:start bookshelf. That is the command an MCP client configuration starts. It has no authenticated user, so shouldRegister hid the borrowing tool:

$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize",…}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
    '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | php artisan mcp:start bookshelf
1 {'name': 'Bookshelf', 'version': '1.0.0'}
2 ['search-books-tool']

A Sanctum bearer token suits clients that the user configures by hand. MCP clients such as Claude and ChatGPT connectors expect OAuth 2.1 discovery, which Laravel MCP provides with Passport (Mcp::oauthRoutes()). The 401 above carries no OAuth metadata, so those clients cannot sign in to this server. The protocol side is on MCP Authorization, and the OAuth flows are on API Authentication: Sanctum and Passport.

Testing MCP primitives

The server class calls a tool, resource or prompt directly, without HTTP or JSON-RPC:

<?php
// tests/Feature/BookshelfMcpTest.php
use App\Mcp\Servers\BookshelfServer;
use App\Mcp\Tools\{CheckBorrowingTool, SearchBooksTool};
use App\Models\{Author, Book, User};
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->book = Book::factory()->for(Author::factory()->create(['name' => 'Octavia E. Butler']))->create(['title' => 'Kindred']);
});

test('search finds books by author', function () {
    BookshelfServer::tool(SearchBooksTool::class, ['query' => 'Butler'])
        ->assertOk()
        ->assertSee('Kindred');
});

test('search rejects a one-letter query', function () {
    BookshelfServer::tool(SearchBooksTool::class, ['query' => 'B'])
        ->assertHasErrors(['The query field must be at least 2 characters.']);
});

test('members can check borrowing', function () {
    BookshelfServer::actingAs(User::factory()->create())
        ->tool(CheckBorrowingTool::class, ['book_id' => $this->book->id])
        ->assertOk()
        ->assertSee('Yes, Kindred can be borrowed.');
});

All three pass. php artisan mcp:inspector mcp/bookshelf (or bookshelf for the local server) opens the official MCP Inspector against the server for manual checks. More on testing and debugging MCP servers is on Testing and Debugging, and on what makes a good tool on Designing Good MCP Servers.

Laravel Boost

Boost is a development dependency for the coding agent working on the application: composer require --dev laravel/boost, then php artisan boost:install. In Bookshelf, with --guidelines --skills --mcp -n, it detected Claude Code and wrote:

CLAUDE.md          176 lines: "Laravel Boost Guidelines" (PHP 8.3, Laravel 13.34.0, Pest, Pint, Livewire, Folio ...)
.claude/skills/    13 skills: ai-sdk-development, mcp-development, livewire-development, testing-best-practices, ...
.mcp.json          {"mcpServers": {"laravel-boost": {"command": "php", "args": ["artisan", "boost:mcp"]}}}
boost.json         the choices, so that boost:update can refresh the files after composer update

The guidelines and skills are versioned to the installed packages, so the agent writes Laravel 13 code instead of the Laravel 8 patterns common in its training data. The MCP server gives it tools about the running application. tools/list on boost:mcp returned:

application-info, browser-logs, database-connections, database-query, database-schema, get-absolute-url,
last-error, read-log-entries, record-rule, search-docs, database-info, database-query-mongodb

application-info reported {"php_version":"8.3","laravel_version":"13.34.0","database_engine":"sqlite",...}. database-query answered select count(*) as n from books with [{"n":26}], and refused both delete from reviews and a WITH ... DELETE ... RETURNING with Only read-only queries are allowed. The review table was unchanged afterwards. search-docs queries the version-specific Laravel documentation.

Boost runs with the developer’s database credentials, and its tools read logs and data. It belongs in require-dev and on development machines only. Project-specific rules go in .ai/guidelines/*.md (or .blade.php), which boost:install merges into the generated files. Working with coding agents in general is covered on AI-Assisted Development, and the security side on Securing AI-Assisted Development.

Where older books and tutorials are outdated

The books predate all three packages. The tutorials that followed them have aged quickly:

  • openai-php/laravel or Prism called directly from controllers worked, but each provider had its own API. The AI SDK puts agents, tools, structured output, fakes and failover behind one interface, with a driver per provider.

  • Parsing JSON out of free text with a regular expression is replaced by HasStructuredOutput, which sends a JSON Schema to the provider.

  • Community MCP packages written against early protocol drafts (HTTP+SSE transport, no structured content) are replaced by laravel/mcp 1.0 and the streamable HTTP transport.

  • Pasting the Laravel documentation into the agent’s context is replaced by Boost’s search-docs tool and its version-matched guidelines.