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.