Tool System
How a tool is defined, validated, registered, and dispatched through the MCP middleware.
Every action the AI can take is a Tool — a class with a Zod input schema, a name, a description, and an execute() method. The tool system is the part of the codebase you’ll touch most if you want to extend the server.
The BaseTool contract
src/mcp/BaseTool.ts defines the base class. It’s generic over the input and output shapes, both typed by Zod:
export abstract class BaseTool<TInput extends z.ZodTypeAny, TOutput> {
abstract name: string;
abstract description: string;
abstract inputSchema: TInput;
abstract execute(
input: z.infer<TInput>,
context: ToolContext,
): Promise<TOutput>;
}
The Zod schema is the source of truth. It’s used at runtime to validate incoming arguments, and at registration time to generate the JSON Schema that the MCP protocol returns to clients in the tools/list response. The bridge is zod-to-json-schema (zodToJsonSchema in src/mcp/MCPServer.ts).
An example tool
A trimmed version of src/tools/homeassistant/list-devices.tool.ts:
export class ListDevicesTool extends BaseTool<
z.ZodType<{ domain?: string; area?: string }>,
DeviceSummary[]
> {
name = "list_devices";
description =
"List Home Assistant devices, optionally filtered by domain or area.";
inputSchema = z.object({
domain: z.string().optional(),
area: z.string().optional(),
});
async execute(input, context) {
const client = context.hassClient;
const states = await client.getStates();
return states
.filter(
(s) => !input.domain || s.entity_id.startsWith(input.domain + "."),
)
.filter((s) => !input.area || s.attributes.area_id === input.area)
.map(toDeviceSummary);
}
}
The tool receives a ToolContext (defined in src/types/index.ts) that gives it access to the HA client, the logger, the current request ID, and any per-request state the middlewares want to attach.
Registration
src/tools/index.ts re-exports every tool class. Each entry point iterates this list and calls server.registerTool(tool):
for (const ToolClass of TOOL_REGISTRY) {
const tool = new ToolClass();
server.registerTool(tool);
}
MCPServer.registerTool does four things:
- Instantiates the tool (parameterless constructor).
- Calls
zodToJsonSchema(tool.inputSchema)to produce the JSON manifest. - Stores the tool in a
Map<string, ToolDefinition>keyed by name. - Emits the
TOOL_REGISTEREDevent so listeners (telemetry, dev-mode logging) can react.
The execute path
When a tools/call request comes in, the middleware chain hands it to MCPServer.executeTool(). The flow:
- Look up the tool by name. If missing, return
MCPErrorCode.METHOD_NOT_FOUND. - Validate the
argumentsobject against the tool’sinputSchemawith.safeParse(). If it fails, returnMCPErrorCode.VALIDATION_ERRORwith the Zod issues. - Build a
ToolContextfrom the request: the HA client, a per-request logger (withrequestIdbound), and any other middleware-set fields. - Call
tool.execute(input, context)with a timeout. The default is 30s, set inMCPServer’s constructor (executionTimeout). - Return the result as a JSON-RPC
result. If the tool throws, the error-handling middleware maps it to anMCPErrorCode.TOOL_EXECUTION_ERRORwith the error message asdata.
Why Zod over hand-written schemas?
- One source of truth. The schema is the validation function and the type and the JSON manifest.
- Composable.
z.object({ a: z.string() }).merge(z.object({ b: z.number() }))produces a merged schema with a merged TS type and a merged JSON Schema. - Better errors. Zod’s
safeParsereturns a structuredissuesarray that’s friendlier than a hand-rolled validator’s string error.
The cost is one extra dep (zod) and a bridge to JSON Schema (zod-to-json-schema). Both are already in package.json#dependencies and external from the bundle.
Adding a new tool
See Guides > Adding a Tool for the full step-by-step. The short version:
- Create
src/tools/<domain>/<name>.tool.tsextendingBaseTool. - Add it to
src/tools/index.tsregistry. - Add a Zod-typed test under
__tests__/. - Rebuild with
bun run build:all.
The new tool shows up in tools/list for all three entry points automatically.
Next
- HA Client — the WebSocket layer the tools call into.
- Guides > Adding a Tool — the step-by-step walkthrough.