Sentinel's MCP server is how Claude, ChatGPT, and any MCP-compatible assistant can operate your monitoring from inside a conversation. Ask "is anything down?", create a monitor, resolve an incident. It runs in production on laravel/mcp, Laravel's first-party MCP package, which hasn't reached 1.0 yet. This post is the engineering write-up, covering what the package gives you, how the server is put together, and the handful of things I had to learn the hard way while the ecosystem was still wet cement.
If you want the product view instead, the MCP page and the registry announcement cover that side.
What laravel/mcp actually gives you
Under the hood, MCP is JSON-RPC. A client calls tools/list, gets a catalog of tools with schemas, and then calls tools/call with arguments. You could hand-roll that in a controller. The package's value is that it makes the whole thing feel like Laravel. A server is a class, a tool is a class, schemas are fluent builders, and the routing is one line.
Sentinel's server declares itself with attributes:
#[Name('Sentinel')]
#[Version('1.0.0')]
#[Instructions('Access to the authenticated user\'s Sentinel uptime monitoring account, scoped to their current team. Read tools (available to any plan with API access): get_uptime_summary answers "is anything down?", ...')]
class SentinelServer extends Server
{
protected array $tools = [
GetUptimeSummaryTool::class,
GetAttentionItemsTool::class,
ListMonitorsTool::class,
// ... 26 tools total
];
}
The Instructions attribute deserves more respect than it gets. It's delivered to the model as part of the connection, which makes it prompt real estate, the one paragraph where you get to teach every assistant how to use your server. Mine names the tools that answer the most common questions ("is anything down?" maps to get_uptime_summary), states which tools need which plan, and tells the model what to do when a write is refused (report the reason to the user rather than retrying). That last sentence alone eliminated a class of confused retry loops.
Anatomy of a tool
Here's the shape of a real one, lightly trimmed. It's the read-only tool that answers "who has access to this team, and does everyone have 2FA on?":
#[IsReadOnly]
#[Description('Lists the members of the user\'s current team, owner included, with the fields an access review needs: role, active or disabled status, whether two-factor authentication is enabled, and last sign-in.')]
class ListTeamMembersTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$team = $request->user()?->currentTeam;
if (! $team) {
return Response::error('No active team found for this account.');
}
// ... build $members from the team ...
return Response::structured([
'team' => $team->name,
'count' => $members->count(),
'members' => $members->values(),
]);
}
public function outputSchema(JsonSchema $schema): array
{
return [
'team' => $schema->string(),
'count' => $schema->integer(),
'members' => $schema->array()->items($schema->object([
'role' => $schema->string()->description('owner, admin, editor, or viewer'),
'mfa_enabled' => $schema->boolean(),
'last_login_at' => $schema->string()->nullable(),
])),
];
}
}
Three things earn their keep here. #[IsReadOnly] is an MCP annotation clients can use to decide what's safe to call without confirmation. Response::structured plus outputSchema gives the model typed output instead of a string blob to parse, which measurably reduces the "the assistant misread my data" category of weirdness. And Response::error for the no-team case returns a proper tool error the model can relay, instead of an exception page.
Descriptions are the other half of the interface. The model chooses tools by reading them, so I write each one like documentation for a sharp colleague who has never seen the product. Each one says what it answers, what the ids mean, and which tool to call first to get them.
One server, two front doors
Here is the single most practical thing I can tell you about shipping an MCP server for real users. Authentication is where the client ecosystem actually fragments. Claude Code and Claude Desktop happily send an Authorization: Bearer header. The Claude.ai web and mobile apps only speak OAuth, with dynamic client registration. Same protocol, same tools, incompatible front doors.
The package handles this better than I expected. The same server class mounts twice:
Mcp::oauthRoutes();
// Bearer token: Claude Code, Claude Desktop, headless agents.
Mcp::web('/mcp/sentinel', SentinelServer::class)
->middleware(['auth:sanctum', LogApiRequest::class, EnsureApiAccess::class, 'throttle:60,1']);
// OAuth 2.1 via Passport: the Claude.ai connector flow.
Mcp::web('/mcp/sentinel-oauth', SentinelServer::class)
->middleware(['auth:api', LogApiRequest::class, EnsureApiAccess::class, 'throttle:60,1']);
Mcp::oauthRoutes() publishes the OAuth discovery metadata and dynamic client registration endpoints the Claude.ai flow requires, advertising a single mcp:use scope. Passport does the heavy lifting behind auth:api. One wrinkle is worth knowing about. The OAuth guard resolves its own user type rather than your App\Models\User, so any middleware that reaches for user relationships needs to look up the canonical user by id first. My plan-gating middleware does exactly that, and the lookup is easy to forget because the bearer path never needs it.
There's also a /.well-known/mcp.json on the domain, a thin discovery document naming both endpoints and their auth types. It's a convention rather than a ratified standard, so mine deliberately stays minimal.
Authorization is the actual product
Tools are the easy part. The part that matters for a multi-tenant SaaS is what happens when the model calls them, because an assistant with a user's token is an autonomous agent holding real credentials.
Sentinel's MCP routes share the exact middleware stack as the REST API, so the rules can't drift between surfaces. Read tools work on every plan (free included; the one exception is the on-demand check tool, which consumes real probe capacity and needs a paid plan), write tools require full API access, and a token's own permissions are enforced on top, so a read-only token handed to an agent stays read-only no matter what the model asks for. Write tools additionally check per-tool permissions and return a refusal that names the missing permission, which pairs with the instructions telling the model to relay refusals instead of retrying.
Everything is scoped to the authenticated user's current team, same as the dashboard. An MCP server is just another client of your authorization model, and it will find every hole in it faster than a human would.
The gotchas
Clients don't paginate tools/list. The package paginates the tool catalog at 15 by default and provides a cursor. A surprising number of clients and directory scanners never follow it, so they'd see the first 15 tools and conclude the rest don't exist. Sentinel serves all 26 in a single page:
public int $defaultPaginationLength = 50;
If your tool count crosses the default page size and some client mysteriously can't see a tool, this is why.
Log per-tool usage yourself. Every MCP call hits one HTTP path, which makes standard request logging useless for "which tools do people actually use?" My API request logger peeks at the JSON-RPC body and appends the tool name from params.name to the logged path, so tools/call rows become per-tool usage rows. Ten lines, and it's how I know which tools earn their place in the catalog.
Tests are just Pest. The package ships test helpers that make tool tests feel like feature tests:
SentinelServer::actingAs($user)
->tool(ListGroupsTool::class, [])
->assertOk()
->assertSee('"monitors_count":2');
Every tool has tests for its happy path, its cross-team refusals, and its plan gating. Riding a v0 package in production is a lot less exciting when your whole surface is covered by tests you can run after every update.
Watch how the package enters your dependency tree. My one genuine production surprise wasn't an API change at all. laravel/mcp first arrived in this codebase transitively, through a dev-only package, so composer install --no-dev on the production box quietly skipped it, and every MCP route 404'd in production while working perfectly in local and CI. The fix was one line (promote it to a real require), but the lesson generalizes to any package that registers routes. As for the pre-1.0 label itself, the test coverage above is what makes riding a v0 package routine instead of scary. If you're deciding whether to build on it now, my answer is yes, with coverage.
Where it ends up
The server is listed in the official MCP Registry as io.rootstuff/sentinel, verified against this domain, so assistants can discover it without anyone pasting URLs. Connection walkthroughs for Claude specifically live on the Claude integration page, and the full tool catalog is in the MCP tools reference.
If you're building something on laravel/mcp and hit a wall this post didn't cover, email me. I've probably hit it too.
