AI & Search5 min read

How I Would Build a Secure MCP Server in Laravel 13

A practical Laravel MCP security model covering tool design, OAuth or Sanctum, authorization, schemas, idempotency, audit trails, and testing.

JBy Jeffrey Klaassen van Oorschot

Laravel 13 makes an MCP server feel familiar: routes, middleware, validation, authorization, dependency injection, and tests. That is exactly the right mental model. An MCP tool is not a magical AI function. It is an API endpoint whose caller happens to be a model, and models are unusually good at finding accidental capability.

Design the capability before the protocol

I start by listing what the user is allowed to accomplish, not which internal services exist. create_support_draft is a reasonable capability. execute_query, call_service, and update_model are not: their authority is impossible to understand from the name and their input space is much too broad.

A tool should have one owner, one purpose, a narrow schema, a predictable cost, and a small response. Read-only is not automatically harmless. A search tool can still leak another tenant’s records, expose internal notes, or become an expensive scraping endpoint.

  • Prefer business verbs over infrastructure verbs
  • Return the minimum data required for the next decision
  • Split discovery tools from mutation tools
  • Do not expose arbitrary SQL, shell, URLs, or class names

Authenticate the server; authorize inside every tool

Laravel documents OAuth 2.1 with Passport as the broadly compatible choice for remote MCP clients, with Sanctum as a pragmatic option for applications that already use it. Authentication tells me who is calling. It does not answer whether that user may read this order, issue this refund, or search this tenant.

I keep authorization inside the tool handler, as close as possible to loading the resource. The query itself is tenant-scoped, then a policy checks the action. Filtering a globally loaded result after the fact is both slower and easier to get wrong.

Authentication at the route, authorization at the operation
Mcp::web('/mcp/support', SupportServer::class)
    ->middleware(['auth:sanctum', 'throttle:mcp']);

public function handle(Request $request): Response
{
    $ticket = Ticket::query()
        ->whereBelongsTo($request->user()->organisation)
        ->findOrFail($request->get('ticket_id'));

    if (! $request->user()->can('draftReply', $ticket)) {
        return Response::error('Permission denied.');
    }

    // Return only fields required by the tool contract.
}

Treat tool descriptions and data as separate trust zones

Tool descriptions are trusted application instructions. Ticket text, uploaded documents, product descriptions, and web content are untrusted data even when they contain phrases such as ‘ignore previous instructions’. I tell the model which fields are data, but I never rely on prompting alone for security. Authorization and allowed operations remain enforced by PHP.

For external URLs, I use allow-lists, block private and metadata-network ranges, limit redirects and response size, and fetch through a dedicated client. Otherwise a convenient ‘read URL’ tool becomes an SSRF primitive. For files, I validate type from content, scan where appropriate, and avoid returning raw storage paths.

Make mutations idempotent and reviewable

MCP clients retry and models can call the same tool twice. Every meaningful mutation accepts an operation key tied to the authenticated user and canonical input. The database stores the result. Reusing a key with different input returns a conflict instead of creating a second effect.

For high-impact operations, the first tool creates a proposal and returns a summary. A separate confirmation tool applies it after current permissions and state are checked again. That pattern creates a natural human-approval point and prevents stale context from becoming an action.

  • Audit actor, client, tool, resource, input hash, result, and duration
  • Never log access tokens or sensitive payloads
  • Give proposals an expiry
  • Re-authorize at confirmation time

Test the abuse cases first

The happy path is rarely where an MCP server fails. I test cross-tenant identifiers, missing scopes, unexpected enum values, oversized arrays, repeated calls, stale versions, canceled approvals, prompt injection in stored content, and expensive searches under rate limits. I also verify that error responses do not reveal whether a forbidden resource exists.

Finally, I run the server with a real MCP inspector or client in a non-production environment. Unit tests prove handlers; protocol tests prove discovery, schemas, authentication, and response shape. Both are necessary.

Use this in practice

  • Give every tool one narrow business capability
  • Authenticate the MCP route and authorize each resource action
  • Scope database queries before loading records
  • Use operation keys and proposal-confirmation for mutations
  • Test tenant escape, retries, injection, limits, and stale state

Keep reading

Go

Go 1.26: The Changes I Would Actually Use

A practical look at the Go 1.26 changes that affect ordinary application code: new expressions, modernizers, profiling, module defaults, and the new garbage collector.

5 min readRead article