Configuration

Tools

Per-tool configuration, MCP annotations, and the read-only / destructive / open-world hints.


Tools can carry extra metadata that MCP clients use to decide whether to call them automatically. The ToolAnnotations interface (defined in src/types/index.ts) is the standard way to expose it.

The four annotations

FieldTypeMeaning
readOnlyHintbooleantrue means the tool doesn’t change any state. Clients can call it freely.
destructiveHintbooleantrue means the tool mutates state in a way the user might want to confirm.
openWorldHintbooleantrue means the tool talks to something beyond the local process (network, file system, etc.).
titlestringA human-friendly title for the tool. Some clients render this instead of name.

All four are optional. A tool with no annotations is treated as: read-write, possibly destructive, talks to the outside world, use the name as the title.

How a tool declares them

A typical example from src/tools/homeassistant/list-devices.tool.ts:

export class ListDevicesTool extends BaseTool<...> {
  name = "list_devices";
  description = "List Home Assistant devices, optionally filtered by domain or area.";
  annotations: ToolAnnotations = {
    readOnlyHint: true,
    openWorldHint: true,
  };
  inputSchema = z.object({ ... });
  async execute(input, context) { ... }
}

A control tool (e.g. control_light) gets:

annotations: ToolAnnotations = {
  readOnlyHint: false,
  destructiveHint: true, // could change device state the user didn't intend
  openWorldHint: true,
};

A pure-computation tool that’s all in-process (e.g. a template tool that runs a Handlebars template against cached state) gets:

annotations: ToolAnnotations = {
  readOnlyHint: true,
  openWorldHint: false,
};

Why clients care

The MCP spec is deliberately quiet about how a client uses the annotations. In practice, the common interpretations are:

  • readOnlyHint: true: safe to call proactively when the model wants context (e.g. “list the lights so I can mention them in my answer”).
  • destructiveHint: true: surface a confirmation prompt or require an explicit user instruction. Some clients will simply refuse to call these tools without a “yes”.
  • openWorldHint: true: inform the model that the tool might fail in environment-specific ways (network down, token revoked, etc.). This is a hint to be more careful about error messages.

Set them honestly. If a tool reads from the network, mark it openWorldHint: true. If it deletes data, mark it destructiveHint: true. Over-marking trains users to dismiss the warnings.

Disabling a tool at runtime

The TOOL_<NAME>_DISABLED env var pattern lets you flip a tool off without redeploying. The check lives in src/mcp/MCPServer.registerTool:

if (process.env[`TOOL_${tool.name.toUpperCase()}_DISABLED`] === "true") {
  logger.info(`Tool ${tool.name} disabled via env var`);
  return;
}

For example, to disable the speech tools without removing them from the registry:

TOOL_TEXT_TO_SPEECH_DISABLED=true \
TOOL_VOICE_COMMAND_PARSER_DISABLED=true \
bun run start:stdio

The tool is omitted from the tools/list response, so clients can’t call it. Flip the env var back to remove the disable, no rebuild required.

Rate-limited tools

The RATE_LIMIT_MAX global applies to the /mcp endpoint, but a small number of expensive tools (e.g. analyze_audio, profile_devices) apply their own per-tool limit via a custom field on ToolAnnotations:

annotations: ToolAnnotations = {
  // ...
  custom: { rateLimit: { windowMs: 60_000, max: 5 } },
};

This is custom (not in the spec) and read by the per-tool rate-limit middleware. Five calls per minute is plenty for an interactive model and prevents a runaway loop from hammering HA.

Verifying the manifest

To see exactly what the running server advertises, hit POST /mcp with:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list"
}

The response includes each tool’s name, description, inputSchema, and annotations. If a tool is missing, it was disabled by env var (see above) or excluded from the registry (rebuild required).

Next