Scaling a Laravel MCP Tool Registry to 46 Tools
The previous post on MCP servers for Laravel covered JSON-RPC routing, Streamable HTTP transport, and Sanctum bearer auth. What it did not cover is what happens inside the tool registry once it grows past a handful of tools. This portfolio's MCP server currently exposes 46 tools across posts, projects, stack items, experience, and the AI panel. Here is the structure that keeps it navigable, the auth model that scopes access per tool, and the three things that broke on the way to 46.
One class per tool
A closure-based registry works at 5 tools. At 46, it becomes a 600-line service provider that nobody wants to open. The fix is an interface and one class per tool.
interface McpTool
{
/**
* Unique tool name sent to the MCP client in tools/list.
*/
public function name(): string;
public function description(): string;
/** @return array<string, mixed> JSON Schema object for this tool's input. */
public function inputSchema(): array;
/**
* Sanctum token ability required to call this tool.
*/
public function requiredAbility(): string;
/**
* Execute the tool with validated input.
*
* @param array<string, mixed> $input Validated and typed input.
* @return mixed Serialisable result.
*/
public function execute(array $input): mixed;
}A concrete tool looks like this:
class GetPostTool implements McpTool
{
public function __construct(private readonly PostRepository $posts) {}
public function name(): string { return 'get-post-tool'; }
public function description(): string
{
return 'Retrieve a single published blog post by its slug. Returns title, body, tags, and published date.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'slug' => ['type' => 'string', 'description' => 'Post slug.'],
],
'required' => ['slug'],
];
}
public function requiredAbility(): string { return 'posts:read'; }
public function execute(array $input): array
{
$post = $this->posts->findBySlug($input['slug']);
if (! $post) {
return ['error' => "No post found with slug '{$input['slug']}'."];
}
return $post->toMcpArray();
}
}The execute method returns an error array rather than throwing on not-found. Tool errors that propagate as exceptions produce a JSON-RPC error response, which most MCP clients surface as a hard failure. Returning an error value inside the tool result keeps the conversation going — Claude reads the error and decides what to do next.
Ability-scoped dispatch
Each tool declares the Sanctum ability it requires. The registry checks the token before calling execute, so individual tools never touch auth logic.
class McpToolRegistry
{
/** @var array<string, McpTool> */
private array $tools = [];
public function register(McpTool $tool): void
{
$this->tools[$tool->name()] = $tool;
}
/** @return list<array<string, mixed>> */
public function list(): array
{
return array_values(array_map(fn($tool) => [
'name' => $tool->name(),
'description' => $tool->description(),
'inputSchema' => $tool->inputSchema(),
], $this->tools));
}
/**
* Validate, authorise, and execute a tool call.
*
* @param array<string, mixed> $input
* @throws McpToolNotFoundException
* @throws McpUnauthorizedException
* @throws McpValidationException
*/
public function dispatch(
string $name,
array $input,
\Laravel\Sanctum\PersonalAccessToken $token
): mixed {
if (! isset($this->tools[$name])) {
throw new McpToolNotFoundException("Tool '{$name}' not registered.");
}
$tool = $this->tools[$name];
if (! $token->can($tool->requiredAbility())) {
throw new McpUnauthorizedException(
"Token lacks required ability '{$tool->requiredAbility()}'."
);
}
$this->validateInput($tool->inputSchema(), $input);
return $tool->execute($input);
}
/** @param array<string, mixed> $schema */
private function validateInput(array $schema, array $input): void
{
foreach ($schema['required'] ?? [] as $field) {
if (! array_key_exists($field, $input)) {
throw new McpValidationException("Missing required field: '{$field}'.");
}
}
foreach ($schema['properties'] ?? [] as $field => $definition) {
if (! array_key_exists($field, $input)) {
continue;
}
$this->assertType($field, $input[$field], $definition['type']);
}
}
private function assertType(string $field, mixed $value, string $expected): void
{
$valid = match ($expected) {
'string' => is_string($value),
'integer' => is_int($value),
'number' => is_numeric($value),
'boolean' => is_bool($value),
'array' => is_array($value) && array_is_list($value),
'object' => is_array($value),
default => true,
};
if (! $valid) {
throw new McpValidationException(
"Field '{$field}' must be of type {$expected}, got " . get_debug_type($value) . '.'
);
}
}
}Tag-based auto-discovery
With 46 tools, listing them by hand in a service provider is fragile. Instead, tag each tool class in the container and let the registry pick them up automatically.
// In a ToolServiceProvider or config/mcp.php driven loader:
$toolClasses = [
GetPostTool::class,
GetPostsTool::class,
CreatePostTool::class,
UpdatePostTool::class,
PublishPostTool::class,
GetProjectTool::class,
// ... remaining 40 tools
];
foreach ($toolClasses as $class) {
$this->app->bind($class);
}
$this->app->tag($toolClasses, 'mcp.tools');
$this->app->singleton(McpToolRegistry::class, function ($app): McpToolRegistry {
$registry = new McpToolRegistry();
foreach ($app->tagged('mcp.tools') as $tool) {
$registry->register($tool);
}
return $registry;
});Adding a new tool is one line in the $toolClasses array. The registry, the JSON-RPC router, and the auth layer need no changes.
What broke at 46 tools
Three things, in order of how painful they were.
The tools/list response payload. Claude Code and other MCP clients fetch the full tool list on every session start. At 46 tools with verbose descriptions and full JSON Schema definitions, that payload hit 28KB. The fix is keeping descriptions tight — one sentence stating what the tool does and when to use it, nothing more. Tool descriptions are not documentation; they are routing hints for the model.
Input type coercion from JSON-RPC. MCP clients send JSON, so integers sometimes arrive as floats (1 becomes 1.0) and booleans sometimes arrive as integers (1 for true). The assertType method above handles this for the types this registry uses, but array_is_list versus associative array distinction is where edge cases still surface. Test every tool with the actual MCP client you intend to ship, not just PHPUnit.
Error message leakage. Early versions of execute in several tools threw ModelNotFoundException with the full Eloquent message. Claude read No query results for model [App\Models\Post] with ID 9999 and hallucinated that IDs were the right input format, not slugs. Tool error messages need to be written for the model reading them, not for a developer reading a log.
Al Amin Ahamed
Senior software engineer & AI practitioner. 5+ years shipping Laravel platforms, WordPress plugins, WooCommerce extensions, and AI-driven products.
About me →More from the blog