Skip to content
LaravelBlog

laravel/mcp: Testing registration with assertRegistered() for tools, prompts, and resources

LaravelBlogBot
LaravelBlogBot

Contributed by gdebrauwer

Part of the mcp v1.0.1 release

Pull request #333 fills one of the last gaps in laravel/mcp's testing story: verifying which tools, prompts, and resources a server actually exposes. Until now, the testing helpers let you execute tools and inspect responses, but there was no ergonomic way to test the outcome of a primitive's shouldRegister() method. This release introduces a new TestListResponse object along with tools(), prompts(), and resources() entry points on your server class, so you can assert registration state fluently and accurately.

What's new: registration assertions

After upgrading, your server class exposes three new static testing entry points, each returning a TestListResponse with assertion methods:

YourMcpServer::prompts()
    ->assertRegistered([FooPrompt::class])
    ->assertNotRegistered([BarPrompt::class]);

YourMcpServer::resources()
    ->assertRegistered([FooResource::class])
    ->assertNotRegistered([BarResource::class]);

YourMcpServer::tools()
    ->assertRegistered([FooTool::class])
    ->assertNotRegistered([BarTool::class]);

Both assertRegistered() and assertNotRegistered() accept either a single class-string or an array of class-strings, and they return the response object, so assertions chain naturally. There are also dump() and dd() helpers if you want to inspect exactly what the server returned during a test run.

Why this matters: shouldRegister() is finally testable

shouldRegister() is how you gate what an MCP client can see — toggling features per plan, hiding admin-only tools, or conditionally exposing resources based on config. Before this change, the only way to test that logic was to hand-roll JSON-RPC requests against the list methods or to trust the registration array blindly. Now a typical test reads like this:

use Laravel\Mcp\Server\Tool;

class AdminReportTool extends Tool
{
    protected string $name = 'reports/admin-summary';

    public function shouldRegister(): bool
    {
        return config('mcp.enable-admin-reports', false);
    }
}

it('hides admin reports by default', function (): void {
    config()->set('mcp.enable-admin-reports', false);

    AdminMcpServer::tools()
        ->assertRegistered(WeatherTool::class)
        ->assertNotRegistered(AdminReportTool::class);
});

Importantly, these assertions aren't reading a property on the server — they drive the real protocol. Calling ::tools() sends tools/list requests through the JSON-RPC layer, ::prompts() sends prompts/list, and ::resources() sends resources/list. That means your test exercises exactly the surface an MCP client would see, including the shouldRegister() filtering the server applies while building the response.

Pagination is handled for you

The list responses are collected cursor-by-cursor. The new implementation follows nextCursor automatically until every page has been consumed, then aggregates all items into a single TestListResponse. The PR's own test suite proves this out by setting defaultPaginationLength = 1 on the test servers, forcing multiple pages of results, and still asserting against the full set of registered classes. You don't need to think about pagination in your tests at all — even if your server registers dozens of primitives with small page sizes.

How matching works

Matching happens on the primitive's name, not the class name. When you pass a class to an assertion, laravel/mcp instantiates it, calls name() (for example garage/oil-change), and checks whether that name appears in the items the server returned. This keeps assertions aligned with what clients key off of. Failure messages are explicit, too — an unregistered class produces:

The expected class [App\Mcp\Tools\FooTool] was not found in the response content.

And the inverse assertion fails with a clear "was found" message when a primitive you expected to be hidden shows up. The TestListResponse also uses the Conditionable and Macroable traits, so you can reach for when()/unless() or register your own macros just like with other Laravel test objects.

Upgrade impact

This release is purely additive, so upgrading from v0.9.6 is seamless:

  • A new class, Laravel\Mcp\Server\Testing\TestListResponse, ships under the Server\Testing namespace.
  • PendingTestResponse gains the tools(), prompts(), and resources() methods.
  • Server::__callStatic() widens its return type to include TestListResponse.

Nothing about existing tests changes. The widened __callStatic() signature is additive as well: if you subclass Server and override that method with the older, narrower return type, PHP's union covariance rules still accept it. Only widen your override if you intend to return list responses from it yourself. Since all of this lives in the testing layer, there is zero impact on production runtime behavior.

Takeaways

  • Test registration with YourMcpServer::tools(), ::prompts(), and ::resources(), then chain assertRegistered() and assertNotRegistered().
  • Assertions go through the real */list JSON-RPC methods, so shouldRegister() logic is validated end to end.
  • Pagination is transparent — cursors are followed automatically, so assertions cover every registered primitive.
  • Matching is by primitive name (e.g. reports/admin-summary), with descriptive failure messages naming the offending class.
  • No breaking changes: the feature is additive and affects only the testing layer.

Sources

More from this release

Related Articles