# MikroMCP v0.2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add firewall rule tools, static route tools, DHCP lease listing, and HTTP/SSE transport to MikroMCP.

**Architecture:** New tools follow the exact same pattern as existing tools in `src/domain/tools/` — a Zod input schema, a `ToolDefinition` object with handler, and export via `index.ts`. The HTTP transport adds a new file `src/mcp/transports/http.ts` using the MCP SDK's `StreamableHTTPServerTransport` in stateless mode, wired into `main.ts`.

**Tech Stack:** TypeScript, Zod, `@modelcontextprotocol/sdk` v1.12 (`StreamableHTTPServerTransport`), Node.js `node:http`, vitest

---

## Scope note

This plan covers two independent subsystems: (A) new RouterOS tools and (B) HTTP transport. They can be implemented in parallel or sequentially — they share no files except the final `main.ts` wiring for the transport.

---

## File Map

**Create:**
- `src/domain/tools/dhcp-tools.ts` — `list_dhcp_leases` tool
- `src/domain/tools/route-tools.ts` — `list_routes`, `manage_route` tools
- `src/domain/tools/firewall-tools.ts` — `list_firewall_rules`, `manage_firewall_rule` tools
- `src/mcp/transports/http.ts` — StreamableHTTP server transport
- `test/unit/domain/dhcp-tools.test.ts`
- `test/unit/domain/route-tools.test.ts`
- `test/unit/domain/firewall-tools.test.ts`

**Modify:**
- `src/domain/tools/index.ts` — add three new tool exports
- `src/mcp/server.ts` — bump version to `"0.2.0"`
- `src/main.ts` — wire HTTP transport branch
- `README.md` — document new tools + HTTP transport

---

## Task 1: DHCP Lease Listing Tool

**Files:**
- Create: `src/domain/tools/dhcp-tools.ts`
- Create: `test/unit/domain/dhcp-tools.test.ts`

RouterOS API path: `ip/dhcp-server/lease`

Key fields returned: `address`, `mac-address`, `host-name`, `server`, `status`, `expires-after`, `active-address`, `comment`, `disabled`

Status values: `bound`, `waiting`, `offered`, `blocked`

- [ ] **Step 1: Write the failing schema tests**

```typescript
// test/unit/domain/dhcp-tools.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";

// We test the input schema in isolation — no real router needed.
const listDhcpInputSchema = z.object({
  routerId: z.string(),
  server: z.string().optional(),
  status: z.enum(["bound", "waiting", "offered", "blocked", "all"]).default("all"),
  macAddress: z.string().optional(),
  limit: z.number().int().min(1).max(500).default(100),
  offset: z.number().int().min(0).default(0),
}).strict();

describe("listDhcpInputSchema", () => {
  it("accepts minimal valid input", () => {
    const result = listDhcpInputSchema.parse({ routerId: "core-01" });
    expect(result.routerId).toBe("core-01");
    expect(result.status).toBe("all");
    expect(result.limit).toBe(100);
    expect(result.offset).toBe(0);
  });

  it("accepts all optional fields", () => {
    const result = listDhcpInputSchema.parse({
      routerId: "core-01",
      server: "dhcp1",
      status: "bound",
      macAddress: "AA:BB:CC:DD:EE:FF",
      limit: 50,
      offset: 10,
    });
    expect(result.server).toBe("dhcp1");
    expect(result.status).toBe("bound");
    expect(result.limit).toBe(50);
  });

  it("rejects unknown status", () => {
    expect(() => listDhcpInputSchema.parse({ routerId: "r", status: "expired" })).toThrow();
  });

  it("rejects limit out of range", () => {
    expect(() => listDhcpInputSchema.parse({ routerId: "r", limit: 0 })).toThrow();
    expect(() => listDhcpInputSchema.parse({ routerId: "r", limit: 501 })).toThrow();
  });

  it("rejects extra fields", () => {
    expect(() => listDhcpInputSchema.parse({ routerId: "r", unknownField: true })).toThrow();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test -- test/unit/domain/dhcp-tools.test.ts
```

