Defining Tools
Defining Tools
A tool is a public controller method marked with #[Tool]. Everything an agent needs to call it — the parameter names, types, which are required, what they mean — is derived from the method signature.
The #[Tool] attribute
use Hazaar\MCP\Attribute\Tool;
#[Tool('Search the customer database by name or email')]
public function searchCustomers(string $query): array
{
return Customer::search($query);
}The description is the single most important thing you write. It is what the model reads when deciding whether this tool is the right one, so describe when to use it, not just what it does.
| Argument | Purpose |
|---|---|
description | What the tool does. Shown to the model during tool selection. |
name | Overrides the published tool name, which otherwise defaults to the method name. |
title | Human-readable name for display in a client UI. |
annotations | Behavioural hints for clients. Treated as untrusted, so never rely on them for access control. |
outputSchema | JSON Schema describing the structured result, when the shape is known and stable. |
#[Tool(
description: 'Permanently delete a customer record',
name: 'delete_customer',
title: 'Delete Customer',
annotations: ['destructiveHint' => true],
)]
public function deleteCustomer(int $id): string { /* ... */ }Tool names should be 1–128 characters of letters, digits, _, - and ., and unique within the server.
The #[Param] attribute
#[Param] refines the schema generated from a parameter's PHP type:
use Hazaar\MCP\Attribute\Param;
#[Tool('Search the customer database')]
public function searchCustomers(
#[Param('Name or email fragment to search for')]
string $query,
#[Param('Maximum number of results', maximum: 100)]
int $limit = 10,
#[Param('Restrict to a single region', enum: ['apac', 'emea', 'amer'])]
?string $region = null,
): array {
// ...
}| Argument | Effect |
|---|---|
description | Describes the parameter to the model. |
enum | Restricts the value to a fixed set. |
minimum / maximum | Inclusive numeric bounds. |
format | A JSON Schema format, e.g. date-time, email, uri. |
items | Schema for the elements of an array parameter. |
schema | Replaces the generated schema entirely, for shapes reflection cannot express. |
A parameter without a description still works, but the model is guessing at what to put in it. Adding one is the cheapest accuracy improvement available.
How PHP types become JSON Schema
| PHP type | Generated schema |
|---|---|
string | {"type": "string"} |
int | {"type": "integer"} |
float | {"type": "number"} |
bool | {"type": "boolean"} |
array / iterable | {"type": "array"} |
?string | {"type": ["string", "null"]} |
string|int | {"type": ["string", "integer"]} |
| backed enum | {"type": "string", "enum": ["low", "high"]} |
untyped or mixed | {} — accepts anything |
A parameter is required when it has no default value. Defaults are published so the model knows what happens if it omits the argument:
public function search(string $query, int $limit = 10): array{
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer", "default": 10 }
},
"required": ["query"]
}A tool with no parameters publishes {"type": "object", "additionalProperties": false}.
Enums
Backed enums are the most useful type you can put in a tool signature. They are the one place PHP expresses a closed set of values that JSON Schema can represent exactly, which stops the model inventing values:
enum Priority: string
{
case Low = 'low';
case High = 'high';
}
#[Tool('Set the priority of a ticket')]
public function setPriority(int $ticketId, Priority $priority): string
{
// $priority arrives as a Priority instance, already validated.
return "Set ticket {$ticketId} to {$priority->value}";
}priority publishes as {"type": "string", "enum": ["low", "high"]}, and the incoming string is resolved to the enum case before your method runs. A value outside the set is rejected with a message telling the model what was allowed.
Argument handling
Arguments arrive as a named JSON object and are bound to parameters by name, so declaration order does not matter to the caller.
JSON's type system is narrower than PHP's and models are inconsistent about quoting numbers, so numeric strings are accepted where a number is expected ("25" for an int parameter). Genuinely mismatched values are rejected rather than silently coerced — a wrong-typed argument reaching your method body produces a far more confusing failure than an explicit one.
Return values
Whatever a tool returns is mapped onto a tool result:
| You return | The agent receives |
|---|---|
string | A single text content block. |
int, float, bool | A text block containing the stringified value. |
array or JsonSerializable | structuredContent, plus the serialised JSON as a text block. |
A Content object | That content block, verbatim. |
An array of Content objects | Those content blocks, verbatim. |
null | An empty result. |
Structured results are echoed as text because clients are only obliged to read structuredContent when the tool declares an outputSchema. Returning an array gets you both representations for free.
To return something other than text — an image, for instance — return a content object:
use Hazaar\MCP\Content;
#[Tool('Render the current sales chart')]
public function salesChart(): Content
{
return Content\Image::fromRaw($this->renderPng(), 'image/png');
}Error handling
MCP distinguishes two kinds of failure, and the distinction is worth understanding because it determines whether the model can recover.
Tool execution errors are things the model can fix by trying again differently — a missing argument, a value out of range, a record that does not exist. Just throw:
#[Tool('Fetch a customer by ID')]
public function getCustomer(int $id): array
{
$customer = Customer::find($id);
if (null === $customer) {
throw new \RuntimeException("No customer with ID {$id}. Use searchCustomers to find one.");
}
return $customer->toArray();
}The exception message is returned with isError: true, and the model reads it and self-corrects. Write these messages for the model — say what went wrong and what to do instead.
Protocol errors are malformed requests the model cannot fix, such as calling a tool that does not exist. Hazaar raises these itself as JSON-RPC errors; you do not need to handle them.
Exception messages reach the model
A thrown exception's message is sent to the client verbatim. Do not let internal detail — SQL, file paths, credentials — reach it. Catch low-level exceptions and rethrow with a message you are happy for a third party to read.
Tools defined at runtime
When the set of tools is not known at author time — driven by configuration, the database, or a plugin registry — register them from init():
class Assistant extends \Hazaar\Controller\MCP
{
public function init(): void
{
foreach (Report::all() as $report) {
$this->registerTool(
'run_report_'.$report->slug,
fn (string $period): array => $report->run($period),
description: "Run the {$report->name} report",
);
}
}
}The schema is derived from the callable's signature just as it is for an attributed method. Pass an explicit inputSchema array as the third argument if you need to describe something reflection cannot see.
Securing a server
Tools run application code on behalf of a model, so treat the endpoint as public API surface:
- Authenticate it. Apply Hazaar's middleware to the route as you would any other endpoint. Because MCP is stateless, credentials arrive per-request, which suits token-based auth well.
- Authorize inside each tool. A model choosing to call a tool is not authorization. Check permissions in the method body.
- Scope what you expose. Only
#[Tool]methods are published — keep destructive operations off the list unless you genuinely want an agent invoking them. - Validate at the boundary. Generated schemas guide the model, they do not constrain a malicious client. Validate anything that reaches a database or filesystem.
State between calls
There is no protocol-level session, so a server cannot rely on per-connection state to relate one call to the next. When a workflow needs continuity, return an explicit handle and accept it back:
#[Tool('Start a new import batch')]
public function createBatch(): array
{
return ['batch_id' => Batch::create()->id];
}
#[Tool('Add a record to an import batch')]
public function addToBatch(
#[Param('The batch_id returned by createBatch')]
string $batchId,
array $record,
): string {
// ...
}The model carries batch_id forward. State the retention policy in the creating tool's description ("batches expire after 24 hours") so the model can see it, and validate the caller's authorization against the handle on every call — a handle is a name, not a capability.