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'),
];
}
}
Source: Laravel — Tools and Validating Tool Arguments
<?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(),
];
}
}
Source: Laravel — Agents and Agent Configuration
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;
Source: Laravel — Structured Output
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}
Source: Laravel — Usage
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
}
}
}
Source: Laravel — Structured Output
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}]" }
Source: Laravel — Tools
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 withErrorException: Undefined array key "author". With$request->validate(), the model gotThe author field is required.as the tool result and could try again.strict: falseinresponse_formatmeans 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}
Source: Laravel — Embeddings
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. -
RemembersConversationsstores 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']);
});
Source: Laravel — Testing Agents
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()];
}
}
Source: Laravel — Creating Tools and Structured Responses
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);
Source: Laravel — Conditional Tool Registration and Sanctum
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."}}
Source: Laravel — Web Servers and Tool Responses
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']
Source: Laravel — Local Servers
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.');
});
Source: Laravel — MCP Unit Tests
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
Source: Laravel — Boost Installation
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
Source: Laravel — Available MCP Tools
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/laravelor 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/mcp1.0 and the streamable HTTP transport. -
Pasting the Laravel documentation into the agent’s context is replaced by Boost’s
search-docstool and its version-matched guidelines.
References
-
OpenAI — Chat Completions API reference (the format of the OpenAI-compatible driver)