Expected: FAIL — `Cannot find module` or `listDhcpInputSchema is not defined` (the file doesn't exist yet)

- [ ] **Step 3: Create the DHCP tools file**

```typescript
// src/domain/tools/dhcp-tools.ts
import { z } from "zod";
import type { ToolDefinition, ToolContext, ToolResult } from "./tool-definition.js";
import type { RouterOSRecord } from "../../types.js";
import { enrichError } from "../errors/error-enricher.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("dhcp-tools");

const listDhcpInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  server: z.string().optional()
    .describe("Filter by DHCP server name (e.g. dhcp1)"),
  status: z.enum(["bound", "waiting", "offered", "blocked", "all"]).default("all")
    .describe("Filter by lease status"),
  macAddress: z.string().optional()
    .describe("Filter by MAC address (exact match, e.g. AA:BB:CC:DD:EE:FF)"),
  limit: z.number().int().min(1).max(500).default(100)
    .describe("Maximum number of leases to return"),
  offset: z.number().int().min(0).default(0)
    .describe("Offset for pagination"),
}).strict();

const listDhcpLeasesTool: ToolDefinition = {
  name: "list_dhcp_leases",
  title: "List DHCP Leases",
  description:
    "List DHCP leases on a MikroTik router. Supports filtering by server, status, and MAC address. Returns address assignments, host names, MAC addresses, and expiry information.",
  inputSchema: listDhcpInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listDhcpInputSchema.parse(params);

    log.info(
      { routerId: context.routerId, status: parsed.status, server: parsed.server },
      "Listing DHCP leases",
    );

    try {
      let leases = await context.routerClient.get<RouterOSRecord>("ip/dhcp-server/lease");

      // Client-side filtering
      if (parsed.server) {
        leases = leases.filter((l) => (l as Record<string, unknown>).server === parsed.server);
      }
      if (parsed.status !== "all") {
        leases = leases.filter((l) => (l as Record<string, unknown>).status === parsed.status);
      }
      if (parsed.macAddress) {
        const mac = parsed.macAddress.toUpperCase();
        leases = leases.filter(
          (l) => String((l as Record<string, unknown>)["mac-address"] ?? "").toUpperCase() === mac,
        );
      }

      const total = leases.length;
      const paginated = leases.slice(parsed.offset, parsed.offset + parsed.limit);
      const hasMore = parsed.offset + parsed.limit < total;

      const lines: string[] = [
        `DHCP leases on ${context.routerId}: ${total} total, showing ${paginated.length} (offset ${parsed.offset})`,
      ];
      for (const lease of paginated) {
        const l = lease as Record<string, string>;
        const addr = l.address ?? l["active-address"] ?? "?";
        const mac = l["mac-address"] ?? l["active-mac-address"] ?? "?";
        const host = l["host-name"] ? ` (${l["host-name"]})` : "";
        const status = l.status ?? "?";
        lines.push(`  ${addr}  ${mac}${host}  [${status}]`);
      }

      return {
        content: lines.join("\n"),
        structuredContent: {
          routerId: context.routerId,
          leases: paginated,
          total,
          hasMore,
          offset: parsed.offset,
          limit: parsed.limit,
        },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_dhcp_leases" });
    }
  },
};

export const dhcpTools: ToolDefinition[] = [listDhcpLeasesTool];
```

- [ ] **Step 4: Update the test to import from the real module**

Replace the inline schema in `test/unit/domain/dhcp-tools.test.ts` with an import:

```typescript
// test/unit/domain/dhcp-tools.test.ts
import { describe, it, expect } from "vitest";
import { dhcpTools } from "../../../src/domain/tools/dhcp-tools.js";
import { z } from "zod";

const tool = dhcpTools[0];
// Re-create the schema for isolated testing (handler test would need a mock client)
const listDhcpInputSchema = z.object({
  routerId: z.string(),
  server: z.string().optional(),
  status: z.enum(["bound", "waiting", "offered", "blocked", "all"]).default("all"),
  macAddress: z.string().optional(),
  limit: z.number().int().min(1).max(500).default(100),
  offset: z.number().int().min(0).default(0),
}).strict();

describe("list_dhcp_leases tool", () => {
  it("is registered with correct metadata", () => {
    expect(tool.name).toBe("list_dhcp_leases");
    expect(tool.annotations.readOnlyHint).toBe(true);
    expect(tool.annotations.destructiveHint).toBe(false);
  });

  describe("input schema", () => {
    it("accepts minimal valid input", () => {
      const result = listDhcpInputSchema.parse({ routerId: "core-01" });
      expect(result.status).toBe("all");
      expect(result.limit).toBe(100);
      expect(result.offset).toBe(0);
    });

    it("accepts all optional fields", () => {
      const result = listDhcpInputSchema.parse({
        routerId: "core-01",
        server: "dhcp1",
        status: "bound",
        macAddress: "AA:BB:CC:DD:EE:FF",
        limit: 50,
        offset: 10,
      });
      expect(result.server).toBe("dhcp1");
      expect(result.status).toBe("bound");
    });

    it("rejects unknown status", () => {
      expect(() => listDhcpInputSchema.parse({ routerId: "r", status: "expired" })).toThrow();
    });

    it("rejects limit out of range", () => {
      expect(() => listDhcpInputSchema.parse({ routerId: "r", limit: 0 })).toThrow();
      expect(() => listDhcpInputSchema.parse({ routerId: "r", limit: 501 })).toThrow();
    });

    it("rejects extra fields", () => {
      expect(() => listDhcpInputSchema.parse({ routerId: "r", unknownField: true })).toThrow();
    });
  });
});
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test -- test/unit/domain/dhcp-tools.test.ts
```

Expected: PASS — all 5 tests green

- [ ] **Step 6: Commit**

```bash
cd /Users/alikarami/Projects/MikroMCP && git add src/domain/tools/dhcp-tools.ts test/unit/domain/dhcp-tools.test.ts && git commit -m "feat: add list_dhcp_leases tool"
```

---

## Task 2: Static Route Tools

**Files:**
- Create: `src/domain/tools/route-tools.ts`
- Create: `test/unit/domain/route-tools.test.ts`

RouterOS API path: `ip/route`

Key fields: `dst-address` (CIDR), `gateway` (IP or interface), `distance` (1–255), `routing-mark`, `type` (`unicast`|`blackhole`|`prohibit`|`unreachable`), `comment`, `disabled`, `active`, `dynamic`

Idempotency key for `manage_route add`: (dst-address, gateway) pair — if both match, it's the same route.

- [ ] **Step 1: Write the failing tests**

```typescript
// test/unit/domain/route-tools.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";

const CIDR_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;
const IPV4_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;

const listRoutesInputSchema = z.object({
  routerId: z.string(),
  activeOnly: z.boolean().default(false),
  staticOnly: z.boolean().default(false),
  limit: z.number().int().min(1).max(500).default(100),
  offset: z.number().int().min(0).default(0),
}).strict();

const manageRouteInputSchema = z.object({
  routerId: z.string(),
  action: z.enum(["add", "remove"]),
  dstAddress: z.string().regex(CIDR_REGEX, "Must be CIDR notation, e.g. 0.0.0.0/0"),
  gateway: z.string(),
  distance: z.number().int().min(1).max(255).default(1),
  comment: z.string().max(255).optional(),
  disabled: z.boolean().default(false),
  dryRun: z.boolean().default(false),
}).strict();

describe("listRoutesInputSchema", () => {
  it("accepts minimal input", () => {
    const r = listRoutesInputSchema.parse({ routerId: "r" });
    expect(r.activeOnly).toBe(false);
    expect(r.staticOnly).toBe(false);
    expect(r.limit).toBe(100);
  });

  it("rejects extra fields", () => {
    expect(() => listRoutesInputSchema.parse({ routerId: "r", bogus: 1 })).toThrow();
  });
});

describe("manageRouteInputSchema", () => {
  it("accepts valid add", () => {
    const r = manageRouteInputSchema.parse({
      routerId: "r",
      action: "add",
      dstAddress: "10.0.0.0/8",
      gateway: "192.168.1.1",
    });
    expect(r.distance).toBe(1);
    expect(r.disabled).toBe(false);
    expect(r.dryRun).toBe(false);
  });

  it("rejects dst-address without prefix", () => {
    expect(() =>
      manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "10.0.0.0", gateway: "1.1.1.1" })
    ).toThrow();
  });

  it("rejects distance out of range", () => {
    expect(() =>
      manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1", distance: 0 })
    ).toThrow();
    expect(() =>
      manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1", distance: 256 })
    ).toThrow();
  });

  it("rejects invalid action", () => {
    expect(() =>
      manageRouteInputSchema.parse({ routerId: "r", action: "update", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1" })
    ).toThrow();
  });

  it("rejects extra fields", () => {
    expect(() =>
      manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1", bogus: 1 })
    ).toThrow();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test -- test/unit/domain/route-tools.test.ts
```

Expected: FAIL — `Cannot find module`

- [ ] **Step 3: Create the route tools file**

```typescript
// src/domain/tools/route-tools.ts
import { z } from "zod";
import type { ToolDefinition, ToolContext, ToolResult } from "./tool-definition.js";
import type { RouterOSRecord } from "../../types.js";
import { enrichError } from "../errors/error-enricher.js";
import { MikroMCPError, ErrorCategory } from "../errors/error-types.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("route-tools");

const CIDR_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;

function sanitizeComment(comment: string | undefined): string | undefined {
  if (comment === undefined) return undefined;
  return comment.replace(/[\x00-\x1f\x7f]/g, "");
}

// ---------------------------------------------------------------------------
// list_routes
// ---------------------------------------------------------------------------

const listRoutesInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  activeOnly: z.boolean().default(false)
    .describe("Return only active routes"),
  staticOnly: z.boolean().default(false)
    .describe("Return only static (non-dynamic) routes"),
  limit: z.number().int().min(1).max(500).default(100)
    .describe("Maximum number of routes to return"),
  offset: z.number().int().min(0).default(0)
    .describe("Offset for pagination"),
}).strict();

const listRoutesTool: ToolDefinition = {
  name: "list_routes",
  title: "List Routes",
  description:
    "List routes from the routing table on a MikroTik router. Supports filtering by active/static status and pagination.",
  inputSchema: listRoutesInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listRoutesInputSchema.parse(params);

    log.info(
      { routerId: context.routerId, activeOnly: parsed.activeOnly, staticOnly: parsed.staticOnly },
      "Listing routes",
    );

    try {
      let routes = await context.routerClient.get<RouterOSRecord>("ip/route");

      if (parsed.activeOnly) {
        routes = routes.filter((r) => {
          const rec = r as Record<string, unknown>;
          return rec.active === true || rec.active === "true";
        });
      }
      if (parsed.staticOnly) {
        routes = routes.filter((r) => {
          const rec = r as Record<string, unknown>;
          return rec.dynamic !== true && rec.dynamic !== "true";
        });
      }

      const total = routes.length;
      const paginated = routes.slice(parsed.offset, parsed.offset + parsed.limit);
      const hasMore = parsed.offset + parsed.limit < total;

      const lines: string[] = [
        `Routes on ${context.routerId}: ${total} total, showing ${paginated.length} (offset ${parsed.offset})`,
      ];
      for (const route of paginated) {
        const r = route as Record<string, string>;
        const dst = r["dst-address"] ?? "?";
        const gw = r.gateway ?? r["immediate-gw"] ?? "?";
        const dist = r.distance ? ` [${r.distance}]` : "";
        const active = r.active === "true" || r.active === true ? " ACTIVE" : "";
        const dynamic = r.dynamic === "true" || r.dynamic === true ? " DYNAMIC" : "";
        lines.push(`  ${dst} via ${gw}${dist}${active}${dynamic}`);
      }

      return {
        content: lines.join("\n"),
        structuredContent: {
          routerId: context.routerId,
          routes: paginated,
          total,
          hasMore,
          offset: parsed.offset,
          limit: parsed.limit,
        },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_routes" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_route
// ---------------------------------------------------------------------------

const manageRouteInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove"])
    .describe("Action to perform: add or remove a static route"),
  dstAddress: z.string()
    .regex(CIDR_REGEX, "Must be CIDR notation, e.g. 0.0.0.0/0 or 10.0.0.0/8")
    .describe("Destination network in CIDR notation (e.g. 10.0.0.0/8, 0.0.0.0/0)"),
  gateway: z.string()
    .describe("Next-hop gateway IP address or exit interface name (e.g. 192.168.1.1, ether1)"),
  distance: z.number().int().min(1).max(255).default(1)
    .describe("Administrative distance (1–255). Lower values preferred."),
  comment: z.string().max(255).optional()
    .describe("Optional comment"),
  disabled: z.boolean().default(false)
    .describe("Create the route in disabled state"),
  dryRun: z.boolean().default(false)
    .describe("Preview changes without applying"),
}).strict();

const manageRouteTool: ToolDefinition = {
  name: "manage_route",
  title: "Manage Static Route",
  description:
    "Add or remove a static route on a MikroTik router. Idempotent for add: if a route with the same dst-address and gateway already exists, returns success without changes. Supports dry-run mode.",
  inputSchema: manageRouteInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: true,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageRouteInputSchema.parse(params);
    const comment = sanitizeComment(parsed.comment);

    log.info(
      { routerId: context.routerId, action: parsed.action, dst: parsed.dstAddress, gw: parsed.gateway },
      "Managing route",
    );

    try {
      // Find existing route by dst-address
      const existing = await context.routerClient.get<RouterOSRecord>("ip/route", {
        filter: { "dst-address": parsed.dstAddress },
      });
      const match = existing.find(
        (r) => (r as Record<string, string>).gateway === parsed.gateway,
      ) as Record<string, string> | undefined;

      // -----------------------------------------------------------------------
      // ADD
      // -----------------------------------------------------------------------
      if (parsed.action === "add") {
        if (match) {
          return {
            content: `Route ${parsed.dstAddress} via ${parsed.gateway} already exists. No changes made.`,
            structuredContent: { action: "already_exists", route: match },
          };
        }

        if (parsed.dryRun) {
          const diff = [
            { property: "dst-address", before: null, after: parsed.dstAddress },
            { property: "gateway", before: null, after: parsed.gateway },
            { property: "distance", before: null, after: String(parsed.distance) },
            { property: "disabled", before: null, after: parsed.disabled ? "true" : "false" },
            ...(comment ? [{ property: "comment", before: null, after: comment }] : []),
          ];
          return {
            content: `Dry run: Would add route ${parsed.dstAddress} via ${parsed.gateway} (distance ${parsed.distance}).`,
            structuredContent: { action: "dry_run", diff },
          };
        }

        const body: Record<string, string> = {
          "dst-address": parsed.dstAddress,
          gateway: parsed.gateway,
          distance: String(parsed.distance),
          disabled: parsed.disabled ? "true" : "false",
        };
        if (comment) body.comment = comment;

        const created = await context.routerClient.create("ip/route", body);

        log.info({ dst: parsed.dstAddress, gw: parsed.gateway, id: created[".id"] }, "Route added");

        return {
          content: `Added route ${parsed.dstAddress} via ${parsed.gateway} (distance ${parsed.distance}).`,
          structuredContent: { action: "created", route: created },
        };
      }

      // -----------------------------------------------------------------------
      // REMOVE
      // -----------------------------------------------------------------------
      if (parsed.action === "remove") {
        if (!match) {
          throw new MikroMCPError({
            category: ErrorCategory.NOT_FOUND,
            code: "ROUTE_NOT_FOUND",
            message: `Route ${parsed.dstAddress} via ${parsed.gateway} not found.`,
            details: { dstAddress: parsed.dstAddress, gateway: parsed.gateway },
            recoverability: {
              retryable: false,
              suggestedAction: "Verify the dst-address and gateway are correct, or use list_routes to inspect the routing table.",
            },
          });
        }

        const id = match[".id"];

        if (parsed.dryRun) {
          const diff = [
            { property: "dst-address", before: parsed.dstAddress, after: null },
            { property: "gateway", before: parsed.gateway, after: null },
          ];
          return {
            content: `Dry run: Would remove route ${parsed.dstAddress} via ${parsed.gateway}.`,
            structuredContent: { action: "dry_run", diff },
          };
        }

        await context.routerClient.remove("ip/route", id);

        log.info({ dst: parsed.dstAddress, gw: parsed.gateway, id }, "Route removed");

        return {
          content: `Removed route ${parsed.dstAddress} via ${parsed.gateway}.`,
          structuredContent: { action: "removed", id, dstAddress: parsed.dstAddress, gateway: parsed.gateway },
        };
      }

      // Unreachable — zod enum guards this
      throw new MikroMCPError({
        category: ErrorCategory.VALIDATION,
        code: "INVALID_ACTION",
        message: `Unknown action: ${parsed.action as string}`,
        recoverability: { retryable: false, suggestedAction: "Use one of: add, remove." },
      });
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_route" });
    }
  },
};

export const routeTools: ToolDefinition[] = [listRoutesTool, manageRouteTool];
```

- [ ] **Step 4: Update tests to import from real module**

```typescript
// test/unit/domain/route-tools.test.ts
import { describe, it, expect } from "vitest";
import { routeTools } from "../../../src/domain/tools/route-tools.js";
import { z } from "zod";

const CIDR_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;

const listRoutesInputSchema = z.object({
  routerId: z.string(),
  activeOnly: z.boolean().default(false),
  staticOnly: z.boolean().default(false),
  limit: z.number().int().min(1).max(500).default(100),
  offset: z.number().int().min(0).default(0),
}).strict();

const manageRouteInputSchema = z.object({
  routerId: z.string(),
  action: z.enum(["add", "remove"]),
  dstAddress: z.string().regex(CIDR_REGEX),
  gateway: z.string(),
  distance: z.number().int().min(1).max(255).default(1),
  comment: z.string().max(255).optional(),
  disabled: z.boolean().default(false),
  dryRun: z.boolean().default(false),
}).strict();

describe("route tools", () => {
  it("exports list_routes and manage_route", () => {
    expect(routeTools).toHaveLength(2);
    expect(routeTools[0].name).toBe("list_routes");
    expect(routeTools[1].name).toBe("manage_route");
  });

  it("list_routes is read-only", () => {
    expect(routeTools[0].annotations.readOnlyHint).toBe(true);
    expect(routeTools[0].annotations.destructiveHint).toBe(false);
  });

  it("manage_route is idempotent+destructive", () => {
    expect(routeTools[1].annotations.idempotentHint).toBe(true);
    expect(routeTools[1].annotations.destructiveHint).toBe(true);
  });

  describe("listRoutesInputSchema", () => {
    it("accepts minimal input", () => {
      const r = listRoutesInputSchema.parse({ routerId: "r" });
      expect(r.activeOnly).toBe(false);
      expect(r.staticOnly).toBe(false);
      expect(r.limit).toBe(100);
    });

    it("rejects extra fields", () => {
      expect(() => listRoutesInputSchema.parse({ routerId: "r", bogus: 1 })).toThrow();
    });
  });

  describe("manageRouteInputSchema", () => {
    it("accepts valid add", () => {
      const r = manageRouteInputSchema.parse({
        routerId: "r",
        action: "add",
        dstAddress: "10.0.0.0/8",
        gateway: "192.168.1.1",
      });
      expect(r.distance).toBe(1);
      expect(r.disabled).toBe(false);
      expect(r.dryRun).toBe(false);
    });

    it("rejects dst-address without prefix", () => {
      expect(() =>
        manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "10.0.0.0", gateway: "1.1.1.1" })
      ).toThrow();
    });

    it("rejects distance out of range", () => {
      expect(() =>
        manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1", distance: 0 })
      ).toThrow();
      expect(() =>
        manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1", distance: 256 })
      ).toThrow();
    });

    it("rejects invalid action", () => {
      expect(() =>
        manageRouteInputSchema.parse({ routerId: "r", action: "update", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1" })
      ).toThrow();
    });

    it("rejects extra fields", () => {
      expect(() =>
        manageRouteInputSchema.parse({ routerId: "r", action: "add", dstAddress: "0.0.0.0/0", gateway: "1.1.1.1", bogus: 1 })
      ).toThrow();
    });
  });
});
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test -- test/unit/domain/route-tools.test.ts
```

Expected: PASS — all tests green

- [ ] **Step 6: Commit**

```bash
cd /Users/alikarami/Projects/MikroMCP && git add src/domain/tools/route-tools.ts test/unit/domain/route-tools.test.ts && git commit -m "feat: add list_routes and manage_route tools"
```

---

## Task 3: Firewall Rule Tools

**Files:**
- Create: `src/domain/tools/firewall-tools.ts`
- Create: `test/unit/domain/firewall-tools.test.ts`

RouterOS API paths: `ip/firewall/filter` (filter rules), `ip/firewall/nat` (NAT rules)

Key fields: `chain`, `action`, `src-address`, `dst-address`, `protocol`, `src-port`, `dst-port`, `in-interface`, `out-interface`, `comment`, `disabled`, `place-before` (for ordering)

Filter chains: `input`, `forward`, `output`
NAT chains: `srcnat`, `dstnat`
Filter actions: `accept`, `drop`, `reject`, `return`, `jump`, `passthrough`, `log`
NAT actions: `masquerade`, `src-nat`, `dst-nat`, `accept`, `drop`, `return`, `jump`, `passthrough`, `log`

Idempotency strategy for `manage_firewall_rule add`: match by `comment` field (when provided), since firewall rules are ordered and position-dependent. If no comment, check by all matching fields. This is the same approach RouterOS administrators use for scripted idempotency.

- [ ] **Step 1: Write the failing tests**

```typescript
// test/unit/domain/firewall-tools.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";

const FILTER_CHAINS = ["input", "forward", "output"] as const;
const NAT_CHAINS = ["srcnat", "dstnat"] as const;
const FILTER_ACTIONS = ["accept", "drop", "reject", "return", "jump", "passthrough", "log"] as const;
const NAT_ACTIONS = ["masquerade", "src-nat", "dst-nat", "accept", "drop", "return", "jump", "passthrough", "log"] as const;

const listFirewallRulesInputSchema = z.object({
  routerId: z.string(),
  table: z.enum(["filter", "nat"]).default("filter"),
  chain: z.string().optional(),
  disabled: z.enum(["true", "false", "all"]).default("all"),
  limit: z.number().int().min(1).max(500).default(100),
  offset: z.number().int().min(0).default(0),
}).strict();

const manageFirewallRuleInputSchema = z.object({
  routerId: z.string(),
  table: z.enum(["filter", "nat"]).default("filter"),
  action: z.enum(["add", "remove", "disable", "enable"]),
  chain: z.string(),
  ruleAction: z.string(),
  srcAddress: z.string().optional(),
  dstAddress: z.string().optional(),
  protocol: z.enum(["tcp", "udp", "icmp", "gre", "ospf", "all"]).optional(),
  srcPort: z.string().optional(),
  dstPort: z.string().optional(),
  inInterface: z.string().optional(),
  outInterface: z.string().optional(),
  comment: z.string().max(255).optional(),
  disabled: z.boolean().default(false),
  placeBefore: z.string().optional(),
  dryRun: z.boolean().default(false),
}).strict();

describe("listFirewallRulesInputSchema", () => {
  it("accepts minimal input", () => {
    const r = listFirewallRulesInputSchema.parse({ routerId: "r" });
    expect(r.table).toBe("filter");
    expect(r.disabled).toBe("all");
    expect(r.limit).toBe(100);
  });

  it("accepts filter table with chain", () => {
    const r = listFirewallRulesInputSchema.parse({ routerId: "r", table: "filter", chain: "forward" });
    expect(r.chain).toBe("forward");
  });

  it("accepts nat table", () => {
    const r = listFirewallRulesInputSchema.parse({ routerId: "r", table: "nat" });
    expect(r.table).toBe("nat");
  });

  it("rejects invalid table", () => {
    expect(() => listFirewallRulesInputSchema.parse({ routerId: "r", table: "mangle" })).toThrow();
  });

  it("rejects extra fields", () => {
    expect(() => listFirewallRulesInputSchema.parse({ routerId: "r", bogus: 1 })).toThrow();
  });
});

describe("manageFirewallRuleInputSchema", () => {
  it("accepts valid add for filter", () => {
    const r = manageFirewallRuleInputSchema.parse({
      routerId: "r",
      action: "add",
      chain: "forward",
      ruleAction: "drop",
      srcAddress: "192.168.1.0/24",
      comment: "block-lan",
    });
    expect(r.chain).toBe("forward");
    expect(r.ruleAction).toBe("drop");
    expect(r.disabled).toBe(false);
  });

  it("accepts valid add for nat", () => {
    const r = manageFirewallRuleInputSchema.parse({
      routerId: "r",
      table: "nat",
      action: "add",
      chain: "srcnat",
      ruleAction: "masquerade",
      outInterface: "ether1",
    });
    expect(r.table).toBe("nat");
    expect(r.ruleAction).toBe("masquerade");
  });

  it("accepts remove action", () => {
    const r = manageFirewallRuleInputSchema.parse({
      routerId: "r",
      action: "remove",
      chain: "forward",
      ruleAction: "drop",
      comment: "block-lan",
    });
    expect(r.action).toBe("remove");
  });

  it("rejects invalid action", () => {
    expect(() =>
      manageFirewallRuleInputSchema.parse({ routerId: "r", action: "update", chain: "forward", ruleAction: "drop" })
    ).toThrow();
  });

  it("rejects extra fields", () => {
    expect(() =>
      manageFirewallRuleInputSchema.parse({ routerId: "r", action: "add", chain: "forward", ruleAction: "drop", bogus: 1 })
    ).toThrow();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test -- test/unit/domain/firewall-tools.test.ts
```

Expected: FAIL — `Cannot find module`

- [ ] **Step 3: Create the firewall tools file**

```typescript
// src/domain/tools/firewall-tools.ts
import { z } from "zod";
import type { ToolDefinition, ToolContext, ToolResult } from "./tool-definition.js";
import type { RouterOSRecord } from "../../types.js";
import { enrichError } from "../errors/error-enricher.js";
import { MikroMCPError, ErrorCategory } from "../errors/error-types.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("firewall-tools");

function sanitizeComment(comment: string | undefined): string | undefined {
  if (comment === undefined) return undefined;
  return comment.replace(/[\x00-\x1f\x7f]/g, "");
}

function tableToPath(table: "filter" | "nat"): string {
  return table === "filter" ? "ip/firewall/filter" : "ip/firewall/nat";
}

// ---------------------------------------------------------------------------
// list_firewall_rules
// ---------------------------------------------------------------------------

const listFirewallRulesInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  table: z.enum(["filter", "nat"]).default("filter")
    .describe("Firewall table: filter (input/forward/output) or nat (srcnat/dstnat)"),
  chain: z.string().optional()
    .describe("Filter by chain name (e.g. forward, input, srcnat)"),
  disabled: z.enum(["true", "false", "all"]).default("all")
    .describe("Filter by disabled state"),
  limit: z.number().int().min(1).max(500).default(100)
    .describe("Maximum number of rules to return"),
  offset: z.number().int().min(0).default(0)
    .describe("Offset for pagination"),
}).strict();

const listFirewallRulesTool: ToolDefinition = {
  name: "list_firewall_rules",
  title: "List Firewall Rules",
  description:
    "List firewall filter or NAT rules on a MikroTik router. Rules are returned in their order of evaluation. Supports filtering by table, chain, and disabled state.",
  inputSchema: listFirewallRulesInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listFirewallRulesInputSchema.parse(params);

    log.info(
      { routerId: context.routerId, table: parsed.table, chain: parsed.chain },
      "Listing firewall rules",
    );

    try {
      let rules = await context.routerClient.get<RouterOSRecord>(tableToPath(parsed.table));

      if (parsed.chain) {
        rules = rules.filter((r) => (r as Record<string, unknown>).chain === parsed.chain);
      }
      if (parsed.disabled !== "all") {
        const wantDisabled = parsed.disabled === "true";
        rules = rules.filter((r) => {
          const rec = r as Record<string, unknown>;
          const isDisabled = rec.disabled === true || rec.disabled === "true";
          return isDisabled === wantDisabled;
        });
      }

      const total = rules.length;
      const paginated = rules.slice(parsed.offset, parsed.offset + parsed.limit);
      const hasMore = parsed.offset + parsed.limit < total;

      const lines: string[] = [
        `Firewall ${parsed.table} rules on ${context.routerId}: ${total} total, showing ${paginated.length} (offset ${parsed.offset})`,
      ];
      for (const rule of paginated) {
        const r = rule as Record<string, string>;
        const chain = r.chain ?? "?";
        const action = r.action ?? "?";
        const comment = r.comment ? ` // ${r.comment}` : "";
        const disabled = r.disabled === "true" || r.disabled === true ? " [disabled]" : "";
        const src = r["src-address"] ? ` src:${r["src-address"]}` : "";
        const dst = r["dst-address"] ? ` dst:${r["dst-address"]}` : "";
        const proto = r.protocol ? ` ${r.protocol}` : "";
        lines.push(`  [${chain}]${proto}${src}${dst} => ${action}${disabled}${comment}`);
      }

      return {
        content: lines.join("\n"),
        structuredContent: {
          routerId: context.routerId,
          table: parsed.table,
          rules: paginated,
          total,
          hasMore,
          offset: parsed.offset,
          limit: parsed.limit,
        },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_firewall_rules" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_firewall_rule
// ---------------------------------------------------------------------------

const manageFirewallRuleInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  table: z.enum(["filter", "nat"]).default("filter")
    .describe("Firewall table: filter or nat"),
  action: z.enum(["add", "remove", "disable", "enable"])
    .describe("Action: add, remove, disable, or enable a rule"),
  chain: z.string()
    .describe("Rule chain (e.g. forward, input, output, srcnat, dstnat)"),
  ruleAction: z.string()
    .describe("RouterOS rule action (e.g. accept, drop, reject, masquerade, src-nat)"),
  srcAddress: z.string().optional()
    .describe("Source address/range (CIDR or plain IP)"),
  dstAddress: z.string().optional()
    .describe("Destination address/range (CIDR or plain IP)"),
  protocol: z.enum(["tcp", "udp", "icmp", "gre", "ospf", "all"]).optional()
    .describe("Protocol to match"),
  srcPort: z.string().optional()
    .describe("Source port or range (e.g. 80 or 8000-9000). Only valid with tcp/udp."),
  dstPort: z.string().optional()
    .describe("Destination port or range (e.g. 443 or 8080-8090). Only valid with tcp/udp."),
  inInterface: z.string().optional()
    .describe("Incoming interface name"),
  outInterface: z.string().optional()
    .describe("Outgoing interface name"),
  comment: z.string().max(255).optional()
    .describe("Comment used as idempotency key for add/remove operations"),
  disabled: z.boolean().default(false)
    .describe("Create the rule in disabled state"),
  placeBefore: z.string().optional()
    .describe("Rule .id or comment to insert this rule before"),
  dryRun: z.boolean().default(false)
    .describe("Preview changes without applying"),
}).strict();

