Skip to content
LaravelBlog

laravel/mcp: Include the tool title in annotations.title

LaravelBlogBot
LaravelBlogBot

Contributed by bjornftw

Part of the mcp v1.0.2 release

When the tool title moved to the top-level title field in commit e43537a, Laravel MCP stopped sending it inside annotations as well. That matched one reading of the MCP spec but not the only one: the spec still defines ToolAnnotations.title, and some clients look there. PR #364 restores that field by copying each tool's title into its annotations automatically. If you're upgrading from v1.0.1, there is nothing to change in your code — your tools/list responses simply gain a title key inside every tool's annotations, which is what clients like Claude's connector directory expect.

Why the title had to come back

The PR author hit this while submitting a Laravel MCP server to Claude's connector directory. The submission guidelines require a human-readable title on every tool, but the developer portal checks annotations.title for it — not the top-level field. Every tool already had a top-level title, yet the portal still flagged each one with "Missing title annotation. Add annotations.title, a human-readable name for the directory listing."

Since the MCP spec keeps ToolAnnotations.title as a defined field, and at least one high-profile consumer reads it, serving both fields is the safest interpretation. The fix makes the framework do it for you rather than asking every tool author to duplicate the value by hand.

What changed under the hood

Tool::toArray() now seeds the annotations array with the tool's title before merging in whatever the tool itself declares:

$annotations = [
    'title' => $this->title(),
    ...$this->annotations(),
];

Because the title is always present, the old empty-state branch that emitted an empty object when a tool had no annotations is gone — annotations is now always an array with at least a title key, and the toArray() docblock narrows from array|object to just array.

Precedence matters here: the tool's own annotations are spread after the seeded title, so if a tool deliberately returns its own title from annotations(), that value wins inside annotations. The top-level title is unaffected either way. The PR includes tests covering all three cases: title-only, title alongside existing hints, and an explicit annotations-level override.

What it looks like on the wire

You still define the title once, via the #[Title] attribute or the $title property — no new attribute or configuration was added:

class GetWeather extends Tool
{
    protected string $title = 'Get Weather';

    public function annotations(): array
    {
        return ['readOnlyHint' => true];
    }

    // ...
}

That tool now serializes as:

{
    "name": "get-weather",
    "title": "Get Weather",
    "annotations": { "title": "Get Weather", "readOnlyHint": true }
}

A client reading the top-level field and a client reading annotations.title now see the same name, which is exactly what the connector directory wants for its listing.

search_tools results stay compact

One subtlety worth calling out: the built-in ToolSearch tool previously copied annotations straight from the serialized tool definition. Left alone, that would have pulled the newly injected title into search results too. Instead, the PR switches ToolSearch::search() to read $tool->annotations() directly from the tool instance, so search results only include the annotations you actually set. Deferred tool loading via search_tools produces exactly the same payloads as before — no accidental bloat.

Upgrading from v1.0.1

This is a transparent fix for most servers, but there are a few observable differences to be aware of:

  • Every tools/list entry now includes annotations.title, matching the top-level title. If you had a client or a test asserting annotations is empty (previously {} for tools without hints), update that expectation.
  • Existing hints are untouched. readOnlyHint, destructiveHint, and friends appear exactly as before, now alongside the injected title.
  • Custom annotation titles still win. If you override annotations() with your own title, it takes precedence within annotations, while the top-level title keeps its own value — useful if you want a directory-facing name that differs from the display title.
  • No migration work. Nothing about how you declare titles changes; the fix lives entirely in serialization.

Takeaways

  • PR #364 mirrors each tool's title into annotations.title, satisfying clients (like Claude's connector directory) that read the spec's ToolAnnotations.title field.
  • You define the title once with #[Title] or $title; the framework publishes it in both places.
  • An annotation-level title override still wins, and existing hints are unchanged.
  • search_tools output stays as compact as before because it reads raw tool annotations, not the serialized definition.
  • Upgrading from v1.0.1 requires no code changes — just be aware that annotations is never empty anymore, so adjust any tests that assert on {}.

Sources

More from this release

Related Articles