← Writing

Laravel MCP Discovery: a signpost for your MCP server

A small Laravel package that tells AI agents your site has an MCP server, instead of letting them scrape your HTML.

Contents 5 sections

An MCP server is useless if no agent knows it exists.

A few days ago I argued that sites should stop blocking bots and give them an MCP instead. The door already exists: laravel/mcp makes it easy to expose search or get_article as structured tools. What’s missing is the signpost. An agent landing on your site has no reliable way to learn “there’s an MCP server here, use that.”

So I built one: laravel-mcp-discovery.

What it does

It reads the servers you registered with Mcp::web() and publishes them through every discovery standard that’s currently emerging. Add one attribute:

#[Discoverable(name: 'com.example/blog', primary: true)]
class BlogServer extends Server { /* ... */ }

And you get:

  • A Server Card at /.well-known/mcp-server-card (SEP-2127, still in review).
  • An MCP section in llms.txt, telling LLMs to connect instead of scrape.

Everything else is opt-in: robots.txt pointers with Content Signals, a Link: <…>; rel="mcp" header, an A2A Agent Card, and a server.json for the MCP Registry.

Redirect, don’t reject

The part I care about most is the bot gate. It’s the idea from the original post, as middleware. An AI crawler asking for a page gets a short answer that points to the MCP server:

HTTP/1.1 403 Forbidden
Link: <https://example.com/mcp/blog>; rel="mcp"; title="Blog"
Link: <https://example.com/.well-known/mcp-server-card>; rel="service-desc"

Automated access to this page is not available.
This site offers its content over MCP (Model Context Protocol):

- Blog: https://example.com/mcp/blog

Search crawlers like Googlebot still get HTML. You can return 402 if you want to experiment with paid access, or pass to serve the page and only add the header.

User-agent sniffing is easy to fake, so the gate also verifies Web Bot Auth signatures. Signed agents get through. Unsigned bots pretending to be them get gated. The detector is a small interface, so you can plug in Cloudflare’s verified-bot signal instead.

Configuration

The defaults work without a config file. When you want more, publish it:

php artisan vendor:publish --tag="mcp-discovery-config"

Set APP_URL to your public, canonical URL first. Every discovery URL is built from it, never from the request’s Host header. That way a publicly cached server card can’t be poisoned.

Server metadata

Most metadata comes from what you already wrote for laravel/mcp. The package reads three sources, and later ones win:

  1. The laravel/mcp attributes on the server: #[Name], #[Version], #[Instructions], #[Description].
  2. The #[Discoverable] attribute.
  3. The server’s entry in config/mcp-discovery.php.

Anything you leave out gets a sensible default. BlogServer becomes the key blog. The registry name is the reverse-DNS of APP_URL plus the key: com.example/blog. The URL is read from the Mcp::web() route.

Auth is inferred from the route middleware. With auth:sanctum, the card says a bearer token is required. With OAuth routes, the card stays minimal and clients discover OAuth through the WWW-Authenticate header, as the MCP spec describes. Mark a server public: false and its card only shows up for logged-in users and verified agents.

Prefer config over attributes? Replace 'auto' with an explicit map. That also lets you advertise a server that runs somewhere else:

// config/mcp-discovery.php
'servers' => [
    'blog' => [
        'class' => App\Mcp\Servers\BlogServer::class,
        'name' => 'com.example/blog',
        'primary' => true,
    ],
    'search' => [
        'class' => App\Mcp\Servers\SearchServer::class,
        'url' => 'https://search.example.com/mcp',
        'auth' => 'oauth',
    ],
],

Emitters

Each standard is an emitter with an enabled flag and a class. Only the server card and llms.txt are on by default. Turning on the rest is a matter of flipping flags:

'emitters' => [
    'link_header' => ['enabled' => true, 'only' => 'bots'],
    'markdown_hint' => ['enabled' => true],
    'robots_txt' => [
        'enabled' => true,
        'content_signals' => ['search' => 'yes', 'ai-input' => 'yes', 'ai-train' => 'no'],
    ],
],

Two things worth knowing:

  • include_capabilities on the server card lists your tools, so agents see what a server does before connecting. It’s off by default. Not every site wants its tool surface public.
  • Static files win. Your web server serves public/llms.txt and public/robots.txt before Laravel runs. So llms.txt has two modes. route serves it dynamically and appends the MCP section to a base file you keep outside public/. file keeps your static file and maintains a marked section in it. For file mode, add this to your deploy script:
php artisan mcp-discovery:write

It only touches the lines between its own markers, and running it twice changes nothing. robots.txt defaults to file mode, since Laravel ships one. The package only adds comments and a Content-Signal line there. It never adds Allow or Disallow rules that could override yours.

Bot gate

The gate is off by default. This is a typical setup:

'bot_gate' => [
    'enabled' => true,
    'response' => 403,  // 403 | 402 | 'pass'
    'except' => ['/', 'robots.txt', 'llms.txt', '.well-known/*'],
    'allow' => ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'YandexBot'],
    'web_bot_auth' => [
        'enabled' => true,
        'unsigned' => 'block',  // or 'pass': serve the page, add the Link header
    ],
],

Paths in except are never gated, so agents can always reach the signposts themselves. Crawlers in allow always get HTML. If you have no public servers, the gate does nothing.

The middleware registers itself on the web group, but only when a feature that needs it is on. Set register_middleware to false to place it yourself.

When you’re done, check the result:

php artisan mcp-discovery:list
php artisan mcp-discovery:validate

Why a package

Because the standards are a moving target. Server Cards are in review, rel="mcp" isn’t registered, and WebMCP is still experimental in Chrome. I don’t want to chase every revision in every app I run.

Each standard lives in its own emitter class. When a spec changes, one file changes. When a new one shows up, you add one class. php artisan mcp-discovery:validate checks the output and exits non-zero on errors, so it can run in CI.

It’s a 0.x release on purpose. Config keys and output will follow the specs as they settle.

Try it

composer require pietervanleuven/laravel-mcp-discovery

It needs PHP 8.3, Laravel 12 or 13 and laravel/mcp 1.0. If you’ve built an MCP server, give it a signpost. Issues and PRs on GitHub are welcome.