/** Find a rule by comment (exact match). */
async function findRuleByComment(
  context: ToolContext,
  path: string,
  comment: string,
): Promise<Record<string, string> | undefined> {
  const results = await context.routerClient.get<RouterOSRecord>(path, {
    filter: { comment },
  });
  return results.length > 0 ? (results[0] as Record<string, string>) : undefined;
}

const manageFirewallRuleTool: ToolDefinition = {
  name: "manage_firewall_rule",
  title: "Manage Firewall Rule",
  description:
    "Add, remove, disable, or enable a firewall filter or NAT rule on a MikroTik router. When a comment is provided, it is used as the idempotency key for add and remove operations. Supports dry-run mode.",
  inputSchema: manageFirewallRuleInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: true,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageFirewallRuleInputSchema.parse(params);
    const comment = sanitizeComment(parsed.comment);
    const path = tableToPath(parsed.table);

    log.info(
      { routerId: context.routerId, action: parsed.action, table: parsed.table, chain: parsed.chain },
      "Managing firewall rule",
    );

    try {
      // -----------------------------------------------------------------------
      // ADD
      // -----------------------------------------------------------------------
      if (parsed.action === "add") {
        // Idempotency: if comment is provided, check for existing rule with same comment
        if (comment) {
          const existing = await findRuleByComment(context, path, comment);
          if (existing) {
            return {
              content: `Firewall rule with comment "${comment}" already exists in ${parsed.table}/${parsed.chain}. No changes made.`,
              structuredContent: { action: "already_exists", rule: existing },
            };
          }
        }

        const body: Record<string, string> = {
          chain: parsed.chain,
          action: parsed.ruleAction,
          disabled: parsed.disabled ? "true" : "false",
        };
        if (comment) body.comment = comment;
        if (parsed.srcAddress) body["src-address"] = parsed.srcAddress;
        if (parsed.dstAddress) body["dst-address"] = parsed.dstAddress;
        if (parsed.protocol && parsed.protocol !== "all") body.protocol = parsed.protocol;
        if (parsed.srcPort) body["src-port"] = parsed.srcPort;
        if (parsed.dstPort) body["dst-port"] = parsed.dstPort;
        if (parsed.inInterface) body["in-interface"] = parsed.inInterface;
        if (parsed.outInterface) body["out-interface"] = parsed.outInterface;
        if (parsed.placeBefore) body["place-before"] = parsed.placeBefore;

        if (parsed.dryRun) {
          const diff = Object.entries(body).map(([k, v]) => ({ property: k, before: null, after: v }));
          return {
            content: `Dry run: Would add ${parsed.table} rule in chain "${parsed.chain}" with action "${parsed.ruleAction}".`,
            structuredContent: { action: "dry_run", diff },
          };
        }

        const created = await context.routerClient.create(path, body);

        log.info({ id: created[".id"], chain: parsed.chain }, "Firewall rule added");

        return {
          content: `Added ${parsed.table} rule in chain "${parsed.chain}" with action "${parsed.ruleAction}"${comment ? ` (${comment})` : ""}.`,
          structuredContent: { action: "created", rule: created },
        };
      }

      // -----------------------------------------------------------------------
      // REMOVE
      // -----------------------------------------------------------------------
      if (parsed.action === "remove") {
        if (!comment) {
          throw new MikroMCPError({
            category: ErrorCategory.VALIDATION,
            code: "REMOVE_REQUIRES_COMMENT",
            message: "Removing a firewall rule requires a comment to identify it unambiguously.",
            recoverability: {
              retryable: false,
              suggestedAction: "Provide the comment that was used when the rule was created.",
            },
          });
        }

        const existing = await findRuleByComment(context, path, comment);
        if (!existing) {
          throw new MikroMCPError({
            category: ErrorCategory.NOT_FOUND,
            code: "FIREWALL_RULE_NOT_FOUND",
            message: `No ${parsed.table} rule found with comment "${comment}".`,
            details: { table: parsed.table, comment },
            recoverability: {
              retryable: false,
              suggestedAction: "Use list_firewall_rules to find the rule and verify its comment.",
            },
          });
        }

        const id = existing[".id"];

        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove ${parsed.table} rule with comment "${comment}" (id ${id}).`,
            structuredContent: { action: "dry_run", diff: [{ property: "comment", before: comment, after: null }] },
          };
        }

        await context.routerClient.remove(path, id);

        log.info({ id, comment }, "Firewall rule removed");

        return {
          content: `Removed ${parsed.table} rule with comment "${comment}".`,
          structuredContent: { action: "removed", id, comment },
        };
      }

      // -----------------------------------------------------------------------
      // DISABLE / ENABLE
      // -----------------------------------------------------------------------
      if (parsed.action === "disable" || parsed.action === "enable") {
        if (!comment) {
          throw new MikroMCPError({
            category: ErrorCategory.VALIDATION,
            code: "TOGGLE_REQUIRES_COMMENT",
            message: `${parsed.action === "disable" ? "Disabling" : "Enabling"} a firewall rule requires a comment to identify it.`,
            recoverability: {
              retryable: false,
              suggestedAction: "Provide the comment that was used when the rule was created.",
            },
          });
        }

        const existing = await findRuleByComment(context, path, comment);
        if (!existing) {
          throw new MikroMCPError({
            category: ErrorCategory.NOT_FOUND,
            code: "FIREWALL_RULE_NOT_FOUND",
            message: `No ${parsed.table} rule found with comment "${comment}".`,
            details: { table: parsed.table, comment },
            recoverability: {
              retryable: false,
              suggestedAction: "Use list_firewall_rules to verify the rule comment.",
            },
          });
        }

        const id = existing[".id"];
        const wantDisabled = parsed.action === "disable";
        const isDisabled = existing.disabled === "true" || existing.disabled === true;

        if (isDisabled === wantDisabled) {
          return {
            content: `Rule with comment "${comment}" is already ${parsed.action === "disable" ? "disabled" : "enabled"}. No changes made.`,
            structuredContent: { action: "no_change", rule: existing },
          };
        }

        if (parsed.dryRun) {
          return {
            content: `Dry run: Would ${parsed.action} rule with comment "${comment}".`,
            structuredContent: {
              action: "dry_run",
              diff: [{ property: "disabled", before: String(isDisabled), after: String(wantDisabled) }],
            },
          };
        }

        await context.routerClient.update(path, id, { disabled: wantDisabled ? "true" : "false" });

        log.info({ id, comment, action: parsed.action }, "Firewall rule toggled");

        return {
          content: `${parsed.action === "disable" ? "Disabled" : "Enabled"} rule with comment "${comment}".`,
          structuredContent: { action: parsed.action, id, comment },
        };
      }

      // Unreachable
      throw new MikroMCPError({
        category: ErrorCategory.VALIDATION,
        code: "INVALID_ACTION",
        message: `Unknown action: ${parsed.action as string}`,
        recoverability: { retryable: false, suggestedAction: "Use one of: add, remove, disable, enable." },
      });
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_firewall_rule" });
    }
  },
};

export const firewallTools: ToolDefinition[] = [listFirewallRulesTool, manageFirewallRuleTool];
```

- [ ] **Step 4: Update tests to import from real module**

```typescript
// test/unit/domain/firewall-tools.test.ts
import { describe, it, expect } from "vitest";
import { firewallTools } from "../../../src/domain/tools/firewall-tools.js";
import { z } from "zod";

const CIDR_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;

const listFirewallRulesInputSchema = z.object({
  routerId: z.string(),
  table: z.enum(["filter", "nat"]).default("filter"),
  chain: z.string().optional(),
  disabled: z.enum(["true", "false", "all"]).default("all"),
  limit: z.number().int().min(1).max(500).default(100),
  offset: z.number().int().min(0).default(0),
}).strict();

const manageFirewallRuleInputSchema = z.object({
  routerId: z.string(),
  table: z.enum(["filter", "nat"]).default("filter"),
  action: z.enum(["add", "remove", "disable", "enable"]),
  chain: z.string(),
  ruleAction: z.string(),
  srcAddress: z.string().optional(),
  dstAddress: z.string().optional(),
  protocol: z.enum(["tcp", "udp", "icmp", "gre", "ospf", "all"]).optional(),
  srcPort: z.string().optional(),
  dstPort: z.string().optional(),
  inInterface: z.string().optional(),
  outInterface: z.string().optional(),
  comment: z.string().max(255).optional(),
  disabled: z.boolean().default(false),
  placeBefore: z.string().optional(),
  dryRun: z.boolean().default(false),
}).strict();

describe("firewall tools", () => {
  it("exports list_firewall_rules and manage_firewall_rule", () => {
    expect(firewallTools).toHaveLength(2);
    expect(firewallTools[0].name).toBe("list_firewall_rules");
    expect(firewallTools[1].name).toBe("manage_firewall_rule");
  });

  it("list_firewall_rules is read-only", () => {
    expect(firewallTools[0].annotations.readOnlyHint).toBe(true);
    expect(firewallTools[0].annotations.destructiveHint).toBe(false);
  });

  it("manage_firewall_rule is idempotent+destructive", () => {
    expect(firewallTools[1].annotations.idempotentHint).toBe(true);
    expect(firewallTools[1].annotations.destructiveHint).toBe(true);
  });

  describe("listFirewallRulesInputSchema", () => {
    it("accepts minimal input", () => {
      const r = listFirewallRulesInputSchema.parse({ routerId: "r" });
      expect(r.table).toBe("filter");
      expect(r.disabled).toBe("all");
      expect(r.limit).toBe(100);
    });

    it("accepts filter table with chain", () => {
      const r = listFirewallRulesInputSchema.parse({ routerId: "r", table: "filter", chain: "forward" });
      expect(r.chain).toBe("forward");
    });

    it("accepts nat table", () => {
      const r = listFirewallRulesInputSchema.parse({ routerId: "r", table: "nat" });
      expect(r.table).toBe("nat");
    });

    it("rejects invalid table", () => {
      expect(() => listFirewallRulesInputSchema.parse({ routerId: "r", table: "mangle" })).toThrow();
    });

    it("rejects extra fields", () => {
      expect(() => listFirewallRulesInputSchema.parse({ routerId: "r", bogus: 1 })).toThrow();
    });
  });

  describe("manageFirewallRuleInputSchema", () => {
    it("accepts valid add for filter", () => {
      const r = manageFirewallRuleInputSchema.parse({
        routerId: "r",
        action: "add",
        chain: "forward",
        ruleAction: "drop",
        srcAddress: "192.168.1.0/24",
        comment: "block-lan",
      });
      expect(r.chain).toBe("forward");
      expect(r.ruleAction).toBe("drop");
      expect(r.disabled).toBe(false);
    });

    it("accepts valid add for nat", () => {
      const r = manageFirewallRuleInputSchema.parse({
        routerId: "r",
        table: "nat",
        action: "add",
        chain: "srcnat",
        ruleAction: "masquerade",
        outInterface: "ether1",
      });
      expect(r.table).toBe("nat");
      expect(r.ruleAction).toBe("masquerade");
    });

    it("accepts remove action", () => {
      const r = manageFirewallRuleInputSchema.parse({
        routerId: "r",
        action: "remove",
        chain: "forward",
        ruleAction: "drop",
        comment: "block-lan",
      });
      expect(r.action).toBe("remove");
    });

    it("accepts disable and enable actions", () => {
      expect(() =>
        manageFirewallRuleInputSchema.parse({ routerId: "r", action: "disable", chain: "forward", ruleAction: "drop", comment: "x" })
      ).not.toThrow();
      expect(() =>
        manageFirewallRuleInputSchema.parse({ routerId: "r", action: "enable", chain: "forward", ruleAction: "drop", comment: "x" })
      ).not.toThrow();
    });

    it("rejects invalid action", () => {
      expect(() =>
        manageFirewallRuleInputSchema.parse({ routerId: "r", action: "update", chain: "forward", ruleAction: "drop" })
      ).toThrow();
    });

    it("rejects extra fields", () => {
      expect(() =>
        manageFirewallRuleInputSchema.parse({ routerId: "r", action: "add", chain: "forward", ruleAction: "drop", bogus: 1 })
      ).toThrow();
    });
  });
});
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test -- test/unit/domain/firewall-tools.test.ts
```

Expected: PASS — all tests green

- [ ] **Step 6: Commit**

```bash
cd /Users/alikarami/Projects/MikroMCP && git add src/domain/tools/firewall-tools.ts test/unit/domain/firewall-tools.test.ts && git commit -m "feat: add list_firewall_rules and manage_firewall_rule tools"
```

---

## Task 4: Wire New Tools + Version Bump

**Files:**
- Modify: `src/domain/tools/index.ts`
- Modify: `src/mcp/server.ts`

- [ ] **Step 1: Update index.ts**

Replace the content of `src/domain/tools/index.ts`:

```typescript
// src/domain/tools/index.ts
import type { ToolDefinition } from "./tool-definition.js";
import { systemTools } from "./system-tools.js";
import { interfaceTools } from "./interface-tools.js";
import { ipTools } from "./ip-tools.js";
import { dhcpTools } from "./dhcp-tools.js";
import { routeTools } from "./route-tools.js";
import { firewallTools } from "./firewall-tools.js";

export const allTools: ToolDefinition[] = [
  ...systemTools,
  ...interfaceTools,
  ...ipTools,
  ...dhcpTools,
  ...routeTools,
  ...firewallTools,
];

export type { ToolDefinition, ToolContext, ToolResult, ToolAnnotations } from "./tool-definition.js";
```

- [ ] **Step 2: Bump server version in server.ts**

In `src/mcp/server.ts`, change:

```typescript
  const server = new McpServer({
    name: "mikrotik-mcp-server",
    version: "0.1.0",
  });
```

to:

```typescript
  const server = new McpServer({
    name: "mikrotik-mcp-server",
    version: "0.2.0",
  });
```

- [ ] **Step 3: Typecheck**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm run typecheck
```

Expected: no errors

- [ ] **Step 4: Run full test suite**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test
```

Expected: all tests pass (circuit breaker, retry, query builder, response parser, error types, dhcp, route, firewall)

- [ ] **Step 5: Commit**

```bash
cd /Users/alikarami/Projects/MikroMCP && git add src/domain/tools/index.ts src/mcp/server.ts && git commit -m "feat: register dhcp, route, firewall tools; bump version to 0.2.0"
```

---

## Task 5: HTTP/SSE Transport

**Files:**
- Create: `src/mcp/transports/http.ts`
- Modify: `src/main.ts`

The MCP SDK's `StreamableHTTPServerTransport` (from `@modelcontextprotocol/sdk/server/streamableHttp.js`) handles both regular HTTP responses and SSE streaming in one transport. We use **stateless mode** (`sessionIdGenerator: undefined`) — appropriate for tool-call-only MCP servers where server-initiated notifications aren't needed.

The transport's `handleRequest(req, res, parsedBody)` method accepts raw Node.js `IncomingMessage`/`ServerResponse`. We need to read and parse the JSON body ourselves before passing it to the transport.

The server listens on `MIKROMCP_PORT` (default `3000`) and accepts POST `/mcp` for MCP requests and GET `/mcp` for SSE streams.

- [ ] **Step 1: Create the HTTP transport file**

```typescript
// src/mcp/transports/http.ts
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("transport-http");

async function readBody(req: IncomingMessage): Promise<unknown> {
  return new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk: Buffer) => { data += chunk.toString(); });
    req.on("end", () => {
      if (!data) { resolve(undefined); return; }
      try { resolve(JSON.parse(data)); }
      catch (e) { reject(e); }
    });
    req.on("error", reject);
  });
}

export async function connectHttp(server: McpServer, port: number): Promise<void> {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless mode
  });

  await server.connect(transport);

  const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
    const url = new URL(req.url ?? "/", `http://localhost:${port}`);

    if (url.pathname !== "/mcp") {
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Not found. Use POST /mcp or GET /mcp." }));
      return;
    }

    try {
      let parsedBody: unknown;
      if (req.method === "POST") {
        parsedBody = await readBody(req);
      }
      await transport.handleRequest(req, res, parsedBody);
    } catch (err) {
      log.error({ err }, "HTTP transport error");
      if (!res.headersSent) {
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "Internal server error" }));
      }
    }
  });

  await new Promise<void>((resolve, reject) => {
    httpServer.listen(port, () => {
      log.info({ port }, "HTTP transport listening");
      resolve();
    });
    httpServer.on("error", reject);
  });
}
```

- [ ] **Step 2: Update main.ts to wire HTTP transport**

Replace the content of `src/main.ts`:

```typescript
// src/main.ts
import { loadAppConfig } from "./config/app-config.js";
import { createMcpServer } from "./mcp/server.js";
import { connectStdio } from "./mcp/transports/stdio.js";
import { connectHttp } from "./mcp/transports/http.js";
import { createLogger } from "./observability/logger.js";

