Adding a Tool
Step-by-step: write a new tool, register it, add a test, and rebuild.
The shortest path from “I want my AI to do X” to “X is in the tool manifest.” This page walks the whole flow with a worked example: a tool that returns the count of lights currently on.
1. Pick a file location
Domain-specific tools go in src/tools/homeassistant/. Cross-cutting helpers go in src/tools/. For this example, the tool is HA-specific:
src/tools/homeassistant/light-count.tool.ts
2. Write the class
The minimum viable tool is a class extending BaseTool<TInput, TOutput> with a Zod input schema, a name, a description, and an execute() method.
import { z } from "zod";
import { BaseTool } from "@/mcp/BaseTool";
import type { ToolContext, ToolAnnotations } from "@/types";
const INPUT_SCHEMA = z.object({});
export class CountLightsOnTool extends BaseTool<
typeof INPUT_SCHEMA,
{ count: number }
> {
name = "count_lights_on";
description = "Count the number of light entities that are currently on.";
annotations: ToolAnnotations = {
readOnlyHint: true,
openWorldHint: true,
};
inputSchema = INPUT_SCHEMA;
async execute(_input, context: ToolContext) {
const states = await context.hassClient.getStates();
const on = states.filter(
(s) => s.entity_id.startsWith("light.") && s.state === "on",
);
return { count: on.length };
}
}
Notes:
- The class is parameterless. All state comes from
context. BaseToolis generic over the Zod input type and the output type. The output is whatever you want to return — keep it JSON-serializable.- Annotations are optional but recommended. They help the AI client decide when it’s safe to call the tool.
context.hassClientis the long-lived WebSocket client; see Architecture > HA Client.
3. Register the tool
Open src/tools/index.ts and add the new class to the TOOL_REGISTRY array (the actual export name may differ — find the array of tool classes your file already exports).
import { CountLightsOnTool } from "./homeassistant/light-count.tool";
export const TOOL_REGISTRY = [
// ... existing tools
CountLightsOnTool,
];
That’s it. All three entry points (src/index.ts, src/stdio-server.ts, src/http-server.ts) iterate this list at startup, so the new tool shows up everywhere.
4. Add a test
Tests live under __tests__/, mirroring the source layout. The new file is __tests__/homeassistant/light-count.test.ts (or co-located as __tests__/tools/... — match the existing convention).
import { describe, expect, it, mock } from "bun:test";
import { CountLightsOnTool } from "@/tools/homeassistant/light-count.tool";
import type { ToolContext } from "@/types";
const makeContext = (states: any[]): ToolContext => ({
hassClient: {
getStates: async () => states,
} as any,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as any,
requestId: "test",
});
describe("CountLightsOnTool", () => {
it("counts only light entities in the on state", async () => {
const tool = new CountLightsOnTool();
const states = [
{ entity_id: "light.living_room", state: "on" },
{ entity_id: "light.bedroom", state: "off" },
{ entity_id: "light.kitchen", state: "on" },
{ entity_id: "switch.kettle", state: "on" }, // not a light
];
const result = await tool.execute({}, makeContext(states));
expect(result.count).toBe(2);
});
it("handles zero lights gracefully", async () => {
const tool = new CountLightsOnTool();
const result = await tool.execute({}, makeContext([]));
expect(result.count).toBe(0);
});
});
Run the tests:
bun test __tests__/homeassistant/light-count.test.ts
5. Build
bun run build:all
This rebuilds dist/index.cjs, dist/stdio-server.mjs, and dist/http-server.mjs. The new tool is now in the manifest of all three.
6. Verify
Boot the server and call tools/list to confirm the new tool is registered:
HASS_HOST=... HASS_TOKEN=... bun run start:stdio
In another terminal:
(echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'; sleep 1) \
| bun run start:stdio | head -1
Look for "name": "count_lights_on" in the response. If it’s there, you’re done.
Optional: make the tool disable-able
To allow disabling the tool via an env var without rebuilding, set the annotations.custom.disableable flag (or just rely on the convention TOOL_<NAME>_DISABLED=true). The registry checks the env var and skips the tool on tools/list. See Configuration > Tools.
Common pitfalls
- Don’t read
process.envin the tool. Use the context or the typedAPP_CONFIG. The Zod validation won’t catch a typo. - Don’t store state on the tool instance. The class is instantiated once per process; a long-running tool with mutable state will confuse the rate limiter. Put caches on the
context(which is per-request) or thehassClient(which is per-process and thread-safe). - Don’t use
console.log. Use thewinstonlogger from@/utils/logger. In STDIO mode,console.logcorrupts the JSON-RPC stream. - Return JSON-serializable data only. No functions, no
Dateobjects, noMap/Set. The MCP client serializes the result as JSON.
Next
- Testing — the project’s test conventions in detail.
- Architecture > Tool System — the
BaseToolcontract in depth.