# MikroMCP v0.5a 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 10 new tools across four files — mangle rules, address lists, policy routing, and routing protocols — following the v0.5a design spec.

**Architecture:** Four new tool files in `src/domain/tools/`, each with a matching test file in `test/unit/domain/`. Only `src/domain/tools/index.ts` is modified in existing files. All tools follow the established TDD pattern: write failing tests, implement, verify passing.

**Tech Stack:** TypeScript, Zod, Vitest, RouterOS REST API

---

## Task 1: Mangle tools — tests

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

- [ ] **Step 1: Write the test file**

```typescript
import { describe, it, expect, vi } from "vitest";
import { mangleTools } from "../../../src/domain/tools/mangle-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import { z } from "zod";

const listMangleRulesTool = mangleTools[0];
const manageMangleRuleTool = mangleTools[1];

const listSchema = z.object({
  routerId: z.string(),
  chain: z.string().optional(),
  action: z.string().optional(),
  disabled: z.boolean().optional(),
}).strict();

const manageSchema = z.object({
  routerId: z.string(),
  action: z.enum(["add", "remove", "enable", "disable"]),
  comment: z.string(),
  chain: z.string().optional(),
  dryRun: z.boolean().default(false),
  srcAddress: z.string().optional(),
  dstAddress: z.string().optional(),
  srcAddressList: z.string().optional(),
  dstAddressList: z.string().optional(),
  protocol: z.string().optional(),
  srcPort: z.string().optional(),
  dstPort: z.string().optional(),
  inInterface: z.string().optional(),
  outInterface: z.string().optional(),
  newRoutingMark: z.string().optional(),
  newConnectionMark: z.string().optional(),
  newDscpValue: z.number().int().min(0).max(63).optional(),
  passthrough: z.boolean().optional(),
}).strict();

function makeContext(
  records: Record<string, unknown>[],
  createReturn?: Record<string, unknown>,
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue(createReturn ?? { ".id": "*1", chain: "prerouting" }),
      remove: vi.fn().mockResolvedValue(undefined),
      update: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("mangle tools", () => {
  describe("metadata", () => {
    it("exports 2 tools: list_mangle_rules and manage_mangle_rule", () => {
      expect(mangleTools).toHaveLength(2);
      expect(listMangleRulesTool.name).toBe("list_mangle_rules");
      expect(manageMangleRuleTool.name).toBe("manage_mangle_rule");
    });

    it("list_mangle_rules has correct annotations", () => {
      expect(listMangleRulesTool.annotations.readOnlyHint).toBe(true);
      expect(listMangleRulesTool.annotations.destructiveHint).toBe(false);
      expect(listMangleRulesTool.annotations.idempotentHint).toBe(true);
    });

    it("manage_mangle_rule has correct annotations", () => {
      expect(manageMangleRuleTool.annotations.readOnlyHint).toBe(false);
      expect(manageMangleRuleTool.annotations.destructiveHint).toBe(false);
      expect(manageMangleRuleTool.annotations.idempotentHint).toBe(true);
    });
  });

  describe("list_mangle_rules input schema", () => {
    it("accepts minimal input", () => {
      const r = listSchema.parse({ routerId: "r" });
      expect(r.chain).toBeUndefined();
    });

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

  describe("manage_mangle_rule input schema", () => {
    it("accepts valid add with chain and comment", () => {
      const r = manageSchema.parse({ routerId: "r", action: "add", comment: "mark-web", chain: "prerouting" });
      expect(r.dryRun).toBe(false);
    });

    it("rejects newDscpValue above 63", () => {
      expect(() =>
        manageSchema.parse({ routerId: "r", action: "add", comment: "c", chain: "prerouting", newDscpValue: 64 }),
      ).toThrow();
    });

    it("rejects extra fields", () => {
      expect(() =>
        manageSchema.parse({ routerId: "r", action: "add", comment: "c", chain: "prerouting", unknownField: true }),
      ).toThrow();
    });
  });

  describe("list_mangle_rules handler", () => {
    const sampleRules = [
      { ".id": "*1", chain: "prerouting", action: "mark-routing", "new-routing-mark": "isp1", disabled: "false", comment: "mark-isp1" },
      { ".id": "*2", chain: "forward", action: "mark-connection", disabled: "true", comment: "mark-conn" },
    ];

    it("returns all rules with correct total", async () => {
      const ctx = makeContext(sampleRules);
      const result = await listMangleRulesTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(2);
    });

    it("filters by chain", async () => {
      const ctx = makeContext(sampleRules);
      const result = await listMangleRulesTool.handler({ routerId: "test-router", chain: "prerouting" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(1);
    });

    it("filters by disabled true", async () => {
      const ctx = makeContext(sampleRules);
      const result = await listMangleRulesTool.handler({ routerId: "test-router", disabled: true }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(1);
    });
  });

  describe("manage_mangle_rule handler - add", () => {
    it("creates rule and returns created", async () => {
      const ctx = makeContext([]);
      const result = await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "add", comment: "mark-web", chain: "prerouting", newRoutingMark: "isp1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).toHaveBeenCalled();
    });

    it("returns already_exists when comment matches with same config", async () => {
      const existing = { ".id": "*1", chain: "prerouting", "new-routing-mark": "isp1", comment: "mark-web", disabled: "false" };
      const ctx = makeContext([existing]);
      const result = await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "add", comment: "mark-web", chain: "prerouting", newRoutingMark: "isp1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
    });

    it("throws CONFLICT when comment matches with different config", async () => {
      const existing = { ".id": "*1", chain: "prerouting", "new-routing-mark": "isp1", comment: "mark-web", disabled: "false" };
      const ctx = makeContext([existing]);
      await expect(
        manageMangleRuleTool.handler(
          { routerId: "test-router", action: "add", comment: "mark-web", chain: "forward", newRoutingMark: "isp2" },
          ctx,
        ),
      ).rejects.toThrow();
    });

    it("returns dry_run and does not call create", async () => {
      const ctx = makeContext([]);
      const result = await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "add", comment: "test", chain: "prerouting", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).not.toHaveBeenCalled();
    });
  });

  describe("manage_mangle_rule handler - remove", () => {
    it("removes rule and calls remove with correct path and id", async () => {
      const existing = { ".id": "*1", comment: "mark-web", chain: "prerouting", disabled: "false" };
      const ctx = makeContext([existing]);
      const result = await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "remove", comment: "mark-web" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      const mockRemove = (ctx.routerClient as Record<string, unknown>).remove as ReturnType<typeof vi.fn>;
      expect(mockRemove).toHaveBeenCalledWith("ip/firewall/mangle", "*1");
    });

    it("returns already_removed when comment not found", async () => {
      const ctx = makeContext([]);
      const result = await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "remove", comment: "nonexistent" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_removed");
    });
  });

  describe("manage_mangle_rule handler - enable/disable", () => {
    it("throws NOT_FOUND when comment not found", async () => {
      const ctx = makeContext([]);
      await expect(
        manageMangleRuleTool.handler({ routerId: "test-router", action: "enable", comment: "nonexistent" }, ctx),
      ).rejects.toThrow();
    });

    it("calls update with disabled true when disabling", async () => {
      const existing = { ".id": "*1", comment: "mark-web", chain: "prerouting", disabled: "false" };
      const ctx = makeContext([existing]);
      const result = await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "disable", comment: "mark-web" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("updated");
      const mockUpdate = (ctx.routerClient as Record<string, unknown>).update as ReturnType<typeof vi.fn>;
      expect(mockUpdate).toHaveBeenCalledWith("ip/firewall/mangle", "*1", { disabled: "true" });
    });

    it("calls update with disabled false when enabling", async () => {
      const existing = { ".id": "*1", comment: "mark-web", chain: "prerouting", disabled: "true" };
      const ctx = makeContext([existing]);
      await manageMangleRuleTool.handler(
        { routerId: "test-router", action: "enable", comment: "mark-web" },
        ctx,
      );
      const mockUpdate = (ctx.routerClient as Record<string, unknown>).update as ReturnType<typeof vi.fn>;
      expect(mockUpdate).toHaveBeenCalledWith("ip/firewall/mangle", "*1", { disabled: "false" });
    });
  });
});
```