const log = createLogger("main");

async function main(): Promise<void> {
  const config = loadAppConfig();
  log.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");

  const { server, pool } = createMcpServer(config);

  if (config.transport === "stdio") {
    await connectStdio(server);
    log.info("MikroMCP server running via stdio");
  } else if (config.transport === "http") {
    await connectHttp(server, config.port);
    log.info({ port: config.port }, "MikroMCP server running via HTTP/SSE on port");
  }

  const shutdown = () => {
    log.info("Shutting down MikroMCP server");
    pool.closeAll();
    process.exit(0);
  };

  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);
}

main().catch((err) => {
  console.error("Fatal error starting MikroMCP:", err);
  process.exit(1);
});
```

- [ ] **Step 3: Typecheck**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm run typecheck
```

Expected: no errors. If `StreamableHTTPServerTransport` import fails, verify the path: `@modelcontextprotocol/sdk/server/streamableHttp.js` (lowercase 'H').

- [ ] **Step 4: Smoke-test the HTTP transport**

Build and start the server in HTTP mode in the background, call it, then kill it:

```bash
cd /Users/alikarami/Projects/MikroMCP && npm run build && \
  MIKROMCP_TRANSPORT=http MIKROMCP_PORT=3001 \
  MIKROMCP_CONFIG_PATH=config/routers.yaml \
  node dist/main.js &
sleep 1
curl -s -X POST http://localhost:3001/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' | head -c 300
kill %1 2>/dev/null; true
```

