Skip to content

Add A New Internal MCP Tool

Adding an internal MCP tool means giving the team's Claude client (or any other agent talking to InternalYayaServer) a new read or write operation against Yaya's business data. Every tool in app/Mcp/Tools/Internal/ extends the abstract InternalTool base class, which enforces the permission gate, tags the tool's MCP annotations, and hands the request a resolved AdminUser. The work is mostly: write the tool class, register it, and get the permission and pagination/filtering contracts right — the parts of this codebase's MCP tools that have actually caused incidents before.

Extend InternalTool, not Laravel\Mcp\Server\Tool directly

InternalTool overrides eligibleForRegistration() so a tool only shows up for an admin user who both has generic MCP access and the specific permission the tool declares [@internal-tool]:

public function eligibleForRegistration(): bool
{
    $user = $this->resolveAdminUser();

    return $user instanceof AdminUser
        && $user->can(AdminAccess::PERMISSION_MCP_ACCESS)
        && $user->can($this->requiredPermission);
}

resolveAdminUser() tries auth()->user(), then the admin guard, then the api guard in turn, because the internal server can be reached either through a Filament-authenticated admin session or through a Passport bearer token minted for a machine client [@internal-tool]. Your tool does not need to re-implement any of this; it only needs to set two properties correctly.

AdminAccess defines three permission constants: mcp.access (generic gate), mcp.tools.read, and mcp.tools.write [@admin-access]. InternalTool defaults $requiredPermission to mcp.tools.read and $isReadOnly to true. A new read-only tool (the common case — most internal tools list or summarize data, like SearchUsersTool or GetAlertsSummaryTool) needs no override at all. A tool that mutates state, like ApproveEvidenceTool and RejectEvidenceTool, must set both:

protected bool $isReadOnly = false;
protected string $requiredPermission = AdminAccess::PERMISSION_MCP_TOOLS_WRITE;

InternalTool::toArray() reads $isReadOnly and stamps the tool's MCP response with readOnlyHint or destructiveHint accordingly [@internal-tool]. This is what lets an AI client reason about which tools are safe to call speculatively versus which ones need explicit confirmation — get it wrong and a write tool looks harmless to the calling agent.

Delegate writes to a service, don't mutate models in the tool

ApproveEvidenceTool looks up the UserActionStep, checks its status, and then calls app(UserActionService::class)->approveEvidence($step, $admin) rather than setting fields on the model itself [@approve-evidence-tool]. The tool's job is request validation, permission enforcement, and shaping the Mixpanel event and JSON response; the actual state transition (and any side effects like auto-completing the action or triggering a payout) lives in the service layer, which is also reachable from Filament and other call sites. If your new tool writes data, follow this pattern instead of duplicating business logic inline.

Get filtering and pagination right for tools that list users

If your tool returns a list of users, filter verified and onboarded as separate, explicit boolean parameters — never as one combined "active" flag. See Verified vs Onboarded for why these are distinct states in this schema. QueryUsersTool takes both as independent nullable filters and documents in its own description why the calling agent must not conflate page count with total count:

Returns up to `limit` users (default 100, max 500). If `has_more` is true in
the response, call this tool again with `offset = next_offset` to retrieve the
next page. ALWAYS check `total_matching` against `count` before reporting
totals to the user

[@query-users-tool]. This description is itself part of the contract: an LLM client only sees the tool's schema and description, so pagination truncation has to be signaled in fields (has_more, next_offset, total_matching) and reinforced in prose the model will actually read, not just in code comments. An MCP tool that lacked separate verified/onboarded filters once caused a real incident where the tool reported zero app users to an operator who was investigating a real problem.

Register the tool

Laravel MCP scaffolds a new tool class with php artisan make:mcp-tool ToolName [@openspec-project]; move the generated file into app/Mcp/Tools/Internal/ and change its base class to InternalTool. Then add it to the $tools array in app/Mcp/Servers/InternalYayaServer.php [@internal-server] — nothing is auto-discovered, so a tool that exists on disk but is missing from this array will never be callable. If the tool is meant for Dify rather than internal team/Claude use, it belongs on YayaServer instead; see MCP Servers for the split between the two servers.

It is also worth adding one line to InternalYayaServer's $instructions block describing when to reach for the new tool, since that text is the only orientation the LLM gets across the whole tool catalog [@internal-server].

Test it

tests/Feature/Mcp/InternalYayaServerTest.php is the reference test: it creates AdminUser::factory()->mcpViewer(), ->mcpAdmin(), and a plain ->manager() user, sets each as the authenticated admin guard user, and asserts which tool names actually appear in the server's registered list for that user [@mcp-server-tests]. Add your tool's class to the relevant existing "tool names for role X" assertions, and add a dedicated test that calls the tool through FakeTransporter and checks both the write path (if any) and the permission boundary — that a manager-only admin, lacking mcp.tools.read/mcp.tools.write, cannot see or invoke it.

For exact lookup of every existing tool, its permission, and what it reads/writes, see MCP Tools reference. For the concept this all serves, see MCP Tools.