Content Index
- What is an MCP?
- General Architecture and Components of an MCP Server
- Implementation of the Main Server
- Development of Tools and Parameter Validation
- Exposing Resources and Prompt Templates
- Route Registration and Unit Tests
- What Was Created
- Commands Used
- How to Use It
- With MCP Inspector (fastest for debugging)
- Local Server (Command Line)
- Web Server (HTTP)
- Testing from Tinker (Without a Client)
- Why the Browser Returns 405 and How to Test Properly
- The 405 Is Not an Error, It's the Correct Response
- Testing for Real: With an AI
- What to Ask the AI Once Connected
- Diagnostic Tools When Something Fails
- Additional Data Worth Knowing
- Two Real Project Issues We Had to Work Around
- What is an MCP?
- General Architecture and Components of an MCP Server
- Implementation of the Main Server
- Development of Tools and Parameter Validation
- Exposing Resources and Prompt Templates
- Route Registration and Unit Tests
- What Was Created
- Commands Used
- How to Use It
- With MCP Inspector (fastest for debugging)
- Local Server (Command Line)
- Web Server (HTTP)
- Testing from Tinker (Without a Client)
- Why the Browser Returns 405 and How to Test Properly
- The 405 Is Not an Error, It's the Correct Response
- Testing for Real: With an AI
- What to Ask the AI Once Connected
- Diagnostic Tools When Something Fails
- Additional Data Worth Knowing
- Two Real Project Issues We Had to Work Around
In this section, we will cover an introduction to the Model Context Protocol (MCP) in Laravel. Although we will use this framework as a base, since it is an open standard, the fundamental concepts can be applied to any other technology, such as FastAPI. The development ecosystem includes multiple structured components that can be reviewed to deepen your understanding of the workflow.
To begin, it is necessary to define what the MCP protocol is.
What is an MCP?
It is an open standard initially created by Anthropic and Cloudflare [note: corrected from original typos], designed to connect artificial intelligence applications and language models (LLMs) with external data sources, databases, files, and operating systems.
We can visualize the MCP protocol as a sort of specialized API or connector. Its primary purpose is to allow LLMs and AI agents that we use while coding—for example, through environments like OpenCode—to interact directly with our Laravel application to retrieve real-time information.
Without this connector, the traditional alternative would force us to connect AI agents manually to the database or isolated scripts. However, such direct connection is usually insufficient: a Laravel project processes complex business logic, applies specific data formats (such as date transformations), and executes validations that the AI knows nothing about.
By integrating a custom MCP server into the application, we ensure the LLM retrieves data exactly as the project handles it, respecting the underlying business logic.
At the end of the post, you will find the artisan and composer commands used.
General Architecture and Components of an MCP Server
To implement an MCP server in Laravel, we have a series of Artisan commands that facilitate the structured creation of all necessary elements. The general architecture revolves around a main container known as a Server, which acts as a wrapper grouping and integrating the different components:
- Tools: Executable functions that allow performing specific operations within the application, such as querying listings, searching for items, or retrieving specific details based on parameters.
- Resources: Mechanisms aimed at structuring and exposing general environment or application information in a clean and predictable format.
- Prompts: Reusable structures accompanied by optional arguments, designed to guide the behavior and tone of the LLM when interacting with the system.
Once the components are created, it is necessary to register them properly and configure access routes in the corresponding file (for example, routes/ai.php), allowing the server to be exposed both via remote HTTP requests and locally through the terminal.
Implementation of the Main Server
The main server extends the base class of the MCP package and centralizes the registration of available tools, resources, and templates. It uses PHP attributes to define its name, version, and descriptive instructions that the LLM will interpret automatically:
namespace App\Mcp\Servers;
use App\Mcp\Prompts\SummarizePostsPrompt;
use App\Mcp\Resources\AppInfoResource;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Post Server')]
#[Version('1.0.0')]
#[Instructions('This server exposes blog posts: you can list them, search for one by ID, and read general application information.')]
class PostServer extends Server
{
protected array $tools = [
ListPostsTool::class,
GetPostTool::class,
];
protected array $resources = [
AppInfoResource::class,
];
protected array $prompts = [
SummarizePostsPrompt::class,
];
}
Key points of a tool:
#[Description]is the text the model reads to decide whether to call the tool. If vague, the tool won't be used. This is the most important part.schema()defines arguments and their types. Laravel converts it to JSON Schema and publishes it automatically undertools/list. No need to write JSON by hand.$request->validate()uses standard Laravel validations. Custom messages are read as-is by the client.Response::structured()returns structured JSON (which the model parses better than free text). That is why the return type isResponse|ResponseFactory:structured()returns aResponseFactory, not aResponse. Declaring only: Responseresults in a runtime TypeError.
Development of Tools and Parameter Validation
Tools represent the model's executive capability within our application. A fundamental aspect of their design is the use of metainformation and input schemas. Descriptive properties and attributes allow the AI to understand the tool's purpose and decide when to use it based on the user's query.
Below is the complete implementation of a tool to list and filter posts with pagination:
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Lists blog posts with pagination and optional text filtering (title or content).')]
class ListPostsTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
], [
'per_page.min' => 'per_page must be at least 1.',
'per_page.max' => 'per_page cannot be greater than 50.',
]);
$perPage = (int) ($validated['per_page'] ?? 10);
$query = Post::query()->latest();
if ($search = $validated['search'] ?? null) {
$query->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
$posts = $query->paginate($perPage);
if ($posts->isEmpty()) {
return Response::text('No posts found.');
}
$summary = $posts->getCollection()->map(fn (Post $post) => [
'id' => $post->id,
'title' => $post->title,
'posted' => $post->isPublished(),
'created_at' => $post->created_at?->toDateTimeString(),
])->all();
$header = sprintf(
'Showing %d of %d posts (page %d of %d).',
$posts->count(),
$posts->total(),
$posts->currentPage(),
$posts->lastPage(),
);
return Response::structured([
'summary' => $header,
'total' => $posts->total(),
'page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'posts' => $summary,
]);
}
public function schema(JsonSchema $schema): array
{
return [
'search' => $schema->string()
->description('Text to search in the title or content. Optional.'),
'per_page' => $schema->integer()
->description('Number of posts per page (1-50). Defaults to 10.'),
];
}
}
Complementarily, we can implement tools designed to retrieve individual records via specific identifiers, applying strict validations and returning controlled error messages if the resource does not exist in the database:
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Gets the complete content of a blog post by its ID.')]
class GetPostTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'id' => ['required', 'integer', 'min:1'],
], [
'id.required' => 'You must specify the post ID.',
'id.integer' => 'The post ID must be an integer.',
]);
$post = Post::find($validated['id']);
if (! $post) {
return Response::error("The post with ID {$validated['id']} does not exist.");
}
return Response::structured([
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
'description' => $post->description,
'content' => $post->content,
'posted' => $post->isPublished(),
'created_at' => $post->created_at?->toDateTimeString(),
]);
}
public function schema(JsonSchema $schema): array
{
return [
'id' => $schema->integer()
->description('The numeric ID of the post to retrieve.')
->required(),
];
}
}
Response::error() marks the response as an error (isError: true) instead of throwing an exception. This is the correct way to tell the model "this didn't work, try another way": the error arrives as text so it can react rather than crashing the connection.
Exposing Resources and Prompt Templates
Resources allow exposing static or system configuration data under custom URIs. They are ideal for providing general context about the application's state:
namespace App\Mcp\Resources;
use App\Models\Post;
use App\Models\User;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;
#[Description('General application information: name, environment, and blog stats.')]
#[Uri('app://info')]
#[MimeType('application/json')]
class AppInfoResource extends Resource
{
public function handle(Request $request): Response
{
return Response::json([
'name' => config('app.name'),
'environment' => config('app.env'),
'stats' => [
'posts' => Post::count(),
'published_posts' => Post::published()->count(),
'users' => User::count(),
],
]);
}
}
Resources use URIs (app://info) instead of names. They provide context that the client can read on demand. If data changes on its own, it should be marked as non-cacheable; if it is static, it can be cached.
On the other hand, prompts provide reusable structures with arguments that shape the guideline the model will receive when processing information:
namespace App\Mcp\Prompts;
use App\Models\Post;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;
#[Description('Generates a prompt to summarize the most relevant blog posts.')]
class SummarizePostsPrompt extends Prompt
{
public function handle(Request $request): array
{
$tone = $request->string('tone')->toString() ?: 'neutral';
$limit = (int) ($request->integer('limit') ?: 5);
$titles = Post::query()
->published()
->latest()
->limit($limit)
->pluck('title')
->implode(', ');
return [
->asAssistant(),
Response::text("You are an expert copywriter. Summarize the following blog posts in a {$tone} tone."),
Response::text("Posts to summarize: [{$titles}]"),
];
}
public function arguments(): array
{
return [
new Argument(
name: 'tone',
description: 'The tone of the summary (formal, casual, technical, etc.). Defaults to "neutral".',
required: false,
),
new Argument(
name: 'limit',
description: 'Number of posts to consider. Defaults to 5.',
required: false,
),
];
}
}A prompt can return multiple messages. The one with ->asAssistant() is presented as an assistant message (system instructions for the conversation) and the rest as user messages.
Route Registration and Unit Tests
To finalize configuration, we register the server's communication channels in the AI routes file:
use App\Mcp\Servers\PostServer;
use Laravel\Mcp\Facades\Mcp;
/*
|--------------------------------------------------------------------------
| MCP Routes
|--------------------------------------------------------------------------
| Server accessible via HTTP (remote clients like Claude Desktop, Cursor, etc.).
*/
Mcp::web('/mcp/posts', PostServer::class);
/*
|--------------------------------------------------------------------------
| Local Server
|--------------------------------------------------------------------------
| Runs as an Artisan command: php artisan mcp:start posts
*/
Mcp::local('posts', PostServer::class);Finally, we can ensure proper server operation through automated tests validating tools, resources, and content filters:
use App\Mcp\Prompts\SummarizePostsPrompt;
use App\Mcp\Resources\AppInfoResource;
use App\Mcp\Servers\PostServer;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
Artisan::call('migrate:fresh', [
'--force' => true,
'--realpath' => true,
'--path' => [
database_path('migrations/0001_01_01_000000_create_users_table.php'),
database_path('migrations/2026_09_26_093945_create_categories_table.php'),
database_path('migrations/2026_09_26_094316_create_posts_table.php'),
database_path('migrations/2026_09_26_094319_add_foreign_keys_to_posts_table.php'),
],
]);
});
function createPost(array $attributes = []): Post
{
$user = User::first() ?? User::factory()->create();
$categoryId = DB::table('categories')->value('id')
?? DB::table('categories')->insertGetId(['title' => 'General', 'slug' => 'general']);
return Post::create(array_merge([
'title' => 'First post',
'slug' => 'first-post',
'description' => 'A description',
'content' => 'Full post content',
'posted' => true,
'category_id' => $categoryId,
'user_id' => $user->id,
], $attributes));
}
it('lists posts through the list posts tool', function () {
createPost(['title' => 'Laravel MCP']);
createPost(['title' => 'Another post', 'slug' => 'another-post']);
$response = PostServer::tool(ListPostsTool::class, ['per_page' => 10]);
$response
->assertOk()
->assertSee('Laravel MCP')
->assertSee('Another post');
});
it('filters posts by search term', function () {
createPost(['title' => 'Introduction to MCP']);
createPost(['title' => 'Recipes', 'slug' => 'recipes']);
$response = PostServer::tool(ListPostsTool::class, ['search' => 'MCP']);
$response
->assertOk()
->assertSee('Introduction to MCP')
->assertDontSee('Recipes');
});The test syntax is clean: primitives are invoked directly on the server and chained with assertOk(), assertSee(), assertHasErrors(), assertHasNoErrors(), assertName(), assertDescription(). ->actingAs($user) is also available for testing authorization.
What Was Created
app/Mcp/
├── Servers/
│ └── PostServer.php Server: registers tools, resources, and prompts
├── Tools/
│ ├── ListPostsTool.php Lists posts (pagination + search)
│ └── GetPostTool.php Gets a post by ID
├── Resources/
│ └── AppInfoResource.php Resource app://info (app stats)
└── Prompts/
└── SummarizePostsPrompt.php Prompt template with arguments
routes/ai.php Registers web and local servers
tests/Feature/Mcp/
└── PostServerTest.php 8 Pest tests (all passing)Commands Used
$ composer require laravel/mcp
$ php artisan vendor:publish --tag=ai-routes
$ php artisan make:mcp-server PostServer
$ php artisan make:mcp-tool ListPostsTool
$ php artisan make:mcp-tool GetPostTool
$ php artisan make:mcp-resource AppInfoResource
$ php artisan make:mcp-prompt SummarizePostsPrompt
$ php artisan make:test --pest Mcp/PostServerTestHow to Use It
With MCP Inspector (fastest for debugging)
php artisan mcp:inspector mcp/posts # web server
php artisan mcp:inspector posts # local server
Launches the official Inspector, prints connection configuration, and allows listing and invoking tools, resources, and prompts manually. Ideal for writing the tool before connecting it to a real client.
Local Server (Command Line)
php artisan mcp:start posts
Each AI client launches this command itself. Typical configuration:
{
"mcpServers": {
"laravel-posts": {
"command": "php",
"args": ["artisan", "mcp:start", "posts"],
"cwd": "/Users/andrescruz/Herd/larapackage"
}
}
}
Web Server (HTTP)
{
"mcpServers": {
"laravel-posts": {
"url": "http://larapackage.test/mcp/posts"
}
}
}
http://larapackage.test is this project's Herd domain. From another device or in production, you must use the real domain and serve over HTTPS.
Testing from Tinker (Without a Client)
The package itself includes an MCP client, so you can call your own server from code or tinker. This is the fastest way to check the actual output:
php artisan tinker --execute '
$c = Laravel\Mcp\Client::local(PHP_BINARY, ["artisan", "mcp:start", "posts"]);
$c->tools(); // lists tools
$c->callTool("list-posts-tool", ["per_page" => 3])->text();
$c->callTool("get-post-tool", ["id" => 1])->text();
$c->readResource("app://info")->content();
$c->getPrompt("summarize-posts-prompt", ["tone" => "formal"])->text();
'
Verified actual output:
list-posts-tool | Lists blog posts with pagination and optional filter...
get-post-tool | Gets the complete content of a blog post...
{"summary":"Showing 3 of 30 posts (page 1 of 10).","total":30,
"page":1,"last_page":10,"posts":[...]}
{"name":"Laravel","environment":"local","locale":"en",
"stats":{"posts":30,"published_posts":16,"users":3}}
Watch out for names: the client uses the name derived from the class (list-posts-tool, with the -tool suffix), not the list-posts from the documentation's conceptual example.
Why the Browser Returns 405 and How to Test Properly
The 405 Is Not an Error, It's the Correct Response
If you open http://larapackage.test/mcp/posts in a browser, you will see 405 Method Not Allowed. This is expected: a browser GET sends Accept: text/html, and an MCP server does not speak HTML. The protocol requires POST with a JSON-RPC 2.0 body.
$ curl -i http://larapackage.test/mcp/posts
HTTP/1.1 405 Method Not Allowed
$ curl -X POST http://larapackage.test/mcp/posts \
-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.0"}}}'
Actual server response:
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":false},"resources":{"listChanged":false},
"prompts":{"listChanged":false}},
"serverInfo":{"name":"Post Server","version":"1.0.0"},
"instructions":"This server exposes blog posts: you can list them, search for one
by ID, and read general application information."}}
There you see serverInfo, declared capabilities, and instructions, which act as the "system prompt" read by the model. Never test an MCP server with a browser.
Testing for Real: With an AI
MCP is a contract between an application and an AI agent. The correct client is Claude Desktop, Cursor, Claude Code, or opencode. This project already has opencode.json, so the server was registered there:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"laravel-boost": { "type": "local", "command": ["php", "artisan", "boost:mcp"] },
"laravel-posts": {
"type": "local",
"enabled": true,
"command": ["php", "artisan", "mcp:start", "posts"]
},
"laravel-posts-http": {
"type": "remote",
"enabled": false,
"url": "http://larapackage.test/mcp/posts"
}
}
}
There are two entries on purpose:
laravel-posts(local, active): opencode runsphp artisan mcp:start posts. It doesn't depend on Herd, nginx, or the server being up. It's the convenient option for development.laravel-posts-http(remote, disabled): points to the web server. It is enabled with"enabled": trueto test exactly what a remote client would see. Requiresphp artisan serveor running Herd.
For Claude Desktop or any external client, the equivalent is:
{
"mcpServers": {
"laravel-posts": {
"command": "php",
"args": ["artisan", "mcp:start", "posts"],
"cwd": "/Users/andrescruz/Herd/larapackage"
}
}
}
For the web client, the URL is http://larapackage.test/mcp/posts.
Important: opencode loads configuration once at startup. After editing opencode.json, you must close and reopen opencode; the current session continues using the previous config.
What to Ask the AI Once Connected
The real test isn't "does it answer the prompt", but whether the model chooses the correct tools. Exercises that reveal if the example is well-built:
| Question to AI | What it proves |
|---|---|
| "What posts does the blog have?" | Should call list-posts-tool without being explicitly asked. |
| "Search for posts about Laravel" | Must pass search: "Laravel" in arguments. |
| "Give me post 3" | Must invoke get-post-tool with id: 3. |
| "Give me post 99999" | Tests handling of Response::error(): the model should explain it doesn't exist, not retry in a loop. |
| "Give me 500 posts" | Tests validation: should receive the per_page.max error and re-request a valid value. |
| "Summarize the posts in a formal tone" | Must use the summarize-posts-prompt prompt. |
| "How many published posts are there?" | Tests if it reads the app://info resource. |
If the AI doesn't pick a tool it should, the issue is almost always the #[Description] or the server #[Instructions], not the tool code. That is the finest tuning of any MCP.
Diagnostic Tools When Something Fails
php artisan mcp:inspector mcp/posts # test primitives manually
php artisan mcp:start posts # check if server starts
php artisan route:list --path=mcp # confirm registration
And to see raw tool output without AI in the middle, the package includes an MCP client:
php artisan tinker --execute '
$c = Laravel\Mcp\Client::local(PHP_BINARY, ["artisan", "mcp:start", "posts"]);
$c->callTool("list-posts-tool", ["per_page" => 3])->text();
'Additional Data Worth Knowing
Two Real Project Issues We Had to Work Around
Pgvector migrations don't run on SQLite. The pgvector/pgvector package publishes a create_vector_extension migration running CREATE EXTENSION IF NOT EXISTS vector, and SQLite doesn't understand that syntax. Consequently, RefreshDatabase crashes before reaching our code:
QueryException: SQLSTATE[HY000]: General error: 1 near "EXTENSION": syntax error
(SQL: CREATE EXTENSION IF NOT EXISTS vector)
The test solves this by migrating only necessary tables instead of all of them:
Artisan::call('migrate:fresh', [
'--force' => true,
'--realpath' => true,
'--path' => [
database_path('migrations/0001_01_01_000000_create_users_table.php'),
database_path('migrations/2026_09_26_093945_create_categories_table.php'),
database_path('migrations/2026_09_26_094316_create_posts_table.php'),
database_path('migrations/2026_09_26_094319_add_foreign_keys_to_posts_table.php'),
],
]);
If you ever want tests with clean RefreshDatabase, the underlying fix is making document migration driver-aware (storing vector only in pgsql) or moving embedding tests to a dedicated pgvector database. It's an app change, not an MCP example change, so it wasn't touched.
The posted column is a string, not a boolean.
$table->string('posted')->default('not');
A (bool) 'not' in PHP is true (any non-empty string is truthy). If where('posted', true) or (bool) $post->posted was used, all posts COUNTED as published. That's why the example uses:
in_array((string) $post->posted, ['1', 'true', 'yes', 'si'], true)
In this section, we will cover an introduction to the Model Context Protocol (MCP) in Laravel. Although we will use this framework as a base, since it is an open standard, the fundamental concepts can be applied to any other technology, such as FastAPI. The development ecosystem includes multiple structured components that can be reviewed to deepen your understanding of the workflow.
To begin, it is necessary to define what the MCP protocol is.
What is an MCP?
It is an open standard initially created by Anthropic and Cloudflare [note: corrected from original typos], designed to connect artificial intelligence applications and language models (LLMs) with external data sources, databases, files, and operating systems.
We can visualize the MCP protocol as a sort of specialized API or connector. Its primary purpose is to allow LLMs and AI agents that we use while coding—for example, through environments like OpenCode—to interact directly with our Laravel application to retrieve real-time information.
Without this connector, the traditional alternative would force us to connect AI agents manually to the database or isolated scripts. However, such direct connection is usually insufficient: a Laravel project processes complex business logic, applies specific data formats (such as date transformations), and executes validations that the AI knows nothing about.
By integrating a custom MCP server into the application, we ensure the LLM retrieves data exactly as the project handles it, respecting the underlying business logic.
At the end of the post, you will find the artisan and composer commands used.
General Architecture and Components of an MCP Server
To implement an MCP server in Laravel, we have a series of Artisan commands that facilitate the structured creation of all necessary elements. The general architecture revolves around a main container known as a Server, which acts as a wrapper grouping and integrating the different components:
- Tools: Executable functions that allow performing specific operations within the application, such as querying listings, searching for items, or retrieving specific details based on parameters.
- Resources: Mechanisms aimed at structuring and exposing general environment or application information in a clean and predictable format.
- Prompts: Reusable structures accompanied by optional arguments, designed to guide the behavior and tone of the LLM when interacting with the system.
Once the components are created, it is necessary to register them properly and configure access routes in the corresponding file (for example, routes/ai.php), allowing the server to be exposed both via remote HTTP requests and locally through the terminal.
Implementation of the Main Server
The main server extends the base class of the MCP package and centralizes the registration of available tools, resources, and templates. It uses PHP attributes to define its name, version, and descriptive instructions that the LLM will interpret automatically:
namespace App\Mcp\Servers;
use App\Mcp\Prompts\SummarizePostsPrompt;
use App\Mcp\Resources\AppInfoResource;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Post Server')]
#[Version('1.0.0')]
#[Instructions('This server exposes blog posts: you can list them, search for one by ID, and read general application information.')]
class PostServer extends Server
{
protected array $tools = [
ListPostsTool::class,
GetPostTool::class,
];
protected array $resources = [
AppInfoResource::class,
];
protected array $prompts = [
SummarizePostsPrompt::class,
];
}
Key points of a tool:
#[Description]is the text the model reads to decide whether to call the tool. If vague, the tool won't be used. This is the most important part.schema()defines arguments and their types. Laravel converts it to JSON Schema and publishes it automatically undertools/list. No need to write JSON by hand.$request->validate()uses standard Laravel validations. Custom messages are read as-is by the client.Response::structured()returns structured JSON (which the model parses better than free text). That is why the return type isResponse|ResponseFactory:structured()returns aResponseFactory, not aResponse. Declaring only: Responseresults in a runtime TypeError.
Development of Tools and Parameter Validation
Tools represent the model's executive capability within our application. A fundamental aspect of their design is the use of metainformation and input schemas. Descriptive properties and attributes allow the AI to understand the tool's purpose and decide when to use it based on the user's query.
Below is the complete implementation of a tool to list and filter posts with pagination:
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Lists blog posts with pagination and optional text filtering (title or content).')]
class ListPostsTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
], [
'per_page.min' => 'per_page must be at least 1.',
'per_page.max' => 'per_page cannot be greater than 50.',
]);
$perPage = (int) ($validated['per_page'] ?? 10);
$query = Post::query()->latest();
if ($search = $validated['search'] ?? null) {
$query->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
$posts = $query->paginate($perPage);
if ($posts->isEmpty()) {
return Response::text('No posts found.');
}
$summary = $posts->getCollection()->map(fn (Post $post) => [
'id' => $post->id,
'title' => $post->title,
'posted' => $post->isPublished(),
'created_at' => $post->created_at?->toDateTimeString(),
])->all();
$header = sprintf(
'Showing %d of %d posts (page %d of %d).',
$posts->count(),
$posts->total(),
$posts->currentPage(),
$posts->lastPage(),
);
return Response::structured([
'summary' => $header,
'total' => $posts->total(),
'page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'posts' => $summary,
]);
}
public function schema(JsonSchema $schema): array
{
return [
'search' => $schema->string()
->description('Text to search in the title or content. Optional.'),
'per_page' => $schema->integer()
->description('Number of posts per page (1-50). Defaults to 10.'),
];
}
}
Complementarily, we can implement tools designed to retrieve individual records via specific identifiers, applying strict validations and returning controlled error messages if the resource does not exist in the database:
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Gets the complete content of a blog post by its ID.')]
class GetPostTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'id' => ['required', 'integer', 'min:1'],
], [
'id.required' => 'You must specify the post ID.',
'id.integer' => 'The post ID must be an integer.',
]);
$post = Post::find($validated['id']);
if (! $post) {
return Response::error("The post with ID {$validated['id']} does not exist.");
}
return Response::structured([
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
'description' => $post->description,
'content' => $post->content,
'posted' => $post->isPublished(),
'created_at' => $post->created_at?->toDateTimeString(),
]);
}
public function schema(JsonSchema $schema): array
{
return [
'id' => $schema->integer()
->description('The numeric ID of the post to retrieve.')
->required(),
];
}
}
Response::error() marks the response as an error (isError: true) instead of throwing an exception. This is the correct way to tell the model "this didn't work, try another way": the error arrives as text so it can react rather than crashing the connection.
Exposing Resources and Prompt Templates
Resources allow exposing static or system configuration data under custom URIs. They are ideal for providing general context about the application's state:
namespace App\Mcp\Resources;
use App\Models\Post;
use App\Models\User;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;
#[Description('General application information: name, environment, and blog stats.')]
#[Uri('app://info')]
#[MimeType('application/json')]
class AppInfoResource extends Resource
{
public function handle(Request $request): Response
{
return Response::json([
'name' => config('app.name'),
'environment' => config('app.env'),
'stats' => [
'posts' => Post::count(),
'published_posts' => Post::published()->count(),
'users' => User::count(),
],
]);
}
}
Resources use URIs (app://info) instead of names. They provide context that the client can read on demand. If data changes on its own, it should be marked as non-cacheable; if it is static, it can be cached.
On the other hand, prompts provide reusable structures with arguments that shape the guideline the model will receive when processing information:
namespace App\Mcp\Prompts;
use App\Models\Post;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;
#[Description('Generates a prompt to summarize the most relevant blog posts.')]
class SummarizePostsPrompt extends Prompt
{
public function handle(Request $request): array
{
$tone = $request->string('tone')->toString() ?: 'neutral';
$limit = (int) ($request->integer('limit') ?: 5);
$titles = Post::query()
->published()
->latest()
->limit($limit)
->pluck('title')
->implode(', ');
return [
->asAssistant(),
Response::text("You are an expert copywriter. Summarize the following blog posts in a {$tone} tone."),
Response::text("Posts to summarize: [{$titles}]"),
];
}
public function arguments(): array
{
return [
new Argument(
name: 'tone',
description: 'The tone of the summary (formal, casual, technical, etc.). Defaults to "neutral".',
required: false,
),
new Argument(
name: 'limit',
description: 'Number of posts to consider. Defaults to 5.',
required: false,
),
];
}
}A prompt can return multiple messages. The one with ->asAssistant() is presented as an assistant message (system instructions for the conversation) and the rest as user messages.
Route Registration and Unit Tests
To finalize configuration, we register the server's communication channels in the AI routes file:
use App\Mcp\Servers\PostServer;
use Laravel\Mcp\Facades\Mcp;
/*
|--------------------------------------------------------------------------
| MCP Routes
|--------------------------------------------------------------------------
| Server accessible via HTTP (remote clients like Claude Desktop, Cursor, etc.).
*/
Mcp::web('/mcp/posts', PostServer::class);
/*
|--------------------------------------------------------------------------
| Local Server
|--------------------------------------------------------------------------
| Runs as an Artisan command: php artisan mcp:start posts
*/
Mcp::local('posts', PostServer::class);Finally, we can ensure proper server operation through automated tests validating tools, resources, and content filters:
use App\Mcp\Prompts\SummarizePostsPrompt;
use App\Mcp\Resources\AppInfoResource;
use App\Mcp\Servers\PostServer;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
Artisan::call('migrate:fresh', [
'--force' => true,
'--realpath' => true,
'--path' => [
database_path('migrations/0001_01_01_000000_create_users_table.php'),
database_path('migrations/2026_09_26_093945_create_categories_table.php'),
database_path('migrations/2026_09_26_094316_create_posts_table.php'),
database_path('migrations/2026_09_26_094319_add_foreign_keys_to_posts_table.php'),
],
]);
});
function createPost(array $attributes = []): Post
{
$user = User::first() ?? User::factory()->create();
$categoryId = DB::table('categories')->value('id')
?? DB::table('categories')->insertGetId(['title' => 'General', 'slug' => 'general']);
return Post::create(array_merge([
'title' => 'First post',
'slug' => 'first-post',
'description' => 'A description',
'content' => 'Full post content',
'posted' => true,
'category_id' => $categoryId,
'user_id' => $user->id,
], $attributes));
}
it('lists posts through the list posts tool', function () {
createPost(['title' => 'Laravel MCP']);
createPost(['title' => 'Another post', 'slug' => 'another-post']);
$response = PostServer::tool(ListPostsTool::class, ['per_page' => 10]);
$response
->assertOk()
->assertSee('Laravel MCP')
->assertSee('Another post');
});
it('filters posts by search term', function () {
createPost(['title' => 'Introduction to MCP']);
createPost(['title' => 'Recipes', 'slug' => 'recipes']);
$response = PostServer::tool(ListPostsTool::class, ['search' => 'MCP']);
$response
->assertOk()
->assertSee('Introduction to MCP')
->assertDontSee('Recipes');
});The test syntax is clean: primitives are invoked directly on the server and chained with assertOk(), assertSee(), assertHasErrors(), assertHasNoErrors(), assertName(), assertDescription(). ->actingAs($user) is also available for testing authorization.
What Was Created
app/Mcp/
├── Servers/
│ └── PostServer.php Server: registers tools, resources, and prompts
├── Tools/
│ ├── ListPostsTool.php Lists posts (pagination + search)
│ └── GetPostTool.php Gets a post by ID
├── Resources/
│ └── AppInfoResource.php Resource app://info (app stats)
└── Prompts/
└── SummarizePostsPrompt.php Prompt template with arguments
routes/ai.php Registers web and local servers
tests/Feature/Mcp/
└── PostServerTest.php 8 Pest tests (all passing)Commands Used
$ composer require laravel/mcp
$ php artisan vendor:publish --tag=ai-routes
$ php artisan make:mcp-server PostServer
$ php artisan make:mcp-tool ListPostsTool
$ php artisan make:mcp-tool GetPostTool
$ php artisan make:mcp-resource AppInfoResource
$ php artisan make:mcp-prompt SummarizePostsPrompt
$ php artisan make:test --pest Mcp/PostServerTestHow to Use It
With MCP Inspector (fastest for debugging)
php artisan mcp:inspector mcp/posts # web server
php artisan mcp:inspector posts # local server
Launches the official Inspector, prints connection configuration, and allows listing and invoking tools, resources, and prompts manually. Ideal for writing the tool before connecting it to a real client.
Local Server (Command Line)
php artisan mcp:start posts
Each AI client launches this command itself. Typical configuration:
{
"mcpServers": {
"laravel-posts": {
"command": "php",
"args": ["artisan", "mcp:start", "posts"],
"cwd": "/Users/andrescruz/Herd/larapackage"
}
}
}
Web Server (HTTP)
{
"mcpServers": {
"laravel-posts": {
"url": "http://larapackage.test/mcp/posts"
}
}
}
http://larapackage.test is this project's Herd domain. From another device or in production, you must use the real domain and serve over HTTPS.
Testing from Tinker (Without a Client)
The package itself includes an MCP client, so you can call your own server from code or tinker. This is the fastest way to check the actual output:
php artisan tinker --execute '
$c = Laravel\Mcp\Client::local(PHP_BINARY, ["artisan", "mcp:start", "posts"]);
$c->tools(); // lists tools
$c->callTool("list-posts-tool", ["per_page" => 3])->text();
$c->callTool("get-post-tool", ["id" => 1])->text();
$c->readResource("app://info")->content();
$c->getPrompt("summarize-posts-prompt", ["tone" => "formal"])->text();
'
Verified actual output:
list-posts-tool | Lists blog posts with pagination and optional filter...
get-post-tool | Gets the complete content of a blog post...
{"summary":"Showing 3 of 30 posts (page 1 of 10).","total":30,
"page":1,"last_page":10,"posts":[...]}
{"name":"Laravel","environment":"local","locale":"en",
"stats":{"posts":30,"published_posts":16,"users":3}}
Watch out for names: the client uses the name derived from the class (list-posts-tool, with the -tool suffix), not the list-posts from the documentation's conceptual example.
Why the Browser Returns 405 and How to Test Properly
The 405 Is Not an Error, It's the Correct Response
If you open http://larapackage.test/mcp/posts in a browser, you will see 405 Method Not Allowed. This is expected: a browser GET sends Accept: text/html, and an MCP server does not speak HTML. The protocol requires POST with a JSON-RPC 2.0 body.
$ curl -i http://larapackage.test/mcp/posts
HTTP/1.1 405 Method Not Allowed
$ curl -X POST http://larapackage.test/mcp/posts \
-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.0"}}}'
Actual server response:
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":false},"resources":{"listChanged":false},
"prompts":{"listChanged":false}},
"serverInfo":{"name":"Post Server","version":"1.0.0"},
"instructions":"This server exposes blog posts: you can list them, search for one
by ID, and read general application information."}}
There you see serverInfo, declared capabilities, and instructions, which act as the "system prompt" read by the model. Never test an MCP server with a browser.
Testing for Real: With an AI
MCP is a contract between an application and an AI agent. The correct client is Claude Desktop, Cursor, Claude Code, or opencode. This project already has opencode.json, so the server was registered there:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"laravel-boost": { "type": "local", "command": ["php", "artisan", "boost:mcp"] },
"laravel-posts": {
"type": "local",
"enabled": true,
"command": ["php", "artisan", "mcp:start", "posts"]
},
"laravel-posts-http": {
"type": "remote",
"enabled": false,
"url": "http://larapackage.test/mcp/posts"
}
}
}
There are two entries on purpose:
laravel-posts(local, active): opencode runsphp artisan mcp:start posts. It doesn't depend on Herd, nginx, or the server being up. It's the convenient option for development.laravel-posts-http(remote, disabled): points to the web server. It is enabled with"enabled": trueto test exactly what a remote client would see. Requiresphp artisan serveor running Herd.
For Claude Desktop or any external client, the equivalent is:
{
"mcpServers": {
"laravel-posts": {
"command": "php",
"args": ["artisan", "mcp:start", "posts"],
"cwd": "/Users/andrescruz/Herd/larapackage"
}
}
}
For the web client, the URL is http://larapackage.test/mcp/posts.
Important: opencode loads configuration once at startup. After editing opencode.json, you must close and reopen opencode; the current session continues using the previous config.
What to Ask the AI Once Connected
The real test isn't "does it answer the prompt", but whether the model chooses the correct tools. Exercises that reveal if the example is well-built:
| Question to AI | What it proves |
|---|---|
| "What posts does the blog have?" | Should call list-posts-tool without being explicitly asked. |
| "Search for posts about Laravel" | Must pass search: "Laravel" in arguments. |
| "Give me post 3" | Must invoke get-post-tool with id: 3. |
| "Give me post 99999" | Tests handling of Response::error(): the model should explain it doesn't exist, not retry in a loop. |
| "Give me 500 posts" | Tests validation: should receive the per_page.max error and re-request a valid value. |
| "Summarize the posts in a formal tone" | Must use the summarize-posts-prompt prompt. |
| "How many published posts are there?" | Tests if it reads the app://info resource. |
If the AI doesn't pick a tool it should, the issue is almost always the #[Description] or the server #[Instructions], not the tool code. That is the finest tuning of any MCP.
Diagnostic Tools When Something Fails
php artisan mcp:inspector mcp/posts # test primitives manually
php artisan mcp:start posts # check if server starts
php artisan route:list --path=mcp # confirm registration
And to see raw tool output without AI in the middle, the package includes an MCP client:
php artisan tinker --execute '
$c = Laravel\Mcp\Client::local(PHP_BINARY, ["artisan", "mcp:start", "posts"]);
$c->callTool("list-posts-tool", ["per_page" => 3])->text();
'Additional Data Worth Knowing
Two Real Project Issues We Had to Work Around
Pgvector migrations don't run on SQLite. The pgvector/pgvector package publishes a create_vector_extension migration running CREATE EXTENSION IF NOT EXISTS vector, and SQLite doesn't understand that syntax. Consequently, RefreshDatabase crashes before reaching our code:
QueryException: SQLSTATE[HY000]: General error: 1 near "EXTENSION": syntax error
(SQL: CREATE EXTENSION IF NOT EXISTS vector)
The test solves this by migrating only necessary tables instead of all of them:
Artisan::call('migrate:fresh', [
'--force' => true,
'--realpath' => true,
'--path' => [
database_path('migrations/0001_01_01_000000_create_users_table.php'),
database_path('migrations/2026_09_26_093945_create_categories_table.php'),
database_path('migrations/2026_09_26_094316_create_posts_table.php'),
database_path('migrations/2026_09_26_094319_add_foreign_keys_to_posts_table.php'),
],
]);
If you ever want tests with clean RefreshDatabase, the underlying fix is making document migration driver-aware (storing vector only in pgsql) or moving embedding tests to a dedicated pgvector database. It's an app change, not an MCP example change, so it wasn't touched.
The posted column is a string, not a boolean.
$table->string('posted')->default('not');
A (bool) 'not' in PHP is true (any non-empty string is truthy). If where('posted', true) or (bool) $post->posted was used, all posts COUNTED as published. That's why the example uses:
in_array((string) $post->posted, ['1', 'true', 'yes', 'si'], true)