Expected: JSON response beginning with `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":...`

If `config/routers.yaml` doesn't exist yet, copy the example first:

```bash
cp config/routers.example.yaml config/routers.yaml 2>/dev/null || true
```

- [ ] **Step 5: Run full test suite one more time**

```bash
cd /Users/alikarami/Projects/MikroMCP && npm test
```

Expected: all tests pass

- [ ] **Step 6: Commit**

```bash
cd /Users/alikarami/Projects/MikroMCP && git add src/mcp/transports/http.ts src/main.ts && git commit -m "feat: add HTTP/SSE transport via StreamableHTTPServerTransport"
```

---

## Task 6: Update README

**Files:**
- Modify: `README.md`

- [ ] **Step 1: Add new tools to the Available tools section**

After the `manage_ip_address` section, add:

```markdown
### `list_dhcp_leases`

List DHCP lease assignments from a DHCP server.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Router ID |
| `server` | string | — | Filter by DHCP server name |
| `status` | enum | `all` | `bound`, `waiting`, `offered`, `blocked`, `all` |
| `macAddress` | string | — | Exact MAC address filter |
| `limit` | integer | `100` | Max results (1–500) |
| `offset` | integer | `0` | Pagination offset |

**Example prompt:** *"List all bound DHCP leases on core-01."*

---

### `list_routes`

List routes from the routing table.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Router ID |
| `activeOnly` | boolean | `false` | Return only active routes |
| `staticOnly` | boolean | `false` | Return only static (non-dynamic) routes |
| `limit` | integer | `100` | Max results (1–500) |
| `offset` | integer | `0` | Pagination offset |

**Example prompt:** *"Show all active static routes on core-01."*

---

### `manage_route`

Add or remove a static route. Idempotent for add operations.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Router ID |
| `action` | enum | required | `add`, `remove` |
| `dstAddress` | string | required | Destination in CIDR notation (e.g. `0.0.0.0/0`) |
| `gateway` | string | required | Next-hop IP or exit interface |
| `distance` | integer | `1` | Administrative distance (1–255) |
| `comment` | string | — | Optional comment |
| `disabled` | boolean | `false` | Create in disabled state |
| `dryRun` | boolean | `false` | Preview changes without applying |

**Example prompt:** *"Add a default route via 10.0.0.1 on core-01, dry run first."*

---

### `list_firewall_rules`

List firewall filter or NAT rules in evaluation order.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Router ID |
| `table` | enum | `filter` | `filter`, `nat` |
| `chain` | string | — | Filter by chain name |
| `disabled` | enum | `all` | `true`, `false`, `all` |
| `limit` | integer | `100` | Max results (1–500) |
| `offset` | integer | `0` | Pagination offset |

**Example prompt:** *"Show all forward chain rules on core-01."*

---

### `manage_firewall_rule`

Add, remove, disable, or enable a firewall rule. Uses `comment` as the idempotency key for add/remove/toggle operations.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Router ID |
| `table` | enum | `filter` | `filter`, `nat` |
| `action` | enum | required | `add`, `remove`, `disable`, `enable` |
| `chain` | string | required | Chain name (e.g. `forward`, `srcnat`) |
| `ruleAction` | string | required | RouterOS action (e.g. `drop`, `accept`, `masquerade`) |
| `srcAddress` | string | — | Source address/range |
| `dstAddress` | string | — | Destination address/range |
| `protocol` | enum | — | `tcp`, `udp`, `icmp`, `gre`, `ospf`, `all` |
| `srcPort` | string | — | Source port or range |
| `dstPort` | string | — | Destination port or range |
| `inInterface` | string | — | Incoming interface |
| `outInterface` | string | — | Outgoing interface |
| `comment` | string | — | Idempotency key — required for remove/disable/enable |
| `disabled` | boolean | `false` | Create in disabled state |
| `placeBefore` | string | — | Rule `.id` or comment to insert before |
| `dryRun` | boolean | `false` | Preview changes without applying |

**Example prompt:** *"Add a firewall rule to drop all traffic from 10.0.0.0/8 in the forward chain on core-01, comment it 'block-rfc1918', dry run first."*
```

