Writing MCP Tools with Required Annotations

This document is for developers who add or maintain @rpc / #[RpcAttribute] methods that are exposed as MCP tools. To be listed in the Claude MCP Directory, every tool must declare a safety annotation. The MCPServer plugin supports two declaration forms: PHP 8 attributes (preferred) and PHPDoc tags.

Why annotations are required

Anthropic’s MCP Directory requires every tool to have exactly one of:

  • readOnlyHint: true – the tool only reads data; it does not modify state or send external requests.
  • destructiveHint: true – the tool may modify data, write files, send requests, or have other side effects.

Without these, your server cannot be approved for the catalog. The MCPServer plugin maps both forms (attribute and doc-tag) to these MCP fields automatically — and the attribute pipeline lands the same MethodInfo bucket the doc-tag pipeline uses, so downstream consumers don't branch on the source.

How to annotate your RPC methods

Pick exactly one safety hint per method. Use attributes for new code; existing doc-tag methods keep working as-is.

Declaration form PHP class / tag Maps to
#[McpReadOnlyAttribute] Plugins\MCPServer\Domain\Attribute\McpReadOnlyAttribute readOnlyHint: true
@mcpReadOnly (legacy) McpAnnotationResolver::ANNOTATION_MCP_READ_ONLY readOnlyHint: true
#[McpDestructiveAttribute] Plugins\MCPServer\Domain\Attribute\McpDestructiveAttribute destructiveHint: true
@mcpDestructive (legacy) McpAnnotationResolver::ANNOTATION_MCP_DESTRUCTIVE destructiveHint: true
#[McpTitleAttribute('...')] Plugins\MCPServer\Domain\Attribute\McpTitleAttribute title (optional, human label)
@mcpTitle Some short label McpAnnotationResolver::ANNOTATION_MCP_TITLE title
#[McpOpenWorldAttribute] Plugins\MCPServer\Domain\Attribute\McpOpenWorldAttribute openWorldHint: true
@mcpOpenWorld (legacy) McpAnnotationResolver::ANNOTATION_MCP_OPEN_WORLD openWorldHint: true

When to use McpReadOnly

  • Only reads from DB, files, or APIs.
  • No creates, updates, deletes, or writes.
  • No side effects (no emails, webhooks, or external calls).
  • Internal caching only is still considered read-only.

When to use McpDestructive

  • Creates, updates, or deletes data or resources.
  • Writes files (including temporary files).
  • Sends emails, notifications, or webhooks.
  • Calls external APIs that change state.
  • Any other side effect.

Examples

Read-only tool

Preferred — attributes:

use Plugins\MCPServer\Domain\Attribute\McpReadOnlyAttribute;
use Plugins\Rpc\Domain\Attribute\RpcAttribute;

#[RpcAttribute('getProjectByID')]
#[McpReadOnlyAttribute]
public function getProjectByID(int $idProject): array
{
    return $this->projectFacade->getByID($idProject);
}

Legacy — doc-tag:

/**
 * @rpc getProjectByID
 * @param int $idProject
 * @return array
 * @mcpReadOnly
 */
public function getProjectByID(int $idProject): array
{
    // Only reads from DB; no modifications.
    return $this->projectFacade->getByID($idProject);
}

Destructive tool

Preferred — attributes:

use Plugins\MCPServer\Domain\Attribute\McpDestructiveAttribute;
use Plugins\Rpc\Domain\Attribute\RpcAttribute;

#[RpcAttribute('createTask')]
#[McpDestructiveAttribute]
public function createTask(string $title, int $idProject): array
{
    return $this->_taskStore->create(['title' => $title, 'id_project' => $idProject]);
}

Legacy — doc-tag:

/**
 * @rpc createTask
 * @param string $title
 * @param int $idProject
 * @return array
 * @mcpDestructive
 */
public function createTask(string $title, int $idProject): array
{
    // Creates a new task; modifies data.
    return $this->_taskStore->create(['title' => $title, 'id_project' => $idProject]);
}

Another destructive tool (external request)

Preferred — attributes:

