Building a Laravel MCP Server That Answers Questions Over Real Data
A hands-on guide to the Model Context Protocol in Laravel — from first principles to a live tool inside Claude Desktop
The Model Context Protocol (MCP) lets an AI assistant like Claude call into your application — running your code, reading your data, and answering questions it otherwise couldn't. This tutorial teaches MCP in Laravel by building a working example: a read-only server that answers natural-language questions over a sales database ("who were our top five customers last quarter?") and connecting it to Claude Desktop.
We'll use a sample sales dataset — customers, orders, products, payments — based on the well-known classicmodels sample database (a scale-model car retailer), popularised by MySQL Tutorial and freely available for learning. Nothing here is specific to it, though; the same patterns apply to any Laravel application's data. Every code snippet is an illustrative example; the complete, runnable server is in the accompanying project, linked at the end.
What you'll learn
- What MCP is and how it differs from a traditional REST API.
- How to test a server locally with the MCP Inspector, before any AI client touches it.
- How to make your tools "agent-native" — the small touches that make a server pleasant for a model to use.
- How to connect the server to Claude Desktop, and how to secure it for real use.
Part 1 — What is MCP, and how is it different from an API?
An MCP server is, in one sense, an API — it speaks JSON-RPC over a transport and returns structured data. But it's a fundamentally different kind of interface than the REST API you'd hand-write for a single-page app or a mobile client.
The core difference: who the consumer is
-
A REST API is designed for developers. A human reads the docs, learns the endpoints, and writes code to call
GET /orders?status=Shipped. The contract lives in documentation that sits outside the API. - An MCP server is designed for an AI model. The model discovers what's available at runtime by asking the server "what can you do?" The contract is self-describing and ships with the server.
That single shift drives every other difference.
Side by side
| REST API | MCP Server | |
|---|---|---|
| Consumer | A developer writing code | An AI model, at runtime |
| Discovery | Read external docs | The server advertises its tools + schemas itself |
| Description | Docs say how to call it | Each tool says what it does and when to use it |
| Coupling | Client hard-codes endpoints | Client asks "what can you do?" and adapts |
| Primitives | Endpoints + HTTP verbs | Tools (actions), Resources (data), Prompts (templates) |
| Transport/auth | HTTP, you design it | Standardized (stdio / streamable HTTP) — any MCP client connects |
The three primitives
A Laravel MCP server is a class that registers three kinds of capability. Here's the skeleton of the server we'll build, which exposes all three:
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
#[Instructions(<<<'TXT'
This server exposes a read-only sales dataset for ad-hoc querying.
Read the "database-schema" resource first to learn the tables, then
answer with the curated tools, falling back to a read-only SQL query
only when no curated tool fits.
TXT)]
class AssistantServer extends Server
{
protected array $tools = [
ListOrdersTool::class,
SearchCustomersTool::class,
RevenueReportTool::class,
RunAssistantQueryTool::class,
// ...
];
protected array $resources = [DatabaseSchemaResource::class];
protected array $prompts = [RevenueInsightsPrompt::class];
}
-
Tools are the callable actions —
ListOrdersTool,SearchCustomersTool, and so on. Each is a class with ahandle()method, much like a controller action. -
Resources are read-only context the model can pull in. Our
DatabaseSchemaResourcedescribes every queryable table and column in plain language, so the model knows the shape of the data. -
Prompts are reusable templates —
RevenueInsightsPromptseeds a canned analysis workflow.
Notice the #[Instructions] block. It tells the model how to work — read the schema first, prefer curated tools, treat raw SQL as a fallback — and the model receives it automatically on connect. A REST API has no equivalent; the closest thing is a README the developer might never read.
Why this matters
- Self-describing & runtime-discoverable. A tool's description ("Lists orders with their status, dates, customer and computed total…") is part of the wire protocol. The model reads it and knows when to reach for the tool — no human gluing docs to code.
- One client, any server. Because MCP is a standard, Claude Desktop connects to your Laravel server with zero bespoke integration code. Contrast a REST API, where every consumer writes its own client.
- Built for reasoning, not just transport. Descriptions and JSON schemas are written to help a model decide. That's a different design goal than an endpoint that just returns rows.
- You keep all your Laravel guts. Tools run inside the app, so they reuse Eloquent, validation, the query builder, config, and auth. You expose your domain to a model without rebuilding it.
The honest trade-offs
- Not for browsers or mobile apps. If a human-facing frontend needs the data, a REST/JSON API is still the right tool. MCP targets AI clients.
- More machinery for trivial cases. If you just need one endpoint hit by your own SPA, MCP is overkill.
- Younger ecosystem. REST tooling (gateways, caching, doc generators) is mature; MCP is newer.
The mental model
A REST API is a menu you read. An MCP server is a waiter who tells you the specials, knows what you can order, and places it for you. Same kitchen (your Laravel app) — a different way of interacting.
A word on data access (read this before you connect anything)
A common worry is "does the model connect to my database?" It does not — it has no connection, no credentials, and can't reach your database at all. The model only ever emits a tool call and reads the JSON your tool returns; your Laravel app is the one holding the connection and running the query.
But here's the part to internalise: for the assistant to answer, your tools must read real rows and return them — and those rows land in the model's context. So the exposure isn't the connection, it's the return values. Any data your tools can read can end up in a conversation, travel to the model provider's API, and be shown to whoever is using the AI client. That reframes what a server's guardrails are really protecting:
| Control | Looks like it's for | What it's actually protecting |
|---|---|---|
| Read-only DB connection | Preventing writes | The blast radius if a guard is ever bypassed |
| Table allowlist | Stopping bad SQL | Deciding what data is allowed to leave the building |
| Blocked column patterns | SQL hygiene | Keeping credentials/PII out of the model's context |
| Curated tools selecting set columns | Clean output | Not returning columns the model never needs to see |
The design principle: scope read access to exactly the data you're willing to put in front of a model. In practice that means a dedicated read-only database user, treating every returned row as data that leaves your perimeter, and authenticating who can reach the server. We'll act on all three in Part 4. The one-liner to remember:
The app needs read access; whatever it reads and returns becomes visible to the model and the user. Scope the read access to what you're willing to expose, and authenticate who can ask.
With the what and the why settled, let's get a server running and prove it works — before any AI client is involved.
Part 2 — Test the server locally with the MCP Inspector
Laravel MCP ships an Inspector: a local web UI that connects to your server and lets you list and call tools, read resources, and watch the raw JSON-RPC traffic. It's the fastest debug loop you have, and the right place to live before you ever open an AI client. First, though, the server has to be reachable.
How a server is exposed
You register a server on a route in routes/ai.php. Our example mounts it as a web (HTTP) server:
use App\Mcp\Servers\AssistantServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/assistant', AssistantServer::class)
->middleware(['throttle:mcp']);
That exposes a streamable-HTTP endpoint at /mcp/assistant. The throttle:mcp rate limiter is a plain Laravel limiter — in the example it allows 60 requests/min for an authenticated user, or 20/min by IP otherwise, so the endpoint is reachable in local dev without a token.
Keep the route unauthenticated while you test. The Inspector connects to a web server over HTTP, through the real route — so any auth middleware on it applies. An
authorauth:sanctumguard will reject the Inspector's unauthenticated connection. Register the route withthrottle:mcponly for now; we add authentication in Part 4, once the server is ready to expose.
Launch the Inspector
Make sure your app is being served (php artisan serve), then point the Inspector at the server's route or handle:
php artisan mcp:inspector mcp/assistant
This launches the MCP Inspector and opens it in your browser, connected to your running endpoint.
A testing checklist
Work through the server methodically — each step mirrors something the model will do:
- List the tools. Confirm they all appear with readable names and descriptions. This is exactly what a model sees on connect — if a description is vague here, the model will misuse the tool there.
- Read the schema resource. It should return your curated table/column documentation. The server's instructions tell the model to read this first, so make sure it's clear.
-
Call a curated tool. Run
list-orderswith{ "status": "Shipped", "limit": 5 }. You should get back JSON with the matching rows. -
Exercise validation. Call
list-orderswith{ "status": "Banana" }. You should get a friendly error like "Invalid status. Choose one of: In Process, Shipped, …". Good error text is how a model self-corrects. -
Test the raw-SQL guardrails. If your server has an open-ended query tool, call it with a write attempt (
DELETE FROM orders) and confirm it's rejected, then a validSELECTand confirm it runs, then a forbidden table (SELECT * FROM users) and confirm the allowlist blocks it. - Try a prompt. Invoke one and confirm it returns a sensible starter message.
If every item passes, the server is sound.
Tip: keep the Inspector open in a tab while you edit tools. After a change, just re-run a tool call — a far tighter loop than restarting an AI client each time.
Your tools work. But "works" and "pleasant for a model to use" aren't the same thing — and the gap between them is where most of the real craft of an MCP server lives. That's Part 3.
Part 3 — Make the tools "agent-native"
A tool can return correct data and still be hard for a model to use well. The refinements below don't change what a tool does; they change how much the model understands about each call without trial and error — Is it safe to retry? What shape comes back? Did I get all the data? That understanding is the whole "designed for agents" difference. We'll show each as a small before/after.
1. Tool annotations
A read-only tool should say it's read-only. Annotations are metadata the host (the AI client) uses to decide whether a call needs user confirmation, can be safely retried, cached, or run in parallel. Without them, a host has to treat every call as potentially dangerous.
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Attributes\Description;
#[IsReadOnly]
#[IsIdempotent]
#[Description('Lists orders with their status, dates, customer and computed order total...')]
class ListOrdersTool extends Tool
{
// ...
}
Add #[IsReadOnly] and #[IsIdempotent] to every read-only tool. For an open-ended SQL tool, add #[IsOpenWorld(false)] to signal it never reaches outside your dataset. This is the cheapest change with the highest signal.
| Annotation | Meaning |
|---|---|
#[IsReadOnly] |
The tool does not modify its environment. |
#[IsDestructive] |
The tool may perform destructive updates (only meaningful when not read-only). |
#[IsIdempotent] |
Repeated calls with the same arguments have no additional effect. |
#[IsOpenWorld] |
The tool may interact with external entities. |
2. Structured responses + output schemas
A tool that returns Response::text(json_encode($rows)) hands the model a text blob it has to re-parse, and the model can't know the result's shape until after it calls. laravel/mcp has first-class support for structured content:
// Before:
return Response::text(json_encode([
'row_count' => $rows->count(),
'rows' => $rows,
], JSON_PRETTY_PRINT));
// After:
return Response::structured([
'row_count' => $rows->count(),
'rows' => $rows,
]);
Then declare the shape with an outputSchema() method, so the model knows what it'll get back before it calls:
use Illuminate\Contracts\JsonSchema\JsonSchema;
public function outputSchema(JsonSchema $schema): array
{
return [
'row_count' => $schema->integer()->required(),
'rows' => $schema->array()->description('Matching order rows.'),
];
}
Response::structured() keeps a JSON-encoded text fallback for older clients, so you lose nothing.
Note on return types.
Response::structured()returns aResponseFactory, not aResponse, so widen the method signature tohandle(Request $request): Response|ResponseFactory.Gotcha — declare nullable fields, or the whole response is rejected. The client doesn't just read your
outputSchema, it validates the response against it. If a field can benull— say an optional date you echo back when no range was passed — but you declared it$schema->string(), thennullviolates"type": "string"and a strict client (Claude Desktop) throws the entire response away with an opaque "Tool execution failed". Your server never errors and your logs stay empty, because the rejection happens client-side. Mark such fields nullable:'start_date' => $schema->string()->nullable()->description('Start date applied, or null.'),Telltale sign: the tool works over
php artisan mcp:start(the raw client doesn't validate) but fails in Claude Desktop. Inspect exactly what the client sees with(new YourTool)->toArray()['outputSchema']and check every field whose value can be null.
3. Drop JSON_PRETTY_PRINT — token economy
Models pay per token, and pretty-printing pads every response with whitespace the model doesn't need. Response::structured() handles encoding for you, so this disappears the moment you adopt #2 — but it's worth stating as its own principle: for a model, terse beats pretty. The same applies to any resource that hand-encodes JSON.
4. Signal truncation — a real design gap
Capping a result set (LIMIT 50, a clampLimit() helper) silently hides rows. If 50 come back, the model can't tell whether that's the whole answer or a wall it hit — so it may reason on incomplete data. Fetch one extra row to detect the cap, and tell the model:
$limit = $this->clampLimit($validated['limit'] ?? null, 50);
$rows = $query->limit($limit + 1)->get(); // one extra to detect "more"
$truncated = $rows->count() > $limit;
return Response::structured([
'row_count' => min($rows->count(), $limit),
'truncated' => $truncated, // model now knows to narrow filters or page
'rows' => $rows->take($limit)->values(),
]);
5. Use real schema ->default() values
Documenting "(default 50)" in prose leaves the value in text the model has to interpret. ->default(50) is machine-readable — something the client can act on directly:
'limit' => $schema->integer()
->default(50)
->description('Maximum number of orders to return.'),
6. Gate your highest-risk tool
An open-ended SQL tool is the one surface where the guardrails are the security boundary. You can expose it only to authorized users with shouldRegister(), which runs per request — a tool that returns false never appears in the tool list and can't be invoked:
public function shouldRegister(Request $request): bool
{
return $request?->user()?->can('run-raw-queries') ?? false;
}
(This requires an authenticated route and a matching ability — see Part 4.)
The takeaway
None of this changes what the server does. It changes how much the model understands about each call — read-only? retry-safe? what shape comes back? did I get everything? — without finding out the hard way. Items #1–#4 are the high-value set.
Test every change. Each refinement is easy to cover: assert the new
truncatedkey, assert the structured output, assert a tool carries its annotations. A server you can't test, you can't trust in front of a model.
Your server is correct and agent-friendly. Time to put it in front of Claude.
Part 4 — Connect the server to Claude Desktop
Claude Desktop launches MCP servers as local processes that speak over stdio. Our server is a web (HTTP) server, so we bridge the two with mcp-remote — a tiny npm tool that proxies Claude Desktop's stdio to an HTTP endpoint. No code change required.
Step 1 — Start the server yourself, and keep it running
This is the step that trips people up. With the web + mcp-remote setup, Claude Desktop only launches the bridge — it does not start your Laravel app. So you have to run the server manually and leave it running for the whole session:
php artisan serve # or your usual dev command (composer run dev, Sail, Herd, etc.)
Confirm the endpoint is live (e.g. http://localhost:8000/mcp/assistant). If the app isn't running, the bridge connects to a dead URL and the tools never appear in Claude.
Why manual, when other MCP servers "just start"? A server registered with
Mcp::local(...)is launched by Claude Desktop itself — the config runsphp artisan mcp:start <handle>over stdio, so there's nothing to start by hand. A web server (Mcp::web) is the opposite: it's a normal HTTP endpoint that has to be up before the client connects. (See Two ways to connect at the end of this part for the local/stdio alternative.)
Step 2 — Open the Claude Desktop config
In Claude Desktop: Settings → Developer → Edit Config. That opens claude_desktop_config.json:
-
macOS:
~/Library/Application Support/Claude/claude_desktop_config.json -
Windows:
%APPDATA%\Claude\claude_desktop_config.json
Step 3 — Add the server
Add an entry under mcpServers. The key (laravel-assistant) is the display name; the command runs mcp-remote pointed at your endpoint:
{"mcpServers":{"laravel-assistant":{"command":"npx","args":["-y","mcp-remote","http://localhost:8000/mcp/assistant"]}}}
If you already have other servers in
mcpServers, addlaravel-assistantalongside them — don't replace the whole object.
Step 4 — Restart Claude Desktop
Fully quit and reopen Claude Desktop (a window reload isn't enough — it spawns the server processes on launch). When it comes back, your tools appear in the chat's tool menu.
Step 5 — Try it
Ask Claude a question your server can answer from its data:
"Using the Assistant tools, what were our top 5 customers by revenue, and which sales rep owns each?"
Claude reads the schema resource, picks the right tool (or composes a read-only SELECT), and answers from your live data — exactly the workflow the #[Instructions] block describes.
Securing it for real use
The setup above is unauthenticated — fine for localhost, dangerous anywhere else. Never expose an unauthenticated MCP server publicly; it's a direct line to your data. Two things make it production-safe.
1. Authenticate who can ask. Protect the route and pass a token from the client:
// routes/ai.php — protect with Sanctum
Mcp::web('/mcp/assistant', AssistantServer::class)
->middleware(['auth:sanctum', 'throttle:mcp']);
// claude_desktop_config.json — pass the bearer token through the bridge{"mcpServers":{"laravel-assistant":{"command":"npx","args":["-y","mcp-remote","https://your-app.com/mcp/assistant","--header","Authorization: Bearer YOUR_SANCTUM_TOKEN"]}}}
For the broadest client compatibility, laravel/mcp also supports OAuth 2.1 via Laravel Passport (Mcp::oauthRoutes() + auth:api middleware), the mechanism the MCP spec recommends. Reach for it when you need to support clients that only speak OAuth.
2. Scope what the app can read. Authentication decides who can ask; the database grant decides what the app can read on their behalf. As Part 1 covered, every returned row leaves your perimeter — so the read-only user behind your assistant connection should hold SELECT on only the tables you mean to expose. The application guardrails are your first line; a scoped grant is the backstop if one is ever bypassed.
CREATE USER 'assistant_ro'@'%' IDENTIFIED BY 'a-strong-password';
GRANT SELECT ON app_db.customers TO 'assistant_ro'@'%';
GRANT SELECT ON app_db.orders TO 'assistant_ro'@'%';
GRANT SELECT ON app_db.order_details TO 'assistant_ro'@'%';
GRANT SELECT ON app_db.products TO 'assistant_ro'@'%';
GRANT SELECT ON app_db.payments TO 'assistant_ro'@'%';
-- ...one line per table you expose; nothing else.
FLUSH PRIVILEGES;
Point the assistant connection at that user, then verify the grant from the database's side — don't trust the config comment, check it:
# Every line should be SELECT on a table you intend to expose —
# no other tables, no INSERT/UPDATE/DELETE, no schema-wide "app_db.*".
mysql -u root -p -e "SHOW GRANTS FOR 'assistant_ro'@'%';"
# And confirm the negative cases are denied:
mysql -u assistant_ro -p app_db -e "DELETE FROM orders LIMIT 1;" # must fail
mysql -u assistant_ro -p app_db -e "SELECT * FROM users LIMIT 1;" # must fail
If a write or a non-exposed table succeeds, the grant is too broad — fix it before going live. A scoped grant means even a bug in your query guard can only ever read the tables you already chose to expose. (For zero impact on production load, point it at a read replica.)
Two ways to connect
We used a web server plus the mcp-remote bridge because it's the same endpoint you'd authenticate and deploy for real. But Laravel MCP supports a second transport that's often simpler for purely local use:
Web (Mcp::web, used above) |
Local / stdio (Mcp::local) |
|
|---|---|---|
| Who starts the server |
You do — php artisan serve, kept running |
Claude Desktop does — it runs the command for you |
| Client config | npx mcp-remote <url> |
php artisan mcp:start <handle> |
| Auth | Sanctum / OAuth over HTTP | Inherits your CLI environment |
| Best for | Shared/remote access, production | One developer, one machine |
To use the local transport, register the server with Mcp::local('assistant', AssistantServer::class) in routes/ai.php, then have Claude Desktop launch it directly:
{"mcpServers":{"laravel-assistant":{"command":"/opt/homebrew/bin/php","args":["/absolute/path/to/your-app/artisan","mcp:start","assistant"]}}}
Use absolute paths. Claude Desktop doesn't inherit your shell's
PATH, so"command": "php"will usually fail to launch. Point at the full binary (which php→ e.g./opt/homebrew/bin/php) and the full path to your project'sartisan. With this transport there's nothing to start by hand — Claude Desktop spawns the process — but you trade away the HTTP auth story from the previous section.
You've gone the full loop:
- understood MCP as a self-describing interface built for models, not developers;
- tested a server in isolation with the Inspector;
- made the tools agent-native with annotations, structured output, and truncation signalling;
- connected it to Claude Desktop and locked down both who can ask and what the app can read.
The same patterns scale to any Laravel application: expose your domain as a handful of well-described tools, return structured data, and be deliberate about what leaves your perimeter.
Get the code. The complete, runnable server used throughout this tutorial — every tool, the schema resource, the prompt, and the test suite — is in the accompanying project: laravel-mcp-demo