Skip to content

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.

  1. Set MCP_ENABLED=true.
  2. Give the people who should connect AI clients the Use MCP permission (UseMcp). Of the default roles, only Super Administrator has it.
  3. 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.

Settings
#MCP_ENABLEDfalse

Turns the MCP server on

#MCP_CUSTOM_SCHEMES

URI schemes desktop clients may redirect to after sign-in, comma-separated. Most clients don’t need one; see below

#MCP_REGISTRATIONS_PER_HOUR20

Client registrations allowed per IP address per hour

#MCP_TOOL_CALLS_PER_MINUTE60

Tool calls allowed per person per minute

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 /mcp in 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.

  1. The client calls POST /mcp without a token. The server answers 401 with a WWW-Authenticate header 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-resource for clients that look there.
  2. That document names the application as the authorization server; /.well-known/oauth-authorization-server lists its endpoints.
  3. 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.1 or [::1]), or one of MCP_CUSTOM_SCHEMES. It gets a public client, with no secret, that may ask only for the mcp:use scope.
  4. 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 UseMcp can approve it.
  5. 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.

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.

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.

  1. Generate a tool with php artisan make:mcp-tool SearchUsers, which creates app/Mcp/Tools/SearchUsers.php.
  2. List it in AppServer::$tools.
  3. 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 return Response::structured(), so clients get typed results. Write a nullable field as anyOf with a null branch: some clients reject a type array 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:

app/Mcp/Tools/SearchUsers.php
#[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.

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-clients runs daily. It deletes a client nobody connected within 24 hours of registering, and revokes one nobody has used for 90 days.
  • oauth:revoke-ineligible runs hourly in production and at noon on weekdays elsewhere. It disconnects the MCP clients of people who lost UseMcp. Their tokens stop working at once, before it runs: the server answers 401, the client signs in again, and the consent screen refuses it.

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.

MCP requests are logged with the API’s requests, with three more fields, because a failed tool call still answers 200:

#mcp_method

The JSON-RPC method, such as tools/call

#mcp_tool
The tool called
#mcp_outcome

ok, error for a JSON-RPC error, or tool_error for a tool that reported failure

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).

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:

Terminal window
NODE_EXTRA_CA_CERTS="$HOME/Library/Application Support/Herd/config/valet/CA/LaravelValetCASelfSigned.pem" claude

That 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:

Terminal window
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).