The Model Context Protocol has become the default way to plug an AI agent into a real system. Most examples expose read-only tools: search this, look up that. The design problem gets interesting when you let an agent write.
This site runs three MCP servers on Laravel's first-party laravel/mcp package. One checks domain availability for VividSol, our hosting and domain business. One lets an agent author articles for this Insights section. One lets an agent propose and submit n8n workflow templates like the ones in my workflow library. Two of the three write data. None of them can publish anything.
That last sentence is the whole design. Here is how it is put together.
One server, a handful of narrow tools
An MCP server in Laravel is a class that lists its tools and tells the client how to use them:
#[Name('Article Authoring Server')]
#[Version('1.0.0')]
#[Instructions(<<<'TEXT'
Lets you author articles for omerfayyaz.com in the site owner's voice and submit them into the review queue.
Workflow:
1. Call "get-article-requirements" first ...
2. (Optional) Ground the article on real curated material ...
3. Write the article following the rules, then call "submit-article-draft".
Submitted articles are stored as PENDING-REVIEW drafts. Nothing is published until a human reviews, approves, and publishes it.
TEXT)]
class ArticleServer extends Server
{
protected array $tools = [
ArticleRequirementsTool::class,
ListSourceItemsTool::class,
GetSourceItemTool::class,
SubmitArticleDraftTool::class,
];
}
Four tools, each with one job:
get-article-requirementsreturns the voice guide, the factual rules, the format rules, and the exact field contract.list-source-itemsandget-source-itemlet the agent ground a piece in material the site has already collected.submit-article-draftis the only tool that changes anything.
The instructions block matters more than it looks. It is the first thing a client reads, so it states the workflow in order and states the limit plainly. An agent that knows its submissions are held for review does not try to work around it, and a person reading the connector setup learns the same thing.
Mark what is safe so clients can trust it
laravel/mcp supports tool annotations as PHP attributes. The requirements tool carries #[IsReadOnly] and #[IsIdempotent], and so does the domain checker. The submit tool carries neither, and its description says in capitals that it WRITES data.
Annotations are hints, not enforcement. A client can ignore them. But they are how a well-behaved client decides whether to ask the user before calling a tool, so getting them right is the cheapest guardrail you have. I would rather a client asks someone once too often than silently fires a write tool it thought was a lookup.
Validate like it is a public form, because it is
The submit tool validates input with the same validate() you would use in a controller:
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'confidence' => ['required', Rule::in(['low', 'medium', 'high'])],
'topic_tag' => ['nullable', 'string', Rule::in($allowedTags)],
'sources_cited.*' => ['string', 'url'],
'source_item_id' => ['nullable', 'integer', 'exists:source_items,id'],
], [
'confidence.in' => 'confidence must be one of: low, medium, high. Use "low" if the source material was thin.',
'topic_tag.in' => 'topic_tag must be one of the allowed tags: '.implode(', ', $allowedTags).'.',
]);
The custom messages are written for a model, not a person. When validation fails, the message goes back to the agent as the tool result, and a model corrects itself far more reliably when the error names the values it is allowed to use. Treat validation messages as part of the prompt.
The confidence field is required on purpose. Every submission has to say how well grounded it thinks it is. A "low" is a signal for the reviewer, not a failure.
The write path ends in a queue
The tool hands validated data to a StoreSubmittedDraft action. That action stores an AiDraft with the status pending_review, records where it came from (the origin, the author model, the sources it cited), and returns a link to the review screen.
The public Insights page reads only from the articles table. A draft becomes an article through a separate PublishDraft action, which runs when a signed-in person approves it in the admin and records who reviewed it and when.
So the agent's blast radius is this: it can add rows to a queue that only I read. The worst case is clutter. The in-app writer feeds the same queue, under rules I describe in Grounding an AI Writer in Laravel.
The n8n template factory follows the same pattern. An agent can propose template ideas and submit linted workflow builds, and everything it sends lands in a status a person has to review. Nothing deploys automatically.
Authentication you can explain in one line
Each server is mounted with Mcp::web() in routes/ai.php, behind a long random secret in the URL path, a rate limit, and a logging middleware:
Mcp::web('/mcp/articles/'.config('articles_mcp.path_token'), ArticleServer::class)
->middleware(['throttle:30,1', 'mcp.log:articles']);
An Artisan command generates the token, so rotating it takes a minute. That is a deliberately small gate, and it is acceptable here only because of the queue behind it: a leaked token lets someone submit drafts I will reject. If a tool could publish, take payments, or email customers, a URL secret would not be enough, and Laravel's MCP package supports proper OAuth for that case.
Match the strength of the authentication to the worst thing the tools can do.
Log every call, including the calls your tools make
The mcp.log middleware writes one row per MCP request: the JSON-RPC method, the tool name, the arguments, the result, any error, the duration, and the client details. It also arms a recorder that listens to Laravel's HTTP client events. When a tool calls a third-party API (the domain checker calls VividSol's billing system), that outbound request is stored against the same log row, with secrets, tokens, and API keys redacted.
This is the part people skip, and it is the part that pays off first. When an agent reports that a tool failed, I can see whether the tool failed, the upstream API failed, or the agent sent nonsense arguments. Without that log you are debugging a chat transcript.
Two rules in that middleware are worth copying:
- Analytics must never break the response. A failed log write is reported and swallowed.
- Always flush the recorder, so state from one request never leaks into the next.
Test tools the way the client calls them
laravel/mcp lets a test call a tool through its server, exactly as a client would:
$response = ArticleServer::tool(SubmitArticleDraftTool::class, [
'title' => 'Shipping n8n Workflows That Survive Production',
'body' => "## Why this matters\n\nProduction workflows need real error handling.",
'topic_tag' => 'not-a-real-tag',
'confidence' => 'high',
]);
$response->assertHasErrors();
$this->assertSame(0, AiDraft::count());
My tests cover the happy path, every rejection, how missing tags are filled in, and the property that matters most: a pending draft is invisible on the public site, and its URL returns a 404 until a person publishes it. I cover the full approach in Testing AI Features in Laravel.
My take
MCP made connecting agents easy. It did not make it safe; that is still application design.
The pattern I keep coming back to is boring on purpose: narrow tools, honest annotations, validation messages written for models, a queue in front of anything public, authentication that matches the worst case, and a log of every call. If you are adding MCP to a Laravel app, start with read-only tools. Then add exactly one write tool that can only create something a person must approve. You will learn more from that one tool than from a dozen lookups.
It is the same approach we take at VividSol when we connect AI agents to a client's systems, and the kind of architecture work I consult on.