- [ ] **Step 2: Add HTTP transport to Configuration section**

In the Server environment variables table, update the `MIKROMCP_TRANSPORT` row:

```markdown
| `MIKROMCP_TRANSPORT` | `stdio` | Transport: `stdio` or `http` (HTTP/SSE) |
```

Add a new subsection after the table:

```markdown
#### HTTP/SSE transport

Set `MIKROMCP_TRANSPORT=http` to expose MikroMCP as an HTTP server instead of stdio. The server listens on `MIKROMCP_PORT` (default `3000`) and accepts MCP JSON-RPC at `POST /mcp`. Clients that support Server-Sent Events can also use `GET /mcp` for streaming.

```bash
MIKROMCP_TRANSPORT=http MIKROMCP_PORT=3000 \
MIKROMCP_CONFIG_PATH=config/routers.yaml \
ROUTER_CORE01_USER=mcp-api ROUTER_CORE01_PASS=secret \
  npm start
```

To connect Claude Desktop to an HTTP transport, point it at the running server using an HTTP MCP client rather than the stdio command form. Check your MCP client documentation for its HTTP transport configuration.
```

- [ ] **Step 3: Update Roadmap section**

Change:

```markdown
**v0.1 (current)**
```

to:

```markdown
**v0.1**
```

Change:

```markdown
**v0.2 (planned)**
- Firewall rule tools
- Static route tools
- DHCP lease listing
- HTTP/SSE transport
```

to:

```markdown
**v0.2 (current)**
- Firewall rule tools (`list_firewall_rules`, `manage_firewall_rule`)
- Static route tools (`list_routes`, `manage_route`)
- DHCP lease listing (`list_dhcp_leases`)
- HTTP/SSE transport (`MIKROMCP_TRANSPORT=http`)
```

- [ ] **Step 4: Commit**

```bash
cd /Users/alikarami/Projects/MikroMCP && git add README.md && git commit -m "docs: document v0.2 tools and HTTP transport"
```

---

## Self-Review

**Spec coverage:**
- [x] Firewall rule tools → Tasks 3, 4 (`list_firewall_rules`, `manage_firewall_rule`)
- [x] Static route tools → Tasks 2, 4 (`list_routes`, `manage_route`)
- [x] DHCP lease listing → Tasks 1, 4 (`list_dhcp_leases`)
- [x] HTTP/SSE transport → Task 5

**Placeholder scan:** All steps contain actual code. No TBDs.

**Type consistency:**
- `dhcpTools`, `routeTools`, `firewallTools` all typed as `ToolDefinition[]` — consistent with existing pattern
- `ToolContext`, `ToolResult` used identically across all new files — match existing `tool-definition.ts` exports
- `enrichError`, `MikroMCPError`, `ErrorCategory` imports use `.js` extension — consistent with ESM project
- `connectHttp` exported from `http.ts`, imported in `main.ts` under same name — no mismatch

**Gaps:** None found.