- [ ] **Step 2: Run tests to confirm they fail**

```bash
npm test -- --reporter=verbose test/unit/domain/mangle-tools.test.ts
```

Expected: FAIL — `Cannot find module '../../../src/domain/tools/mangle-tools.js'`

---

## Task 2: Mangle tools — implementation

**Files:**
- Create: `src/domain/tools/mangle-tools.ts`

- [ ] **Step 1: Write the implementation**

```typescript
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("mangle-tools");

const MANGLE_PATH = "ip/firewall/mangle";

async function findMangleRuleByComment(
  context: ToolContext,
  comment: string,
): Promise<Record<string, string> | undefined> {
  const results = await context.routerClient.get<RouterOSRecord>(MANGLE_PATH, {
    filter: { comment },
  });
  return results.length > 0 ? (results[0] as Record<string, string>) : undefined;
}

const listMangleRulesInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  chain: z.string().optional().describe("Filter by chain name (e.g. prerouting, forward, postrouting)"),
  action: z.string().optional().describe("Filter by mangle action (e.g. mark-routing, mark-connection)"),
  disabled: z.boolean().optional().describe("Filter by disabled state"),
}).strict();

const listMangleRulesTool: ToolDefinition = {
  name: "list_mangle_rules",
  title: "List Mangle Rules",
  description:
    "List firewall mangle rules on a MikroTik router in evaluation order. Supports filtering by chain, action, and disabled state.",
  inputSchema: listMangleRulesInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listMangleRulesInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing mangle rules");

    try {
      let rules = await context.routerClient.get<RouterOSRecord>(MANGLE_PATH, {
        limit: undefined,
        offset: undefined,
      });

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

      return {
        content: `Mangle rules on ${context.routerId}: ${rules.length} rule(s).`,
        structuredContent: { routerId: context.routerId, rules, total: rules.length },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "list_mangle_rules" });
    }
  },
};

const manageMangleRuleInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
  comment: z.string().describe("Idempotency key — uniquely identifies this mangle rule"),
  chain: z.string().optional().describe("Mangle chain (required on add): prerouting, input, forward, output, postrouting"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  srcAddress: z.string().optional().describe("Source IP/CIDR to match"),
  dstAddress: z.string().optional().describe("Destination IP/CIDR to match"),
  srcAddressList: z.string().optional().describe("Source address list name to match"),
  dstAddressList: z.string().optional().describe("Destination address list name to match"),
  protocol: z.string().optional().describe("Protocol to match (e.g. tcp, udp)"),
  srcPort: z.string().optional().describe("Source port or range"),
  dstPort: z.string().optional().describe("Destination port or range"),
  inInterface: z.string().optional().describe("Incoming interface to match"),
  outInterface: z.string().optional().describe("Outgoing interface to match"),
  newRoutingMark: z.string().optional().describe("Routing mark to set"),
  newConnectionMark: z.string().optional().describe("Connection mark to set"),
  newDscpValue: z.number().int().min(0).max(63).optional().describe("DSCP value to set (0–63)"),
  passthrough: z.boolean().optional().describe("Whether to continue matching subsequent rules"),
}).strict().refine(
  (d) => d.action !== "add" || d.chain !== undefined,
  { message: "chain is required when action is add", path: ["chain"] },
);

const manageMangleRuleTool: ToolDefinition = {
  name: "manage_mangle_rule",
  title: "Manage Mangle Rule",
  description:
    "Add, remove, enable, or disable a firewall mangle rule. Uses comment as idempotency key. Supports dry-run mode.",
  inputSchema: manageMangleRuleInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageMangleRuleInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, comment: parsed.comment }, "Managing mangle rule");

    try {
      if (parsed.action === "add") {
        const existing = await findMangleRuleByComment(context, parsed.comment);

        if (existing) {
          const sameChain = existing.chain === parsed.chain;
          const sameSrcAddress = (existing["src-address"] ?? "") === (parsed.srcAddress ?? "");
          const sameDstAddress = (existing["dst-address"] ?? "") === (parsed.dstAddress ?? "");
          const sameSrcAddressList = (existing["src-address-list"] ?? "") === (parsed.srcAddressList ?? "");
          const sameDstAddressList = (existing["dst-address-list"] ?? "") === (parsed.dstAddressList ?? "");
          const sameNewRoutingMark = (existing["new-routing-mark"] ?? "") === (parsed.newRoutingMark ?? "");
          const sameNewConnectionMark = (existing["new-connection-mark"] ?? "") === (parsed.newConnectionMark ?? "");

          if (
            sameChain &&
            sameSrcAddress &&
            sameDstAddress &&
            sameSrcAddressList &&
            sameDstAddressList &&
            sameNewRoutingMark &&
            sameNewConnectionMark
          ) {
            return {
              content: `Mangle rule with comment "${parsed.comment}" already exists. No changes made.`,
              structuredContent: { action: "already_exists", rule: existing },
            };
          }

          throw new MikroMCPError({
            category: ErrorCategory.CONFLICT,
            code: "MANGLE_RULE_CONFLICT",
            message: `Mangle rule with comment "${parsed.comment}" already exists but with different configuration.`,
            details: {
              existing: {
                chain: existing.chain,
                "src-address": existing["src-address"],
                "dst-address": existing["dst-address"],
                "new-routing-mark": existing["new-routing-mark"],
                "new-connection-mark": existing["new-connection-mark"],
              },
              requested: {
                chain: parsed.chain,
                "src-address": parsed.srcAddress,
                "dst-address": parsed.dstAddress,
                "new-routing-mark": parsed.newRoutingMark,
                "new-connection-mark": parsed.newConnectionMark,
              },
            },
            recoverability: {
              retryable: false,
              suggestedAction:
                "Remove the existing mangle rule first, then re-add with the desired configuration.",
              alternativeTools: ["manage_mangle_rule with action=remove"],
            },
          });
        }

        const body: Record<string, string> = {
          chain: parsed.chain!,
          comment: parsed.comment,
        };

        if (parsed.srcAddress !== undefined) body["src-address"] = parsed.srcAddress;
        if (parsed.dstAddress !== undefined) body["dst-address"] = parsed.dstAddress;
        if (parsed.srcAddressList !== undefined) body["src-address-list"] = parsed.srcAddressList;
        if (parsed.dstAddressList !== undefined) body["dst-address-list"] = parsed.dstAddressList;
        if (parsed.protocol !== undefined) body.protocol = parsed.protocol;
        if (parsed.srcPort !== undefined) body["src-port"] = parsed.srcPort;
        if (parsed.dstPort !== undefined) body["dst-port"] = parsed.dstPort;
        if (parsed.inInterface !== undefined) body["in-interface"] = parsed.inInterface;
        if (parsed.outInterface !== undefined) body["out-interface"] = parsed.outInterface;
        if (parsed.newRoutingMark !== undefined) body["new-routing-mark"] = parsed.newRoutingMark;
        if (parsed.newConnectionMark !== undefined) body["new-connection-mark"] = parsed.newConnectionMark;
        if (parsed.newDscpValue !== undefined) body["new-dscp"] = String(parsed.newDscpValue);
        if (parsed.passthrough !== undefined) body.passthrough = parsed.passthrough ? "yes" : "no";

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

        const created = await context.routerClient.create(MANGLE_PATH, body);
        log.info({ comment: parsed.comment, id: created[".id"] }, "Mangle rule added");

        return {
          content: `Added mangle rule in chain "${parsed.chain}" with comment "${parsed.comment}".`,
          structuredContent: { action: "created", rule: created },
        };
      }

      if (parsed.action === "remove") {
        const existing = await findMangleRuleByComment(context, parsed.comment);
        if (!existing) {
          return {
            content: `Mangle rule with comment "${parsed.comment}" does not exist. No changes made.`,
            structuredContent: { action: "already_removed", comment: parsed.comment },
          };
        }

        const id = existing[".id"];

        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove mangle rule with comment "${parsed.comment}".`,
            structuredContent: { action: "dry_run", id, comment: parsed.comment },
          };
        }

        await context.routerClient.remove(MANGLE_PATH, id);
        log.info({ id, comment: parsed.comment }, "Mangle rule removed");

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

      if (parsed.action === "enable" || parsed.action === "disable") {
        const wantDisabled = parsed.action === "disable";
        const existing = await findMangleRuleByComment(context, parsed.comment);

        if (!existing) {
          throw new MikroMCPError({
            category: ErrorCategory.NOT_FOUND,
            code: "MANGLE_RULE_NOT_FOUND",
            message: `No mangle rule found with comment "${parsed.comment}".`,
            details: { comment: parsed.comment },
            recoverability: {
              retryable: false,
              suggestedAction: "Verify the comment using list_mangle_rules.",
              alternativeTools: ["list_mangle_rules"],
            },
          });
        }

        const id = existing[".id"];
        const isDisabled = existing.disabled === "true";

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

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

        await context.routerClient.update(MANGLE_PATH, id, { disabled: wantDisabled ? "true" : "false" });
        log.info({ id, comment: parsed.comment, action: parsed.action }, "Mangle rule toggled");

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

      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, enable, disable." },
      });
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_mangle_rule" });
    }
  },
};

