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.