Architecture

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:

  1. Instantiates the tool (parameterless constructor).
  2. Calls zodToJsonSchema(tool.inputSchema) to produce the JSON manifest.
  3. Stores the tool in a Map<string, ToolDefinition> keyed by name.
  4. Emits the TOOL_REGISTERED event 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:

  1. Look up the tool by name. If missing, return MCPErrorCode.METHOD_NOT_FOUND.
  2. Validate the arguments object against the tool’s inputSchema with .safeParse(). If it fails, return MCPErrorCode.VALIDATION_ERROR with the Zod issues.
  3. Build a ToolContext from the request: the HA client, a per-request logger (with requestId bound), and any other middleware-set fields.
  4. Call tool.execute(input, context) with a timeout. The default is 30s, set in MCPServer’s constructor (executionTimeout).
  5. Return the result as a JSON-RPC result. If the tool throws, the error-handling middleware maps it to an MCPErrorCode.TOOL_EXECUTION_ERROR with the error message as data.

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 safeParse returns a structured issues array 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:

  1. Create src/tools/<domain>/<name>.tool.ts extending BaseTool.
  2. Add it to src/tools/index.ts registry.
  3. Add a Zod-typed test under __tests__/.
  4. Rebuild with bun run build:all.

The new tool shows up in tools/list for all three entry points automatically.

Next