You're reading this on a Laravel app that exposes an MCP server at /mcp/portfolio. Claude reads posts and projects through it; I write new posts to it from the same chat window. The whole thing is a few hundred lines of Laravel.
The Model Context Protocol gives an LLM structured access to your application — tools it can call, resources it can read, prompts it can run. Adoption has grown fast since Anthropic published the spec, and Claude's connector ecosystem makes it the path of least resistance for exposing your data to an AI client.
Here's the pattern I settled on.
One Route, JSON-RPC, Streamable HTTP
The current MCP transport is "Streamable HTTP" — a single endpoint that accepts POST with a JSON-RPC 2.0 body and optionally streams responses via SSE. For tools that return in under a second, plain JSON is fine; reach for SSE only when a tool genuinely needs to stream.
// routes/api.php
Route::post('/mcp/portfolio', McpController::class)
->middleware(['auth:sanctum', 'throttle:mcp']);The rate limiter lives in your service provider:
// app/Providers/AppServiceProvider.php
RateLimiter::for('mcp', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip())
->response(fn () => response()->json([
'jsonrpc' => '2.0',
'error' => ['code' => -32000, 'message' => 'Rate limit exceeded'],
], 429));
});The controller dispatches on the JSON-RPC method:
public function __invoke(Request $request): JsonResponse
{
$payload = $request->validate([
'jsonrpc' => 'required|in:2.0',
'id' => 'nullable',
'method' => 'required|string',
'params' => 'array',
]);
$result = match ($payload['method']) {
'initialize' => $this->initialize(),
'tools/list' => ['tools' => $this->registry->describe($request->user())],
'tools/call' => $this->registry->dispatch(
$request->user(),
$payload['params']['name'],
$payload['params']['arguments'] ?? []
),
default => throw new MethodNotFoundException($payload['method']),
};
return response()->json([
'jsonrpc' => '2.0',
'id' => $payload['id'] ?? null,
'result' => $result,
]);
}
private function initialize(): array
{
return [
'protocolVersion' => '2025-06-18',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'portfolio-mcp', 'version' => '1.0.0'],
];
}That's the whole transport. Everything else lives in the tool registry.
The Tool Registry
Every MCP tool implements a single interface:
interface McpTool
{
public function name(): string;
public function description(): string;
public function inputSchema(): array;
public function ability(): string; // e.g. 'mcp:read'
public function handle(User $user, array $arguments): mixed;
}A concrete example — fetching posts:
final class GetPostsTool implements McpTool
{
public function name(): string
{
return 'get-posts-tool';
}
public function description(): string
{
return 'Get published blog posts. Optionally filter by tag slug.';
}
public function ability(): string
{
return 'mcp:read';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 30],
'tag' => ['type' => 'string'],
],
];
}
public function handle(User $user, array $arguments): array
{
return Blog::published()
->when($arguments['tag'] ?? null, fn ($q, $t) => $q->withTag($t))
->orderByDesc('published_at')
->limit($arguments['limit'] ?? 10)
->get(['title', 'slug', 'summary', 'published_at'])
->toArray();
}
}The registry resolves every tool via a tagged container binding:
// AppServiceProvider::register()
$this->app->tag([
GetPostsTool::class,
GetPostTool::class,
SearchContentTool::class,
CreatePostTool::class,
], McpTool::class);Inside the registry, describe() filters tools by the bearer token's abilities; dispatch() validates arguments against the schema before calling handle():
public function describe(User $user): array
{
return collect($this->app->tagged(McpTool::class))
->filter(fn (McpTool $t) => $user->tokenCan($t->ability()))
->map(fn (McpTool $t) => [
'name' => $t->name(),
'description' => $t->description(),
'inputSchema' => $t->inputSchema(),
])
->values()
->all();
}Adding a tool is now one new class plus one tag entry. No controller changes, no route changes.
Bearer Auth via Sanctum
Claude's connector sends Authorization: Bearer <token>. Sanctum's auth:sanctum middleware reads it. I issue scoped tokens from the dashboard:
$readToken = $user->createToken('claude-read', ['mcp:read']);
$writeToken = $user->createToken('claude-write', ['mcp:read', 'mcp:write']);The token's abilities map onto which tools the registry will surface. A read-only token gets get-posts-tool and friends; the write token also gets create-post-tool. Same registry, scope filter applied in describe() and dispatch().
A 401 from the endpoint surfaces in the client as "couldn't reach the server" — which is the right failure mode. The connector will not list tools it cannot authenticate against, so a misconfigured token never silently exposes write access.
Verifying It Works
Before pointing a real client at it, hit the endpoint with curl:
curl -X POST https://your-app.test/mcp/portfolio \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'You should get a JSON response listing the tools your token has access to. A 401 means the token is wrong. An empty tools array means the token has no abilities matching any registered tool — check ability() on each tool class against the abilities you minted into the token.
What's Worth Knowing Before You Ship
Accept: application/json, text/event-stream. Claude's connector negotiates both. A plain Content-Type: application/json response is fine for short-running tools. Reach for SSE only when a tool genuinely benefits from it.
Tool names: prefer hyphens. The spec doesn't require it, but hyphenated names render cleanly in the tool picker; underscored ones look like raw identifiers. Small detail; users read your tool names.
Validate arguments inside dispatch(). Don't trust that the client sent what your inputSchema declared. Run Validator::make($arguments, $rules) before calling handle(). Bad inputs should return a JSON-RPC error object, not a Laravel 500.
Cache tools/list. Most clients call it on every reconnect. The response is stable between deployments, so cache it per ability-set for a few minutes. The bandwidth saving is small; the database query saving over a long-running connection is not.
When This Is Worth Building
If your app has structured data a user already accesses through a UI, an MCP server makes that data conversational. Blog posts, project records, support tickets, calendar entries — anything that's already a CRUD resource is a 50-line tool wrapping the existing repository.
What's not worth it: wrapping endpoints you wouldn't expose as an API anyway. The MCP transport is just a delivery mechanism. The hard work is still picking the right abstraction for the data underneath.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.