export const mangleTools: ToolDefinition[] = [listMangleRulesTool, manageMangleRuleTool];
```

- [ ] **Step 2: Run tests and confirm they pass**

```bash
npm test -- --reporter=verbose test/unit/domain/mangle-tools.test.ts
```

Expected: All tests PASS

- [ ] **Step 3: Run typecheck**

```bash
npm run typecheck
```

Expected: No errors

- [ ] **Step 4: Commit**

```bash
git add src/domain/tools/mangle-tools.ts test/unit/domain/mangle-tools.test.ts
git commit -m "feat: add mangle tools (list_mangle_rules, manage_mangle_rule)"
```

---

## Task 3: Address list tools — tests

**Files:**
- Create: `test/unit/domain/address-list-tools.test.ts`

- [ ] **Step 1: Write the test file**

```typescript
import { describe, it, expect, vi } from "vitest";
import { addressListTools } from "../../../src/domain/tools/address-list-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import { z } from "zod";

const listAddressListTool = addressListTools[0];
const manageAddressListTool = addressListTools[1];

const listSchema = z.object({
  routerId: z.string(),
  list: z.string().optional(),
  address: z.string().optional(),
}).strict();

const manageSchema = z.object({
  routerId: z.string(),
  action: z.enum(["add", "remove"]),
  list: z.string(),
  address: z.string(),
  comment: z.string().optional(),
  timeout: z.string().optional(),
  dryRun: z.boolean().default(false),
}).strict();

