MCP Server
The starter can run a Model Context Protocol (MCP) server, so AI clients such as Claude and VS Code can use the application’s tools. A client connects as the person using it: the person signs in and approves it on the same consent screen as any connected application, and every tool works within that person’s permissions.
It is built on Laravel MCP and the starter’s Passport API, and it is off by default.
Turning It On
Section titled “Turning It On”- Set
MCP_ENABLED=true. - Give the people who should connect AI clients the Use MCP permission (
UseMcp). Of the default roles, only Super Administrator has it. - Add the tools your application should offer. The server ships with none.
While MCP_ENABLED is false, the server, its discovery documents and client registration all answer 404, and no self-registered client can be approved.
MCP_ENABLEDfalseTurns the MCP server on
MCP_CUSTOM_SCHEMESURI schemes desktop clients may redirect to after sign-in, comma-separated. Most clients don’t need one; see below
MCP_REGISTRATIONS_PER_HOUR20Client registrations allowed per IP address per hour
MCP_TOOL_CALLS_PER_MINUTE60Tool calls allowed per person per minute
Connecting a Client
Section titled “Connecting a Client”The server is at https://your-app.example.edu/mcp. Add it to the client as a remote (Streamable HTTP) server with OAuth, and the client does the rest: it registers itself, opens the browser for the person to sign in and approve it, and stores the token.
-
Claude Code:
claude mcp add --transport http my-app https://your-app.example.edu/mcp, then/mcpin Claude Code to sign in. -
VS Code: add the server to
.vscode/mcp.json:{"servers": {"my-app": {"type": "http","url": "https://your-app.example.edu/mcp"}}} -
Claude (claude.ai and Claude Desktop): add a custom connector with the server URL. Claude connects from Anthropic’s servers, so the application must be reachable from the internet.
-
MCP Inspector:
npx @modelcontextprotocol/inspector, then connect to the URL with the Streamable HTTP transport.
Any client that supports MCP’s OAuth sign-in can connect the same way. A client that can only send a fixed token, such as some IDE assistants, can’t sign in itself; a local proxy such as mcp-remote can do the sign-in for it.
Most clients return from sign-in to https:// or http://localhost. A desktop app that returns through its own URI scheme (myapp://…) needs that scheme listed in MCP_CUSTOM_SCHEMES.
How a Client Connects
Section titled “How a Client Connects”- The client calls
POST /mcpwithout a token. The server answers401with aWWW-Authenticateheader pointing at its protected resource metadata,/.well-known/oauth-protected-resource/mcp, and naming the scope to request,mcp:use. The same document is also served at/.well-known/oauth-protected-resourcefor clients that look there. - That document names the application as the authorization server;
/.well-known/oauth-authorization-serverlists its endpoints. - The client registers itself at
POST /oauth/register(dynamic client registration). Its redirect URIs must use HTTPS, HTTP to the person’s own computer (localhost,127.0.0.1or[::1]), or one ofMCP_CUSTOM_SCHEMES. It gets a public client, with no secret, that may ask only for themcp:usescope. - The person signs in and sees the consent screen, which marks the client as an unverified AI client: a client names itself, so its name is only a claim. The screen shows where approving returns the person, which they can check, and the plain (punycode) form of an internationalized domain, which can imitate another site’s name. Only people with
UseMcpcan approve it. - The client exchanges the code, with PKCE, for an access token that lasts one hour and a refresh token that lasts 30 days, and calls the server with it.
Separate from the REST API
Section titled “Separate from the REST API”An MCP client’s token carries only mcp:use. The REST API refuses any token with mcp:use (403), and the MCP server accepts only tokens with it, approved by a person who still has UseMcp. Service client, personal access and other application tokens are refused there (403). One middleware enforces both sides, RequireTokenAudience, with TokenAudience::Api on the REST API’s routes and TokenAudience::Mcp on the server’s.
Adding Tools
Section titled “Adding Tools”The server is App\Mcp\Servers\AppServer, and its routes are in routes/ai.php. The rest of app/Mcp is the connection the starter provides: the discovery documents and client registration (Http/Controllers), the scope on the 401 challenge (Http/Middleware), and McpServiceProvider, which wires them up with the registration and tool-call rate limits. It ships with no tools, so a connected client can do nothing until you add some. A tool acts as the person who connected the client: $request->user() is that person, and your permissions and policies apply as they do in the browser.
- Generate a tool with
php artisan make:mcp-tool SearchUsers, which createsapp/Mcp/Tools/SearchUsers.php. - List it in
AppServer::$tools. - Hide it from people who may not use it, in its
shouldRegister()method. A hidden tool isn’t listed and can’t be called.
Design a tool for what an AI client should see, not for what’s easiest to return:
- Return only what’s needed. Choose the fields, never whole models.
- Page large results with a cursor the client passes back, and keep each query bounded.
- Declare an
outputSchema()and returnResponse::structured(), so clients get typed results. Write a nullable field asanyOfwith a null branch: some clients reject atypearray such as["string", "null"]. - Annotate read-only tools with
#[IsReadOnly], so clients can call them without asking every time. - Inject services into
handle(), not the constructor: Laravel MCP’s test helpers create tools without the container.
This tool finds people by name, NetID or email for anyone with View Users, 25 at a time:
#[Description('Find people by name, NetID or email. Returns up to 25 at a time; pass next_cursor back as cursor for the next page.')]#[IsReadOnly]class SearchUsers extends Tool{ public function shouldRegister(): bool { return (bool) auth()->user()?->can(SystemPermission::ViewUsers); }
public function handle(Request $request, UserSearch $search): ResponseFactory { $validated = $request->validate([ 'query' => ['required', 'string', 'min:2', 'max:100'], 'cursor' => ['nullable', 'string'], ]);
/** @var UserBuilder<User> $people */ $people = User::query()->where('auth_type', '!=', AuthType::API);
$page = $search->apply($people, $validated['query']) ->orderBy('id') ->cursorPaginate(25, ['id', 'first_name', 'last_name', 'email'], 'cursor', $validated['cursor'] ?? null);
return Response::structured([ 'users' => $page->getCollection()->map(fn (User $user): array => [ 'name' => $user->full_name, 'email' => $user->email, ])->values()->all(), 'next_cursor' => $page->nextCursor()?->encode(), ]); }
public function schema(JsonSchema $schema): array { return [ 'query' => $schema->string()->description('A name, NetID or email address, or part of one.')->required(), 'cursor' => $schema->string()->description('next_cursor from the previous page.'), ]; }
public function outputSchema(JsonSchema $schema): array { return [ 'users' => $schema->array()->items($schema->object([ 'name' => $schema->string()->required(), 'email' => $schema->anyOf([$schema->string()])->nullable()->required(), ]))->required(), 'next_cursor' => $schema->anyOf([$schema->string()])->nullable()->required(), ]; }}Test a tool through the server, as the person who would call it:
AppServer::actingAs($viewer)->tool(SearchUsers::class, ['query' => 'willie'])->assertHasNoErrors();AppServer::actingAs($someoneElse)->tools()->assertNotRegistered(SearchUsers::class);See the Laravel MCP documentation for resources, prompts and more testing helpers.
Managing Clients
Section titled “Managing Clients”API → MCP Clients in the administration panel lists the clients that registered themselves, with how many people have them connected and when they were last used. Revoking one ends everyone’s access to it. It’s shown to people with ManageApiAccess, even while MCP is off, so its clients can still be revoked.
People see their MCP clients on their Account area’s Connected Applications page, labelled as AI clients whose names aren’t verified, and can disconnect them there.
mcp:prune-clientsruns daily. It deletes a client nobody connected within 24 hours of registering, and revokes one nobody has used for 90 days.oauth:revoke-ineligibleruns hourly in production and at noon on weekdays elsewhere. It disconnects the MCP clients of people who lostUseMcp. Their tokens stop working at once, before it runs: the server answers401, the client signs in again, and the consent screen refuses it.
Reconnecting a Client
Section titled “Reconnecting a Client”A client keeps the client ID it registered, and can’t connect with it once that registration is gone: deleted by mcp:prune-clients because nobody connected within 24 hours, revoked after 90 days unused or by an administrator, or deleted by db:rebuild locally. When the client sends the person to sign in, they see an Application Not Registered page instead of the consent screen, telling them to remove the server from the client and add it again, so it registers anew.
Disconnecting doesn’t do this. The client stays registered, and connecting again shows the consent screen.
Logging and Limits
Section titled “Logging and Limits”MCP requests are logged with the API’s requests, with three more fields, because a failed tool call still answers 200:
A tool’s arguments and results are never logged.
The server, discovery and registration share the API’s per-IP limit. Registration is also limited per IP address (MCP_REGISTRATIONS_PER_HOUR), and tool calls per person (MCP_TOOL_CALLS_PER_MINUTE).
Browser-based clients may call the server, discovery, registration and /oauth/token from any origin, without credentials (cors.open_paths in config/cors.php).
Local Development
Section titled “Local Development”Turn MCP on in .env and give yourself UseMcp. Clients written in Node, such as Claude Code and MCP Inspector, don’t trust Herd’s local certificate authority, so start them with it:
NODE_EXTRA_CA_CERTS="$HOME/Library/Application Support/Herd/config/valet/CA/LaravelValetCASelfSigned.pem" claudeThat covers a client started from the terminal. A client running inside an editor opened from the Dock or Finder, such as an MCP extension in VS Code or PhpStorm, doesn’t see the terminal’s variables. Set it for apps you open next, then quit and reopen the editor:
launchctl setenv NODE_EXTRA_CA_CERTS "$HOME/Library/Application Support/Herd/config/valet/CA/LaravelValetCASelfSigned.pem"It lasts until you restart your Mac, and every Node process you open trusts Herd’s certificate authority until then. launchctl unsetenv NODE_EXTRA_CA_CERTS removes it sooner.
db:rebuild deletes every registered client, but a client remembers the client ID it registered. After a rebuild, remove the server from the client and add it again so it registers anew (see Reconnecting a Client).