#[RpcAttribute('sendNotification')]
#[McpDestructiveAttribute]
public function sendNotification(string $userId, string $message): bool
{
    return $this->_notificationService->send($userId, $message);
}

Legacy — doc-tag:

/**
 * @rpc sendNotification
 * @param string $userId
 * @param string $message
 * @return bool
 * @mcpDestructive
 */
public function sendNotification(string $userId, string $message): bool
{
    // Sends an external request (e.g. email or webhook).
    return $this->_notificationService->send($userId, $message);
}

Default when no annotation is present

If you do not add a ReadOnly or Destructive declaration (in either form), the plugin defaults to destructiveHint: true (and readOnlyHint: false). That keeps existing tools valid but is conservative: catalog reviewers expect every tool to be explicitly categorized, so you should add the correct declaration for each method.

If a method declares both McpReadOnly and McpDestructive at once (e.g. accidentally), the resolver picks destructive — same safe-default rule as the doc-tag pipeline.

Optional: open-world vs bounded tools

The MCP protocol defines an optional openWorldHint:

  • openWorldHint: true – the tool may write to arbitrary URLs / files / resources (an open, unbounded set of targets — e.g. "fetch any URL", "write to any path").
  • openWorldHint: false – the tool only affects a bounded target inside your own product (e.g. "update a task by id").

This is a single marker: add #[McpOpenWorldAttribute] (or the legacy @mcpOpenWorld doc-tag) only to tools that reach arbitrary external resources. It is independent of the read-only / destructive safety hint — a destructive tool can still be bounded.

Default: if you do not add the marker, the tool reports openWorldHint: false (bounded). Most tools act only on our own product, so leaving the marker off is correct for them.

Preferred — attribute:

use Plugins\MCPServer\Domain\Attribute\McpDestructiveAttribute;
use Plugins\MCPServer\Domain\Attribute\McpOpenWorldAttribute;
use Plugins\Rpc\Domain\Attribute\RpcAttribute;

#[RpcAttribute('fetchUrl')]
#[McpDestructiveAttribute]
#[McpOpenWorldAttribute]
public function fetchUrl(string $url): array
{
    // Reaches an arbitrary, caller-supplied URL — an unbounded target.
    return $this->_httpClient->get($url);
}

Legacy — doc-tag:

/**
 * @rpc fetchUrl
 * @param string $url
 * @return array
 * @mcpDestructive
 * @mcpOpenWorld
 */
public function fetchUrl(string $url): array
{
    return $this->_httpClient->get($url);
}

Optional: human-readable title

The MCP protocol allows an optional title for tools (a short, human-readable name for UIs). Both declaration forms are supported.

Preferred — attribute:

use Plugins\MCPServer\Domain\Attribute\McpReadOnlyAttribute;
use Plugins\MCPServer\Domain\Attribute\McpTitleAttribute;
use Plugins\Rpc\Domain\Attribute\RpcAttribute;

#[RpcAttribute('getProjectById')]
#[McpReadOnlyAttribute]
#[McpTitleAttribute('Get project by ID')]
public function getProjectById(int $idProject): array
{
    ...
}

Legacy — doc-tag:

/**
 * @rpc getProjectById
 * @param int $idProject
 * @return array
 * @mcpReadOnly
 * @mcpTitle Get project by ID
 */
public function getProjectById(int $idProject): array
{
    ...
}

If no title is declared (in either form), the tool has no explicit title and clients fall back to name / description.

Summary

Declaration readOnlyHint destructiveHint Use for
#[McpReadOnlyAttribute] / @mcpReadOnly true false Read-only tools
#[McpDestructiveAttribute] / @mcpDestructive false true Tools that modify or have side effects
(none) false true Default; prefer adding an explicit hint

The open-world hint is independent of the safety pair:

Declaration openWorldHint Use for
#[McpOpenWorldAttribute] / @mcpOpenWorld true Tools that write to arbitrary URLs/files/resources
(none) false Default; tools bounded to our own product

After adding or changing declarations, run sync RPC methods so the server’s tools/list is up to date. For catalog submission, ensure every tool has the correct hint and see ./McpClaudeServerRegistrationChecklist.md.