function makeContext(
  records: Record<string, unknown>[],
  createReturn?: Record<string, unknown>,
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue(createReturn ?? { ".id": "*1", list: "blocked", address: "10.0.0.1" }),
      remove: vi.fn().mockResolvedValue(undefined),
      update: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("address list tools", () => {
  describe("metadata", () => {
    it("exports 2 tools", () => {
      expect(addressListTools).toHaveLength(2);
      expect(listAddressListTool.name).toBe("list_address_list_entries");
      expect(manageAddressListTool.name).toBe("manage_address_list_entry");
    });

    it("list_address_list_entries has readOnlyHint true", () => {
      expect(listAddressListTool.annotations.readOnlyHint).toBe(true);
      expect(listAddressListTool.annotations.destructiveHint).toBe(false);
    });

    it("manage_address_list_entry has correct annotations", () => {
      expect(manageAddressListTool.annotations.readOnlyHint).toBe(false);
      expect(manageAddressListTool.annotations.destructiveHint).toBe(false);
      expect(manageAddressListTool.annotations.idempotentHint).toBe(true);
    });
  });

  describe("list_address_list_entries input schema", () => {
    it("accepts minimal input", () => {
      expect(() => listSchema.parse({ routerId: "r" })).not.toThrow();
    });

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

  describe("manage_address_list_entry input schema", () => {
    it("accepts valid add", () => {
      const r = manageSchema.parse({ routerId: "r", action: "add", list: "blocked", address: "10.0.0.1" });
      expect(r.dryRun).toBe(false);
    });

    it("rejects extra fields", () => {
      expect(() =>
        manageSchema.parse({ routerId: "r", action: "add", list: "l", address: "1.1.1.1", extra: true }),
      ).toThrow();
    });
  });

  describe("list_address_list_entries handler", () => {
    it("returns all entries with correct total", async () => {
      const entries = [
        { ".id": "*1", list: "blocked", address: "10.0.0.1" },
        { ".id": "*2", list: "allowed", address: "192.168.1.0/24" },
      ];
      const ctx = makeContext(entries);
      const result = await listAddressListTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(2);
    });
  });

  describe("manage_address_list_entry handler - add", () => {
    it("creates entry and returns created", async () => {
      const ctx = makeContext([]);
      const result = await manageAddressListTool.handler(
        { routerId: "test-router", action: "add", list: "blocked", address: "10.0.0.1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).toHaveBeenCalled();
    });

    it("returns already_exists when list+address found", async () => {
      const existing = { ".id": "*1", list: "blocked", address: "10.0.0.1" };
      const ctx = makeContext([existing]);
      const result = await manageAddressListTool.handler(
        { routerId: "test-router", action: "add", list: "blocked", address: "10.0.0.1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
    });

    it("returns dry_run without calling create", async () => {
      const ctx = makeContext([]);
      const result = await manageAddressListTool.handler(
        { routerId: "test-router", action: "add", list: "blocked", address: "10.0.0.1", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).not.toHaveBeenCalled();
    });
  });

  describe("manage_address_list_entry handler - remove", () => {
    it("removes entry and calls remove with correct args", async () => {
      const existing = { ".id": "*1", list: "blocked", address: "10.0.0.1" };
      const ctx = makeContext([existing]);
      const result = await manageAddressListTool.handler(
        { routerId: "test-router", action: "remove", list: "blocked", address: "10.0.0.1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      const mockRemove = (ctx.routerClient as Record<string, unknown>).remove as ReturnType<typeof vi.fn>;
      expect(mockRemove).toHaveBeenCalledWith("ip/firewall/address-list", "*1");
    });

    it("returns already_removed when not found", async () => {
      const ctx = makeContext([]);
      const result = await manageAddressListTool.handler(
        { routerId: "test-router", action: "remove", list: "blocked", address: "10.0.0.1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_removed");
    });
  });
});
```

- [ ] **Step 2: Run tests to confirm they fail**

```bash
npm test -- --reporter=verbose test/unit/domain/address-list-tools.test.ts
```

Expected: FAIL — `Cannot find module '../../../src/domain/tools/address-list-tools.js'`

---

## Task 4: Address list tools — implementation

**Files:**
- Create: `src/domain/tools/address-list-tools.ts`

- [ ] **Step 1: Write the implementation**

```typescript
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("address-list-tools");

const ADDRESS_LIST_PATH = "ip/firewall/address-list";

const listAddressListInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  list: z.string().optional().describe("Filter by address list name"),
  address: z.string().optional().describe("Filter by address (IP or CIDR)"),
}).strict();

const listAddressListTool: ToolDefinition = {
  name: "list_address_list_entries",
  title: "List Address List Entries",
  description:
    "List firewall address list entries on a MikroTik router. Supports filtering by list name and address.",
  inputSchema: listAddressListInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listAddressListInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing address list entries");

    try {
      const filter: Record<string, string> = {};
      if (parsed.list !== undefined) filter.list = parsed.list;
      if (parsed.address !== undefined) filter.address = parsed.address;

      const entries = await context.routerClient.get<RouterOSRecord>(ADDRESS_LIST_PATH, {
        filter: Object.keys(filter).length > 0 ? filter : undefined,
        limit: undefined,
        offset: undefined,
      });

      return {
        content: `Address list entries on ${context.routerId}: ${entries.length} entry(ies).`,
        structuredContent: { routerId: context.routerId, entries, total: entries.length },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "list_address_list_entries" });
    }
  },
};

const manageAddressListInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove"]).describe("Action to perform"),
  list: z.string().describe("Address list name"),
  address: z.string().describe("IP address or CIDR to add/remove"),
  comment: z.string().optional().describe("Optional comment for the entry"),
  timeout: z.string().optional().describe("Expiry timeout (e.g. 1d, 2h30m) — omit for permanent"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const manageAddressListTool: ToolDefinition = {
  name: "manage_address_list_entry",
  title: "Manage Address List Entry",
  description:
    "Add or remove a firewall address list entry. Idempotent by list name + address. Supports dry-run mode.",
  inputSchema: manageAddressListInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageAddressListInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, list: parsed.list }, "Managing address list entry");

    try {
      const existing = await context.routerClient.get<RouterOSRecord>(ADDRESS_LIST_PATH, {
        filter: { list: parsed.list, address: parsed.address },
      });
      const found = existing.length > 0 ? (existing[0] as Record<string, string>) : undefined;

      if (parsed.action === "add") {
        if (found) {
          return {
            content: `Address list entry "${parsed.address}" in list "${parsed.list}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", entry: found },
          };
        }

        const body: Record<string, string> = { list: parsed.list, address: parsed.address };
        if (parsed.comment !== undefined) body.comment = parsed.comment;
        if (parsed.timeout !== undefined) body.timeout = parsed.timeout;

        if (parsed.dryRun) {
          const diff = Object.entries(body).map(([property, after]) => ({ property, before: null, after }));
          return {
            content: `Dry run: Would add "${parsed.address}" to address list "${parsed.list}".`,
            structuredContent: { action: "dry_run", diff },
          };
        }

        const created = await context.routerClient.create(ADDRESS_LIST_PATH, body);
        log.info({ list: parsed.list, address: parsed.address, id: created[".id"] }, "Address list entry added");

        return {
          content: `Added "${parsed.address}" to address list "${parsed.list}".`,
          structuredContent: { action: "created", entry: created },
        };
      }

      if (parsed.action === "remove") {
        if (!found) {
          return {
            content: `Address list entry "${parsed.address}" in list "${parsed.list}" does not exist. No changes made.`,
            structuredContent: { action: "already_removed", list: parsed.list, address: parsed.address },
          };
        }

        const id = found[".id"];

        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove "${parsed.address}" from address list "${parsed.list}".`,
            structuredContent: { action: "dry_run", id, list: parsed.list, address: parsed.address },
          };
        }

        await context.routerClient.remove(ADDRESS_LIST_PATH, id);
        log.info({ id, list: parsed.list, address: parsed.address }, "Address list entry removed");

        return {
          content: `Removed "${parsed.address}" from address list "${parsed.list}".`,
          structuredContent: { action: "removed", id, list: parsed.list, address: parsed.address },
        };
      }

      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_address_list_entry" });
    }
  },
};

export const addressListTools: ToolDefinition[] = [listAddressListTool, manageAddressListTool];
```

- [ ] **Step 2: Run tests and confirm they pass**

```bash
npm test -- --reporter=verbose test/unit/domain/address-list-tools.test.ts
```

Expected: All tests PASS

- [ ] **Step 3: Run typecheck**

```bash
npm run typecheck
```

Expected: No errors

- [ ] **Step 4: Commit**

```bash
git add src/domain/tools/address-list-tools.ts test/unit/domain/address-list-tools.test.ts
git commit -m "feat: add address list tools (list_address_list_entries, manage_address_list_entry)"
```

---

## Task 5: Policy routing tools — tests

**Files:**
- Create: `test/unit/domain/policy-routing-tools.test.ts`

- [ ] **Step 1: Write the test file**

```typescript
import { describe, it, expect, vi } from "vitest";
import { policyRoutingTools } from "../../../src/domain/tools/policy-routing-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import { z } from "zod";

const listRoutingRulesTool = policyRoutingTools[0];
const manageRoutingRuleTool = policyRoutingTools[1];
const listRoutingTablesTool = policyRoutingTools[2];
const manageRoutingTableTool = policyRoutingTools[3];

const listRulesSchema = z.object({
  routerId: z.string(),
  table: z.string().optional(),
  disabled: z.boolean().optional(),
}).strict();

const manageRuleSchema = z.object({
  routerId: z.string(),
  action: z.enum(["add", "remove", "enable", "disable"]),
  table: z.string(),
  srcAddress: z.string().optional(),
  dstAddress: z.string().optional(),
  interface: z.string().optional(),
  priority: z.number().int().min(0).max(4294967295).optional(),
  dryRun: z.boolean().default(false),
}).strict();

const manageTableSchema = z.object({
  routerId: z.string(),
  action: z.enum(["add", "remove"]),
  name: z.string(),
  fib: z.boolean().default(false),
  dryRun: z.boolean().default(false),
}).strict();

function makeContext(
  records: Record<string, unknown>[],
  createReturn?: Record<string, unknown>,
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue(createReturn ?? { ".id": "*1" }),
      remove: vi.fn().mockResolvedValue(undefined),
      update: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("policy routing tools", () => {
  describe("metadata", () => {
    it("exports 4 tools", () => {
      expect(policyRoutingTools).toHaveLength(4);
      expect(listRoutingRulesTool.name).toBe("list_routing_rules");
      expect(manageRoutingRuleTool.name).toBe("manage_routing_rule");
      expect(listRoutingTablesTool.name).toBe("list_routing_tables");
      expect(manageRoutingTableTool.name).toBe("manage_routing_table");
    });

    it("list tools have readOnlyHint true", () => {
      expect(listRoutingRulesTool.annotations.readOnlyHint).toBe(true);
      expect(listRoutingTablesTool.annotations.readOnlyHint).toBe(true);
    });

    it("manage tools have readOnlyHint false and idempotentHint true", () => {
      expect(manageRoutingRuleTool.annotations.readOnlyHint).toBe(false);
      expect(manageRoutingRuleTool.annotations.idempotentHint).toBe(true);
      expect(manageRoutingTableTool.annotations.readOnlyHint).toBe(false);
      expect(manageRoutingTableTool.annotations.idempotentHint).toBe(true);
    });
  });

  describe("manage_routing_rule input schema", () => {
    it("accepts valid add with dstAddress and table", () => {
      const r = manageRuleSchema.parse({ routerId: "r", action: "add", table: "isp1", dstAddress: "0.0.0.0/0" });
      expect(r.dryRun).toBe(false);
    });

    it("rejects priority below 0", () => {
      expect(() =>
        manageRuleSchema.parse({ routerId: "r", action: "add", table: "t", dstAddress: "0.0.0.0/0", priority: -1 }),
      ).toThrow();
    });

    it("rejects extra fields", () => {
      expect(() =>
        manageRuleSchema.parse({ routerId: "r", action: "add", table: "t", dstAddress: "0.0.0.0/0", unknownField: true }),
      ).toThrow();
    });
  });

  describe("manage_routing_table input schema", () => {
    it("accepts valid add", () => {
      const r = manageTableSchema.parse({ routerId: "r", action: "add", name: "isp1" });
      expect(r.fib).toBe(false);
      expect(r.dryRun).toBe(false);
    });

    it("rejects extra fields", () => {
      expect(() =>
        manageTableSchema.parse({ routerId: "r", action: "add", name: "isp1", unknownField: true }),
      ).toThrow();
    });
  });

  describe("list_routing_rules handler", () => {
    const sampleRules = [
      { ".id": "*1", "dst-address": "0.0.0.0/0", table: "isp1", "src-address": "", interface: "", disabled: "false" },
      { ".id": "*2", "src-address": "192.168.1.0/24", table: "isp2", "dst-address": "", interface: "", disabled: "true" },
    ];

    it("returns all rules with correct total", async () => {
      const ctx = makeContext(sampleRules);
      const result = await listRoutingRulesTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(2);
    });

    it("filters by table", async () => {
      const ctx = makeContext(sampleRules);
      const result = await listRoutingRulesTool.handler({ routerId: "test-router", table: "isp1" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(1);
    });

    it("filters by disabled", async () => {
      const ctx = makeContext(sampleRules);
      const result = await listRoutingRulesTool.handler({ routerId: "test-router", disabled: true }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(1);
    });
  });

  describe("manage_routing_rule handler - add", () => {
    it("creates rule and returns created", async () => {
      const ctx = makeContext([]);
      const result = await manageRoutingRuleTool.handler(
        { routerId: "test-router", action: "add", table: "isp1", dstAddress: "0.0.0.0/0" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).toHaveBeenCalled();
    });

    it("returns already_exists when composite key matches", async () => {
      const existing = {
        ".id": "*1",
        table: "isp1",
        "dst-address": "0.0.0.0/0",
        "src-address": "",
        interface: "",
        disabled: "false",
      };
      const ctx = makeContext([existing]);
      const result = await manageRoutingRuleTool.handler(
        { routerId: "test-router", action: "add", table: "isp1", dstAddress: "0.0.0.0/0" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
    });

    it("returns dry_run without calling create", async () => {
      const ctx = makeContext([]);
      const result = await manageRoutingRuleTool.handler(
        { routerId: "test-router", action: "add", table: "isp1", dstAddress: "0.0.0.0/0", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).not.toHaveBeenCalled();
    });
  });

  describe("manage_routing_rule handler - remove", () => {
    it("removes rule and returns removed", async () => {
      const existing = {
        ".id": "*1",
        table: "isp1",
        "dst-address": "0.0.0.0/0",
        "src-address": "",
        interface: "",
        disabled: "false",
      };
      const ctx = makeContext([existing]);
      const result = await manageRoutingRuleTool.handler(
        { routerId: "test-router", action: "remove", table: "isp1", dstAddress: "0.0.0.0/0" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      const mockRemove = (ctx.routerClient as Record<string, unknown>).remove as ReturnType<typeof vi.fn>;
      expect(mockRemove).toHaveBeenCalledWith("routing/rule", "*1");
    });

    it("returns already_removed when not found", async () => {
      const ctx = makeContext([]);
      const result = await manageRoutingRuleTool.handler(
        { routerId: "test-router", action: "remove", table: "isp1", dstAddress: "0.0.0.0/0" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_removed");
    });
  });

  describe("manage_routing_rule handler - enable/disable", () => {
    it("throws NOT_FOUND when rule not found", async () => {
      const ctx = makeContext([]);
      await expect(
        manageRoutingRuleTool.handler(
          { routerId: "test-router", action: "enable", table: "isp1", dstAddress: "0.0.0.0/0" },
          ctx,
        ),
      ).rejects.toThrow();
    });

    it("calls update with disabled true when disabling", async () => {
      const existing = {
        ".id": "*1",
        table: "isp1",
        "dst-address": "0.0.0.0/0",
        "src-address": "",
        interface: "",
        disabled: "false",
      };
      const ctx = makeContext([existing]);
      const result = await manageRoutingRuleTool.handler(
        { routerId: "test-router", action: "disable", table: "isp1", dstAddress: "0.0.0.0/0" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("updated");
      const mockUpdate = (ctx.routerClient as Record<string, unknown>).update as ReturnType<typeof vi.fn>;
      expect(mockUpdate).toHaveBeenCalledWith("routing/rule", "*1", { disabled: "true" });
    });
  });

  describe("manage_routing_table handler", () => {
    it("creates table and returns created", async () => {
      const ctx = makeContext([]);
      const result = await manageRoutingTableTool.handler(
        { routerId: "test-router", action: "add", name: "isp1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
    });

    it("returns already_exists when name found", async () => {
      const existing = { ".id": "*1", name: "isp1" };
      const ctx = makeContext([existing]);
      const result = await manageRoutingTableTool.handler(
        { routerId: "test-router", action: "add", name: "isp1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
    });

    it("removes table and calls remove with correct args", async () => {
      const existing = { ".id": "*1", name: "isp1" };
      const ctx = makeContext([existing]);
      const result = await manageRoutingTableTool.handler(
        { routerId: "test-router", action: "remove", name: "isp1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      const mockRemove = (ctx.routerClient as Record<string, unknown>).remove as ReturnType<typeof vi.fn>;
      expect(mockRemove).toHaveBeenCalledWith("routing/table", "*1");
    });

    it("returns already_removed when not found", async () => {
      const ctx = makeContext([]);
      const result = await manageRoutingTableTool.handler(
        { routerId: "test-router", action: "remove", name: "isp1" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_removed");
    });

    it("returns dry_run without calling create", async () => {
      const ctx = makeContext([]);
      const result = await manageRoutingTableTool.handler(
        { routerId: "test-router", action: "add", name: "isp1", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      const mockCreate = (ctx.routerClient as Record<string, unknown>).create as ReturnType<typeof vi.fn>;
      expect(mockCreate).not.toHaveBeenCalled();
    });
  });
});
```

- [ ] **Step 2: Run tests to confirm they fail**

```bash
npm test -- --reporter=verbose test/unit/domain/policy-routing-tools.test.ts
```

Expected: FAIL — `Cannot find module '../../../src/domain/tools/policy-routing-tools.js'`

---

## Task 6: Policy routing tools — implementation

**Files:**
- Create: `src/domain/tools/policy-routing-tools.ts`

- [ ] **Step 1: Write the implementation**

```typescript
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("policy-routing-tools");

const ROUTING_RULE_PATH = "routing/rule";
const ROUTING_TABLE_PATH = "routing/table";

const listRoutingRulesInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  table: z.string().optional().describe("Filter by routing table name"),
  disabled: z.boolean().optional().describe("Filter by disabled state"),
}).strict();

const listRoutingRulesTool: ToolDefinition = {
  name: "list_routing_rules",
  title: "List Routing Rules",
  description: "List policy routing rules on a MikroTik router in evaluation order. Supports filtering by table and disabled state.",
  inputSchema: listRoutingRulesInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listRoutingRulesInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing routing rules");

    try {
      let rules = await context.routerClient.get<RouterOSRecord>(ROUTING_RULE_PATH, {
        limit: undefined,
        offset: undefined,
      });

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

      return {
        content: `Routing rules on ${context.routerId}: ${rules.length} rule(s).`,
        structuredContent: { routerId: context.routerId, rules, total: rules.length },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "list_routing_rules" });
    }
  },
};

const manageRoutingRuleInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
  table: z.string().describe(
    "Routing table name — part of the composite idempotency key; required for all actions",
  ),
  srcAddress: z.string().optional().describe("Source CIDR to match"),
  dstAddress: z.string().optional().describe("Destination CIDR to match"),
  interface: z.string().optional().describe("Incoming interface to match"),
  priority: z.number().int().min(0).max(4294967295).optional().describe("Rule priority (0–4294967295)"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict().refine(
  (d) => d.action !== "add" || d.srcAddress !== undefined || d.dstAddress !== undefined || d.interface !== undefined,
  {
    message: "At least one of srcAddress, dstAddress, or interface must be provided when action is add",
    path: ["srcAddress"],
  },
);

function findRoutingRule(
  rules: RouterOSRecord[],
  srcAddress: string | undefined,
  dstAddress: string | undefined,
  iface: string | undefined,
  table: string,
): Record<string, string> | undefined {
  return rules.find((r) => {
    const rec = r as Record<string, string>;
    return (
      (rec["src-address"] ?? "") === (srcAddress ?? "") &&
      (rec["dst-address"] ?? "") === (dstAddress ?? "") &&
      (rec["interface"] ?? "") === (iface ?? "") &&
      rec.table === table
    );
  }) as Record<string, string> | undefined;
}

const manageRoutingRuleTool: ToolDefinition = {
  name: "manage_routing_rule",
  title: "Manage Routing Rule",
  description:
    "Add, remove, enable, or disable a policy routing rule. Idempotent by srcAddress+dstAddress+interface+table composite key. Supports dry-run mode.",
  inputSchema: manageRoutingRuleInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageRoutingRuleInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, table: parsed.table }, "Managing routing rule");

    try {
      const allRules = await context.routerClient.get<RouterOSRecord>(ROUTING_RULE_PATH, {
        limit: undefined,
        offset: undefined,
      });
      const existing = findRoutingRule(allRules, parsed.srcAddress, parsed.dstAddress, parsed.interface, parsed.table);

      if (parsed.action === "add") {
        if (existing) {
          return {
            content: `Routing rule for table "${parsed.table}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", rule: existing },
          };
        }

        const body: Record<string, string> = { table: parsed.table };
        if (parsed.srcAddress !== undefined) body["src-address"] = parsed.srcAddress;
        if (parsed.dstAddress !== undefined) body["dst-address"] = parsed.dstAddress;
        if (parsed.interface !== undefined) body["interface"] = parsed.interface;
        if (parsed.priority !== undefined) body.priority = String(parsed.priority);

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

        const created = await context.routerClient.create(ROUTING_RULE_PATH, body);
        log.info({ table: parsed.table, id: created[".id"] }, "Routing rule added");

        return {
          content: `Added routing rule for table "${parsed.table}".`,
          structuredContent: { action: "created", rule: created },
        };
      }

      if (parsed.action === "remove") {
        if (!existing) {
          return {
            content: `Routing rule for table "${parsed.table}" does not exist. No changes made.`,
            structuredContent: { action: "already_removed", table: parsed.table },
          };
        }

        const id = existing[".id"];

        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove routing rule for table "${parsed.table}".`,
            structuredContent: { action: "dry_run", id, table: parsed.table },
          };
        }

        await context.routerClient.remove(ROUTING_RULE_PATH, id);
        log.info({ id, table: parsed.table }, "Routing rule removed");

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

      if (parsed.action === "enable" || parsed.action === "disable") {
        const wantDisabled = parsed.action === "disable";

        if (!existing) {
          throw new MikroMCPError({
            category: ErrorCategory.NOT_FOUND,
            code: "ROUTING_RULE_NOT_FOUND",
            message: `No routing rule found matching table="${parsed.table}".`,
            details: {
              table: parsed.table,
              srcAddress: parsed.srcAddress,
              dstAddress: parsed.dstAddress,
              interface: parsed.interface,
            },
            recoverability: {
              retryable: false,
              suggestedAction: "Verify the rule using list_routing_rules.",
              alternativeTools: ["list_routing_rules"],
            },
          });
        }

        const id = existing[".id"];
        const isDisabled = existing.disabled === "true";

        if (isDisabled === wantDisabled) {
          return {
            content: `Routing rule is already ${wantDisabled ? "disabled" : "enabled"}. No changes made.`,
            structuredContent: { action: "no_change", id, table: parsed.table },
          };
        }

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

        await context.routerClient.update(ROUTING_RULE_PATH, id, { disabled: wantDisabled ? "true" : "false" });
        log.info({ id, table: parsed.table, action: parsed.action }, "Routing rule toggled");

        return {
          content: `${parsed.action === "disable" ? "Disabled" : "Enabled"} routing rule for table "${parsed.table}".`,
          structuredContent: { action: "updated", id, table: parsed.table },
        };
      }

      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, enable, disable." },
      });
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_routing_rule" });
    }
  },
};

const listRoutingTablesInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
}).strict();

const listRoutingTablesTool: ToolDefinition = {
  name: "list_routing_tables",
  title: "List Routing Tables",
  description: "List custom routing tables on a MikroTik router.",
  inputSchema: listRoutingTablesInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listRoutingTablesInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing routing tables");

    try {
      const tables = await context.routerClient.get<RouterOSRecord>(ROUTING_TABLE_PATH, {
        limit: undefined,
        offset: undefined,
      });

      return {
        content: `Routing tables on ${context.routerId}: ${tables.length} table(s).`,
        structuredContent: { routerId: context.routerId, tables, total: tables.length },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "list_routing_tables" });
    }
  },
};

const manageRoutingTableInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove"]).describe("Action to perform"),
  name: z.string().describe("Routing table name (idempotency key)"),
  fib: z.boolean().default(false).describe("Whether to sync this table with the FIB"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const manageRoutingTableTool: ToolDefinition = {
  name: "manage_routing_table",
  title: "Manage Routing Table",
  description: "Create or remove a custom routing table. Idempotent by table name. Supports dry-run mode.",
  inputSchema: manageRoutingTableInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageRoutingTableInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing routing table");

    try {
      const existing = await context.routerClient.get<RouterOSRecord>(ROUTING_TABLE_PATH, {
        filter: { name: parsed.name },
      });
      const found = existing.length > 0 ? (existing[0] as Record<string, string>) : undefined;

      if (parsed.action === "add") {
        if (found) {
          return {
            content: `Routing table "${parsed.name}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", table: found },
          };
        }

        const body: Record<string, string> = { name: parsed.name, fib: parsed.fib ? "true" : "false" };

        if (parsed.dryRun) {
          const diff = Object.entries(body).map(([property, after]) => ({ property, before: null, after }));
          return {
            content: `Dry run: Would create routing table "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff },
          };
        }

        const created = await context.routerClient.create(ROUTING_TABLE_PATH, body);
        log.info({ name: parsed.name, id: created[".id"] }, "Routing table created");

        return {
          content: `Created routing table "${parsed.name}".`,
          structuredContent: { action: "created", table: created },
        };
      }

      if (parsed.action === "remove") {
        if (!found) {
          return {
            content: `Routing table "${parsed.name}" does not exist. No changes made.`,
            structuredContent: { action: "already_removed", name: parsed.name },
          };
        }

        const id = found[".id"];

        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove routing table "${parsed.name}".`,
            structuredContent: { action: "dry_run", id, name: parsed.name },
          };
        }

        await context.routerClient.remove(ROUTING_TABLE_PATH, id);
        log.info({ id, name: parsed.name }, "Routing table removed");

        return {
          content: `Removed routing table "${parsed.name}".`,
          structuredContent: { action: "removed", id, name: parsed.name },
        };
      }

      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_routing_table" });
    }
  },
};

export const policyRoutingTools: ToolDefinition[] = [
  listRoutingRulesTool,
  manageRoutingRuleTool,
  listRoutingTablesTool,
  manageRoutingTableTool,
];
```

- [ ] **Step 2: Run tests and confirm they pass**

```bash
npm test -- --reporter=verbose test/unit/domain/policy-routing-tools.test.ts
```

Expected: All tests PASS

- [ ] **Step 3: Run typecheck**

```bash
npm run typecheck
```

Expected: No errors

- [ ] **Step 4: Commit**

```bash
git add src/domain/tools/policy-routing-tools.ts test/unit/domain/policy-routing-tools.test.ts
git commit -m "feat: add policy routing tools (list/manage routing rules and tables)"
```

---

## Task 7: Routing protocol tools — tests

**Files:**
- Create: `test/unit/domain/routing-protocol-tools.test.ts`

- [ ] **Step 1: Write the test file**

```typescript
import { describe, it, expect, vi } from "vitest";
import { routingProtocolTools } from "../../../src/domain/tools/routing-protocol-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import { z } from "zod";

const listBgpPeersTool = routingProtocolTools[0];
const listOspfNeighborsTool = routingProtocolTools[1];

const listBgpSchema = z.object({ routerId: z.string(), state: z.string().optional() }).strict();
const listOspfSchema = z.object({ routerId: z.string(), state: z.string().optional() }).strict();

function makeContext(records: Record<string, unknown>[]): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
    } as unknown as RouterOSRestClient,
  };
}

describe("routing protocol tools", () => {
  describe("metadata", () => {
    it("exports 2 tools", () => {
      expect(routingProtocolTools).toHaveLength(2);
      expect(listBgpPeersTool.name).toBe("list_bgp_peers");
      expect(listOspfNeighborsTool.name).toBe("list_ospf_neighbors");
    });

    it("both tools have readOnlyHint true and destructiveHint false", () => {
      expect(listBgpPeersTool.annotations.readOnlyHint).toBe(true);
      expect(listBgpPeersTool.annotations.destructiveHint).toBe(false);
      expect(listOspfNeighborsTool.annotations.readOnlyHint).toBe(true);
      expect(listOspfNeighborsTool.annotations.destructiveHint).toBe(false);
    });
  });

  describe("list_bgp_peers input schema", () => {
    it("accepts minimal input", () => {
      expect(() => listBgpSchema.parse({ routerId: "r" })).not.toThrow();
    });

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

  describe("list_ospf_neighbors input schema", () => {
    it("accepts minimal input", () => {
      expect(() => listOspfSchema.parse({ routerId: "r" })).not.toThrow();
    });

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

  describe("list_bgp_peers handler", () => {
    const sessions = [
      { ".id": "*1", "remote.as": "65001", "remote.address": "10.0.0.1", state: "established", uptime: "1d2h" },
      { ".id": "*2", "remote.as": "65002", "remote.address": "10.0.0.2", state: "active", uptime: "0s" },
    ];

    it("returns all sessions with correct total", async () => {
      const ctx = makeContext(sessions);
      const result = await listBgpPeersTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(2);
    });

    it("filters by state", async () => {
      const ctx = makeContext(sessions);
      const result = await listBgpPeersTool.handler({ routerId: "test-router", state: "established" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(1);
      expect((sc.sessions as Record<string, unknown>[])[0]["remote.as"]).toBe("65001");
    });

    it("calls get with correct ROS 7 path", async () => {
      const ctx = makeContext([]);
      await listBgpPeersTool.handler({ routerId: "test-router" }, ctx);
      const mockGet = (ctx.routerClient as Record<string, unknown>).get as ReturnType<typeof vi.fn>;
      expect(mockGet).toHaveBeenCalledWith("routing/bgp/session", expect.any(Object));
    });
  });

  describe("list_ospf_neighbors handler", () => {
    const neighbors = [
      { ".id": "*1", "neighbor-id": "1.1.1.1", interface: "ether1", state: "full", uptime: "5h" },
      { ".id": "*2", "neighbor-id": "2.2.2.2", interface: "ether2", state: "2-way", uptime: "1h" },
    ];

    it("returns all neighbors with correct total", async () => {
      const ctx = makeContext(neighbors);
      const result = await listOspfNeighborsTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(2);
    });

    it("filters by state", async () => {
      const ctx = makeContext(neighbors);
      const result = await listOspfNeighborsTool.handler({ routerId: "test-router", state: "full" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.total).toBe(1);
    });

    it("calls get with correct ROS 7 path", async () => {
      const ctx = makeContext([]);
      await listOspfNeighborsTool.handler({ routerId: "test-router" }, ctx);
      const mockGet = (ctx.routerClient as Record<string, unknown>).get as ReturnType<typeof vi.fn>;
      expect(mockGet).toHaveBeenCalledWith("routing/ospf/neighbor", expect.any(Object));
    });
  });
});
```

- [ ] **Step 2: Run tests to confirm they fail**

```bash
npm test -- --reporter=verbose test/unit/domain/routing-protocol-tools.test.ts
```

Expected: FAIL — `Cannot find module '../../../src/domain/tools/routing-protocol-tools.js'`

---

## Task 8: Routing protocol tools — implementation

**Files:**
- Create: `src/domain/tools/routing-protocol-tools.ts`

- [ ] **Step 1: Write the implementation**

```typescript
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 } from "../errors/error-types.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("routing-protocol-tools");

const listBgpPeersInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  state: z.string().optional().describe("Filter by session state (e.g. established, active, idle)"),
}).strict();

const listBgpPeersTool: ToolDefinition = {
  name: "list_bgp_peers",
  title: "List BGP Peers",
  description:
    "List BGP sessions on a MikroTik router (RouterOS 7+). Returns state, remote AS, prefix counts, and uptime.",
  inputSchema: listBgpPeersInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listBgpPeersInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing BGP sessions");

    try {
      let sessions = await context.routerClient.get<RouterOSRecord>("routing/bgp/session", {
        limit: undefined,
        offset: undefined,
      });

      if (parsed.state !== undefined) {
        sessions = sessions.filter(
          (s) => (s as Record<string, string>).state === parsed.state,
        );
      }

      return {
        content: `BGP sessions on ${context.routerId}: ${sessions.length} session(s).`,
        structuredContent: { routerId: context.routerId, sessions, total: sessions.length },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "list_bgp_peers" });
    }
  },
};

const listOspfNeighborsInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  state: z.string().optional().describe("Filter by neighbor state (e.g. full, 2-way, init)"),
}).strict();

const listOspfNeighborsTool: ToolDefinition = {
  name: "list_ospf_neighbors",
  title: "List OSPF Neighbors",
  description:
    "List OSPF neighbors on a MikroTik router (RouterOS 7+). Returns neighbor state, interface, DR/BDR, and uptime.",
  inputSchema: listOspfNeighborsInputSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listOspfNeighborsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing OSPF neighbors");

    try {
      let neighbors = await context.routerClient.get<RouterOSRecord>("routing/ospf/neighbor", {
        limit: undefined,
        offset: undefined,
      });

      if (parsed.state !== undefined) {
        neighbors = neighbors.filter(
          (n) => (n as Record<string, string>).state === parsed.state,
        );
      }

      return {
        content: `OSPF neighbors on ${context.routerId}: ${neighbors.length} neighbor(s).`,
        structuredContent: { routerId: context.routerId, neighbors, total: neighbors.length },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "list_ospf_neighbors" });
    }
  },
};

export const routingProtocolTools: ToolDefinition[] = [listBgpPeersTool, listOspfNeighborsTool];
```

- [ ] **Step 2: Run tests and confirm they pass**

```bash
npm test -- --reporter=verbose test/unit/domain/routing-protocol-tools.test.ts
```

Expected: All tests PASS

- [ ] **Step 3: Run typecheck**

```bash
npm run typecheck
```

Expected: No errors

- [ ] **Step 4: Commit**

```bash
git add src/domain/tools/routing-protocol-tools.ts test/unit/domain/routing-protocol-tools.test.ts
git commit -m "feat: add routing protocol tools (list_bgp_peers, list_ospf_neighbors)"
```

---

## Task 9: Wire into index.ts and run full test suite

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

- [ ] **Step 1: Add four imports and spreads to index.ts**

In `src/domain/tools/index.ts`, add after the existing imports:

```typescript
import { mangleTools } from "./mangle-tools.js";
import { addressListTools } from "./address-list-tools.js";
import { policyRoutingTools } from "./policy-routing-tools.js";
import { routingProtocolTools } from "./routing-protocol-tools.js";
```

And add four spreads to the `allTools` array after `...dnsTools`:

```typescript
export const allTools: ToolDefinition[] = [
  ...systemTools,
  ...interfaceTools,
  ...ipTools,
  ...dhcpTools,
  ...routeTools,
  ...firewallTools,
  ...diagnosticTools,
  ...systemOpsTools,
  ...bridgeTools,
  ...wifiTools,
  ...wireguardTools,
  ...dnsTools,
  ...mangleTools,
  ...addressListTools,
  ...policyRoutingTools,
  ...routingProtocolTools,
];
```

- [ ] **Step 2: Run the full test suite**

```bash
npm test
```

Expected: All tests PASS (no regressions)

- [ ] **Step 3: Run typecheck**

```bash
npm run typecheck
```

Expected: No errors

- [ ] **Step 4: Commit**

```bash
git add src/domain/tools/index.ts
git commit -m "feat: register v0.5a tools in allTools index"
```

---

## Task 10: Documentation updates

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

- [ ] **Step 1: Update ROADMAP.md**

In `ROADMAP.md`, change the v0.5 heading and bullet status. Replace:

```markdown
## 🔜 v0.5 — Advanced Firewall, Policy Routing & Security Hardening
```

with:

```markdown
## 🔄 v0.5 — Advanced Firewall, Policy Routing & Security Hardening
```

Mark completed bullets under v0.5 with ✅. The mangle, address list, policy routing, and routing protocol items are done. Security hardening items remain 🔜.

Replace the v0.5 section bullet list:

```markdown
- **Firewall Mangle:**
  - `list_mangle_rules` — mangle rules in evaluation order
  - `manage_mangle_rule` — add/remove/disable/enable mangle rules (comment as idempotency key)
- **Firewall Address Lists:**
  - `list_address_list_entries` — entries across all address lists (`/ip/firewall/address-list`)
  - `manage_address_list_entry` — add/remove entries (idempotent by list+address)
- **Policy Routing:**
  - `list_routing_rules` — routing rules (`/routing/rule`)
  - `manage_routing_rule` — add/remove routing rules
  - `list_routing_tables` — custom routing tables (`/routing/table`)
  - `manage_routing_table` — create/remove routing tables
- **Routing Protocols (read-only first):**
  - `list_bgp_peers` — BGP sessions with state, prefix counts, uptime
  - `list_ospf_neighbors` — OSPF neighbor state and adjacency info
- **Security hardening:**
```

with:

```markdown
- ✅ **Firewall Mangle:**
  - `list_mangle_rules` — mangle rules in evaluation order
  - `manage_mangle_rule` — add/remove/disable/enable mangle rules (comment as idempotency key)
- ✅ **Firewall Address Lists:**
  - `list_address_list_entries` — entries across all address lists (`/ip/firewall/address-list`)
  - `manage_address_list_entry` — add/remove entries (idempotent by list+address)
- ✅ **Policy Routing:**
  - `list_routing_rules` — routing rules (`/routing/rule`)
  - `manage_routing_rule` — add/remove/enable/disable routing rules (composite key)
  - `list_routing_tables` — custom routing tables (`/routing/table`)
  - `manage_routing_table` — create/remove routing tables
- ✅ **Routing Protocols (read-only first):**
  - `list_bgp_peers` — BGP sessions with state, prefix counts, uptime (RouterOS 7+)
  - `list_ospf_neighbors` — OSPF neighbor state and adjacency info (RouterOS 7+)
- 🔜 **Security hardening (v0.5b):**
```

- [ ] **Step 2: Update README.md tool count**

Find the tool count number in README.md (shown in the mermaid diagram and the Available Tools table header) and increment it by 10. Also add rows to the Available Tools table for all 10 new tools following the existing row format.

- [ ] **Step 3: Run full test suite one final time**

```bash
npm test && npm run typecheck
```

Expected: All PASS

- [ ] **Step 4: Commit**

```bash
git add ROADMAP.md README.md
git commit -m "docs: update roadmap and README for v0.5a (10 new tools)"
```
