# v0.9 Fleet Operations & Remaining RouterOS Surface — 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:** Expand MikroMCP to the remaining RouterOS management surfaces (IPSec, certificates, users, DHCP servers/pools, queues, VRRP, SNMP, Netwatch, NTP, neighbor discovery, ARP) and add two architectural tools: `bulk_execute` for fan-out across multiple routers, and `check_router_health` for reachability probing.

**Architecture:** Each RouterOS surface follows the established tool pattern (Zod schema, idempotency check, dry-run, enrichError). `bulk_execute` needs per-router REST clients; to supply these, `ToolContext` gains two optional fields (`connectionPool`, `routerRegistry`) populated by `tool-registry.ts`. `check_router_health` probes the router via the existing `routerClient`. Docs are updated in the final task.

**Tech Stack:** Node.js 22, TypeScript strict ESM, Zod, Vitest. No new runtime dependencies.

**Current tool count (after v0.8):** 54. **After v0.9:** 79 (+25 tools).

---

## File Map

### New files
| Path | Purpose |
|---|---|
| `src/domain/tools/ipsec-tools.ts` | `list_ipsec_peers`, `list_ipsec_policies`, `manage_ipsec_peer` |
| `src/domain/tools/certificate-tools.ts` | `list_certificates`, `manage_certificate` |
| `src/domain/tools/user-tools.ts` | `list_users`, `manage_user` |
| `src/domain/tools/dhcp-server-tools.ts` | `list_dhcp_servers`, `manage_dhcp_server`, `list_ip_pools`, `manage_ip_pool` |
| `src/domain/tools/queue-tools.ts` | `list_queues`, `manage_queue` |
| `src/domain/tools/vrrp-tools.ts` | `list_vrrp_instances`, `manage_vrrp_instance` |
| `src/domain/tools/snmp-tools.ts` | `get_snmp_settings`, `manage_snmp_community` |
| `src/domain/tools/netwatch-tools.ts` | `list_netwatch_entries`, `manage_netwatch_entry` |
| `src/domain/tools/ntp-tools.ts` | `get_ntp_settings`, `manage_ntp_settings` |
| `src/domain/tools/neighbor-tools.ts` | `list_neighbors`, `list_arp_entries` |
| `src/domain/tools/bulk-tools.ts` | `bulk_execute` (factory: receives baseTools) |
| `src/domain/tools/health-tools.ts` | `check_router_health` |
| `test/unit/domain/tools/ipsec-tools.test.ts` | Tests for IPSec tools |
| `test/unit/domain/tools/certificate-tools.test.ts` | Tests for certificate tools |
| `test/unit/domain/tools/user-tools.test.ts` | Tests for user tools |
| `test/unit/domain/tools/dhcp-server-tools.test.ts` | Tests for DHCP server/pool tools |
| `test/unit/domain/tools/queue-tools.test.ts` | Tests for queue tools |
| `test/unit/domain/tools/vrrp-tools.test.ts` | Tests for VRRP tools |
| `test/unit/domain/tools/snmp-tools.test.ts` | Tests for SNMP tools |
| `test/unit/domain/tools/netwatch-tools.test.ts` | Tests for Netwatch tools |
| `test/unit/domain/tools/ntp-tools.test.ts` | Tests for NTP tools |
| `test/unit/domain/tools/neighbor-tools.test.ts` | Tests for neighbor/ARP tools |
| `test/unit/domain/tools/bulk-tools.test.ts` | Tests for bulk_execute |
| `test/unit/domain/tools/health-tools.test.ts` | Tests for check_router_health |

### Modified files
| Path | Change |
|---|---|
| `src/domain/tools/tool-definition.ts` | Add `connectionPool?` and `routerRegistry?` to `ToolContext` |
| `src/domain/tools/index.ts` | Add all new tool arrays; wire `createBulkTools(baseTools)` |
| `src/mcp/tool-registry.ts` | Pass `connectionPool` and `routerRegistry` in every `ToolContext` |
| `ROADMAP.md` | Flip `🔜 v0.9` → `✅ v0.9` |
| `README.md` | Tool count 54 → 79; add new rows to Available Tools table |

---

## Task 1: IPSec tools

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

RouterOS paths: `ip/ipsec/peer`, `ip/ipsec/policy`. Idempotency for `manage_ipsec_peer` is by `name`.

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

Create `test/unit/domain/tools/ipsec-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { ipsecTools } from "../../../src/domain/tools/ipsec-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const PEER = { ".id": "*1", name: "vpn-peer-1", address: "203.0.113.1", profile: "default", "exchange-mode": "ike2" };
const POLICY = { ".id": "*1", src: "10.0.0.0/8", dst: "192.168.0.0/16", "sa-src-address": "10.1.1.1", tunnel: "true" };

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("ipsecTools metadata", () => {
  it("exports 3 tools", () => {
    expect(ipsecTools).toHaveLength(3);
  });

  it("has correct names", () => {
    const names = ipsecTools.map((t) => t.name);
    expect(names).toContain("list_ipsec_peers");
    expect(names).toContain("list_ipsec_policies");
    expect(names).toContain("manage_ipsec_peer");
  });

  it("list tools are readOnly", () => {
    const listPeers = ipsecTools.find((t) => t.name === "list_ipsec_peers")!;
    const listPolicies = ipsecTools.find((t) => t.name === "list_ipsec_policies")!;
    expect(listPeers.annotations.readOnlyHint).toBe(true);
    expect(listPolicies.annotations.readOnlyHint).toBe(true);
  });

  it("manage_ipsec_peer is not readOnly and has snapshotPaths", () => {
    const tool = ipsecTools.find((t) => t.name === "manage_ipsec_peer")!;
    expect(tool.annotations.readOnlyHint).toBe(false);
    expect(tool.snapshotPaths).toContain("ip/ipsec/peer");
  });
});

describe("list_ipsec_peers input schema", () => {
  const tool = () => ipsecTools.find((t) => t.name === "list_ipsec_peers")!;

  it("accepts valid input", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1" })).not.toThrow();
  });

  it("rejects extra fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("list_ipsec_peers handler", () => {
  const tool = () => ipsecTools.find((t) => t.name === "list_ipsec_peers")!;

  it("fetches peers and returns them", async () => {
    const ctx = makeContext([PEER]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/ipsec/peer", expect.any(Object));
    expect(result.content).toContain("1");
    const structured = result.structuredContent as Record<string, unknown>;
    expect((structured.peers as unknown[]).length).toBe(1);
  });
});

describe("list_ipsec_policies handler", () => {
  const tool = () => ipsecTools.find((t) => t.name === "list_ipsec_policies")!;

  it("fetches policies", async () => {
    const ctx = makeContext([POLICY]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/ipsec/policy", expect.any(Object));
    expect(result.content).toContain("1");
  });
});

describe("manage_ipsec_peer input schema", () => {
  const tool = () => ipsecTools.find((t) => t.name === "manage_ipsec_peer")!;

  it("accepts valid add params", () => {
    expect(() =>
      tool().inputSchema.parse({ routerId: "r1", action: "add", name: "p1", address: "1.2.3.4" }),
    ).not.toThrow();
  });

  it("accepts remove action without address", () => {
    expect(() =>
      tool().inputSchema.parse({ routerId: "r1", action: "remove", name: "p1" }),
    ).not.toThrow();
  });

  it("rejects extra fields", () => {
    expect(() =>
      tool().inputSchema.parse({ routerId: "r1", action: "add", name: "p1", address: "1.2.3.4", extra: true }),
    ).toThrow();
  });
});

describe("manage_ipsec_peer handler", () => {
  const tool = () => ipsecTools.find((t) => t.name === "manage_ipsec_peer")!;

  it("returns already_exists when peer with same name and address exists", async () => {
    const ctx = makeContext([PEER]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "vpn-peer-1", address: "203.0.113.1" },
      ctx,
    );
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.action).toBe("already_exists");
  });

  it("creates peer when not found", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "new-peer", address: "198.51.100.1" },
      ctx,
    );
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "ip/ipsec/peer",
      expect.objectContaining({ name: "new-peer", address: "198.51.100.1" }),
    );
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.action).toBe("created");
  });

  it("returns dry_run without creating", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "new-peer", address: "198.51.100.1", dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.action).toBe("dry_run");
  });

  it("removes peer by name", async () => {
    const ctx = makeContext([PEER]);
    const result = await tool().handler(
      { routerId: "test-router", action: "remove", name: "vpn-peer-1" },
      ctx,
    );
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("ip/ipsec/peer", "*1");
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.action).toBe("removed");
  });

  it("returns not_found when removing non-existent peer", async () => {
    const ctx = makeContext([]);
    await expect(
      tool().handler({ routerId: "test-router", action: "remove", name: "ghost" }, ctx),
    ).rejects.toMatchObject({ code: "IPSEC_PEER_NOT_FOUND" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/ipsec-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement ipsec-tools.ts**

Create `src/domain/tools/ipsec-tools.ts`:

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

const listIpsecPeersSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    nameFilter: z.string().optional().describe("Filter peers by name (substring match)"),
  })
  .strict();

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

const manageIpsecPeerSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("Unique name for the IPSec peer"),
    address: z.string().optional().describe("Remote peer IP address (required for add)"),
    profile: z.string().default("default").describe("IPSec profile to use"),
    exchangeMode: z
      .enum(["main", "aggressive", "ike2"])
      .default("ike2")
      .describe("IKE exchange mode"),
    localAddress: z.string().optional().describe("Local address for IKE negotiation"),
    dryRun: z.boolean().default(false).describe("Preview the operation without applying changes"),
  })
  .strict();

const listIpsecPeersTool: ToolDefinition = {
  name: "list_ipsec_peers",
  title: "List IPSec Peers",
  description: "List IPSec IKE peers configured on the router (`/ip/ipsec/peer`). Requires RouterOS 7.",
  inputSchema: listIpsecPeersSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listIpsecPeersSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing IPSec peers");
    try {
      const peers = await context.routerClient.get<RouterOSRecord>("ip/ipsec/peer", {});
      const filtered = parsed.nameFilter
        ? peers.filter((p) => p.name?.includes(parsed.nameFilter!))
        : peers;
      return {
        content: `Found ${filtered.length} IPSec peer(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, peers: filtered, total: filtered.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_ipsec_peers" });
    }
  },
};

const listIpsecPoliciesTool: ToolDefinition = {
  name: "list_ipsec_policies",
  title: "List IPSec Policies",
  description: "List IPSec policies configured on the router (`/ip/ipsec/policy`). Requires RouterOS 7.",
  inputSchema: listIpsecPoliciesSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listIpsecPoliciesSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing IPSec policies");
    try {
      const policies = await context.routerClient.get<RouterOSRecord>("ip/ipsec/policy", {});
      return {
        content: `Found ${policies.length} IPSec polic(ies) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, policies, total: policies.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_ipsec_policies" });
    }
  },
};

const manageIpsecPeerTool: ToolDefinition = {
  name: "manage_ipsec_peer",
  title: "Manage IPSec Peer",
  description:
    "Add or remove an IPSec IKE peer (`/ip/ipsec/peer`). Idempotent by name: adding a peer that already exists with the same address is a no-op. Supports dry-run. Requires RouterOS 7.",
  inputSchema: manageIpsecPeerSchema,
  snapshotPaths: ["ip/ipsec/peer"],
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageIpsecPeerSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing IPSec peer");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("ip/ipsec/peer", {
        filter: { name: parsed.name },
      });
      const peer = existing.find((p) => p.name === parsed.name);

      if (parsed.action === "add") {
        if (peer && peer.address === parsed.address) {
          return {
            content: `IPSec peer "${parsed.name}" already exists with address ${parsed.address}.`,
            structuredContent: { action: "already_exists", peer },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create IPSec peer "${parsed.name}" → ${parsed.address}.`,
            structuredContent: {
              action: "dry_run",
              diff: { name: parsed.name, address: parsed.address, profile: parsed.profile, "exchange-mode": parsed.exchangeMode },
            },
          };
        }
        const created = await context.routerClient.create("ip/ipsec/peer", {
          name: parsed.name,
          address: parsed.address ?? "",
          profile: parsed.profile,
          "exchange-mode": parsed.exchangeMode,
          ...(parsed.localAddress ? { "local-address": parsed.localAddress } : {}),
        });
        return {
          content: `Created IPSec peer "${parsed.name}" → ${parsed.address} on ${context.routerId}.`,
          structuredContent: { action: "created", peer: created },
        };
      }

      // action === "remove"
      if (!peer) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "IPSEC_PEER_NOT_FOUND",
          message: `IPSec peer "${parsed.name}" not found on router "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check the name with list_ipsec_peers." },
        });
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: would remove IPSec peer "${parsed.name}" (id ${peer[".id"]}).`,
          structuredContent: { action: "dry_run", peer },
        };
      }
      await context.routerClient.remove("ip/ipsec/peer", peer[".id"]);
      return {
        content: `Removed IPSec peer "${parsed.name}" from ${context.routerId}.`,
        structuredContent: { action: "removed", name: parsed.name, id: peer[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_ipsec_peer" });
    }
  },
};

export const ipsecTools: ToolDefinition[] = [listIpsecPeersTool, listIpsecPoliciesTool, manageIpsecPeerTool];
```

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

```bash
npm test -- test/unit/domain/tools/ipsec-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/ipsec-tools.ts test/unit/domain/tools/ipsec-tools.test.ts
git commit -m "feat(tools): add IPSec tools — list_ipsec_peers, list_ipsec_policies, manage_ipsec_peer"
```

---

## Task 2: Certificate tools

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

RouterOS path: `certificate`. `manage_certificate` supports generating a self-signed cert (via `certificate/add` + `certificate/sign`) or removing by name. Idempotency by name.

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

Create `test/unit/domain/tools/certificate-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { certificateTools } from "../../../src/domain/tools/certificate-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const CERT = {
  ".id": "*1",
  name: "my-ca",
  "common-name": "My CA",
  "key-size": "2048",
  "days-valid": "3650",
  "key-usage": "key-cert-sign,crl-sign",
  "trusted": "true",
  "invalid-after": "jan/01/2030 00:00:00",
  status: "KAT",
};

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
      execute: vi.fn().mockResolvedValue({}),
    } as unknown as RouterOSRestClient,
  };
}

describe("certificateTools metadata", () => {
  it("exports 2 tools", () => expect(certificateTools).toHaveLength(2));

  it("list_certificates is readOnly", () => {
    const t = certificateTools.find((t) => t.name === "list_certificates")!;
    expect(t.annotations.readOnlyHint).toBe(true);
  });

  it("manage_certificate is not readOnly and has snapshotPaths", () => {
    const t = certificateTools.find((t) => t.name === "manage_certificate")!;
    expect(t.annotations.readOnlyHint).toBe(false);
    expect(t.snapshotPaths).toContain("certificate");
  });
});

describe("list_certificates handler", () => {
  const tool = () => certificateTools.find((t) => t.name === "list_certificates")!;

  it("lists certificates", async () => {
    const ctx = makeContext([CERT]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("certificate", expect.any(Object));
    const structured = result.structuredContent as Record<string, unknown>;
    expect((structured.certificates as unknown[]).length).toBe(1);
  });

  it("rejects extra fields in schema", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("manage_certificate handler", () => {
  const tool = () => certificateTools.find((t) => t.name === "manage_certificate")!;

  it("returns already_exists when cert with same name exists", async () => {
    const ctx = makeContext([CERT]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "my-ca", commonName: "My CA" },
      ctx,
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates and signs cert on add", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "new-cert", commonName: "New Cert" },
      ctx,
    );
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "certificate",
      expect.objectContaining({ name: "new-cert", "common-name": "New Cert" }),
    );
    expect(ctx.routerClient.execute).toHaveBeenCalledWith(
      "certificate/sign",
      expect.objectContaining({ "ca-crl-host": "" }),
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
  });

  it("dry_run skips creation", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "new-cert", commonName: "New Cert", dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("removes cert by name", async () => {
    const ctx = makeContext([CERT]);
    const result = await tool().handler(
      { routerId: "test-router", action: "remove", name: "my-ca" },
      ctx,
    );
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("certificate", "*1");
    expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
  });

  it("throws NOT_FOUND on remove of missing cert", async () => {
    const ctx = makeContext([]);
    await expect(
      tool().handler({ routerId: "test-router", action: "remove", name: "ghost" }, ctx),
    ).rejects.toMatchObject({ code: "CERTIFICATE_NOT_FOUND" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/certificate-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement certificate-tools.ts**

Create `src/domain/tools/certificate-tools.ts`:

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

const listCertificatesSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    nameFilter: z.string().optional().describe("Filter by name (substring match)"),
    expiredOnly: z.boolean().default(false).describe("Return only expired certificates"),
  })
  .strict();

const manageCertificateSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("Certificate name (must be unique on the router)"),
    commonName: z.string().optional().describe("Common name (CN) for a new self-signed certificate"),
    keySize: z.enum(["1024", "2048", "4096"]).default("2048").describe("RSA key size"),
    daysValid: z.number().int().min(1).max(36500).default(3650).describe("Validity period in days"),
    dryRun: z.boolean().default(false).describe("Preview without applying changes"),
  })
  .strict();

const listCertificatesTool: ToolDefinition = {
  name: "list_certificates",
  title: "List Certificates",
  description: "List certificates on the router (`/certificate`). Shows name, common-name, expiry, and trust status.",
  inputSchema: listCertificatesSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listCertificatesSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing certificates");
    try {
      let certs = await context.routerClient.get<RouterOSRecord>("certificate", {});
      if (parsed.nameFilter) certs = certs.filter((c) => c.name?.includes(parsed.nameFilter!));
      if (parsed.expiredOnly) certs = certs.filter((c) => c.status === "I");
      return {
        content: `Found ${certs.length} certificate(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, certificates: certs, total: certs.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_certificates" });
    }
  },
};

const manageCertificateTool: ToolDefinition = {
  name: "manage_certificate",
  title: "Manage Certificate",
  description:
    "Add (generate self-signed) or remove a certificate on the router. Idempotent by name: adding a cert with the same name is a no-op. Supports dry-run.",
  inputSchema: manageCertificateSchema,
  snapshotPaths: ["certificate"],
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageCertificateSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing certificate");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("certificate", {
        filter: { name: parsed.name },
      });
      const cert = existing.find((c) => c.name === parsed.name);

      if (parsed.action === "add") {
        if (cert) {
          return {
            content: `Certificate "${parsed.name}" already exists on ${context.routerId}.`,
            structuredContent: { action: "already_exists", certificate: cert },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would generate self-signed certificate "${parsed.name}" (CN=${parsed.commonName ?? parsed.name}, ${parsed.keySize}-bit, ${parsed.daysValid} days).`,
            structuredContent: {
              action: "dry_run",
              diff: { name: parsed.name, commonName: parsed.commonName ?? parsed.name, keySize: parsed.keySize, daysValid: parsed.daysValid },
            },
          };
        }
        const created = await context.routerClient.create("certificate", {
          name: parsed.name,
          "common-name": parsed.commonName ?? parsed.name,
          "key-size": parsed.keySize,
          "days-valid": String(parsed.daysValid),
        });
        await context.routerClient.execute("certificate/sign", {
          numbers: parsed.name,
          "ca-crl-host": "",
        });
        return {
          content: `Generated self-signed certificate "${parsed.name}" on ${context.routerId}.`,
          structuredContent: { action: "created", certificate: created },
        };
      }

      // action === "remove"
      if (!cert) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "CERTIFICATE_NOT_FOUND",
          message: `Certificate "${parsed.name}" not found on router "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check the name with list_certificates." },
        });
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: would remove certificate "${parsed.name}" (id ${cert[".id"]}).`,
          structuredContent: { action: "dry_run", certificate: cert },
        };
      }
      await context.routerClient.remove("certificate", cert[".id"]);
      return {
        content: `Removed certificate "${parsed.name}" from ${context.routerId}.`,
        structuredContent: { action: "removed", name: parsed.name, id: cert[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_certificate" });
    }
  },
};

export const certificateTools: ToolDefinition[] = [listCertificatesTool, manageCertificateTool];
```

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

```bash
npm test -- test/unit/domain/tools/certificate-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/certificate-tools.ts test/unit/domain/tools/certificate-tools.test.ts
git commit -m "feat(tools): add certificate tools — list_certificates, manage_certificate"
```

---

## Task 3: User tools

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

RouterOS path: `user`. Idempotency by `name`. `manage_user` is marked `destructiveHint: true` because adding/changing users affects authentication.

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

Create `test/unit/domain/tools/user-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { userTools } from "../../../src/domain/tools/user-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const USER = { ".id": "*1", name: "netops", group: "full", address: "10.0.0.0/8", disabled: "false" };

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("userTools metadata", () => {
  it("exports 2 tools", () => expect(userTools).toHaveLength(2));

  it("list_users is readOnly", () => {
    expect(userTools.find((t) => t.name === "list_users")!.annotations.readOnlyHint).toBe(true);
  });

  it("manage_user is destructive and not readOnly", () => {
    const t = userTools.find((t) => t.name === "manage_user")!;
    expect(t.annotations.readOnlyHint).toBe(false);
    expect(t.annotations.destructiveHint).toBe(true);
    expect(t.snapshotPaths).toContain("user");
  });
});

describe("list_users handler", () => {
  const tool = () => userTools.find((t) => t.name === "list_users")!;

  it("lists users", async () => {
    const ctx = makeContext([USER]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("user", expect.any(Object));
    const structured = result.structuredContent as Record<string, unknown>;
    expect((structured.users as unknown[]).length).toBe(1);
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("manage_user handler", () => {
  const tool = () => userTools.find((t) => t.name === "manage_user")!;

  it("returns already_exists when user with same name and group exists", async () => {
    const ctx = makeContext([USER]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "netops", group: "full" },
      ctx,
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates user when not found", async () => {
    const ctx = makeContext([]);
    await tool().handler({ routerId: "test-router", action: "add", name: "newuser", group: "read", password: "s3cr3t" }, ctx);
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "user",
      expect.objectContaining({ name: "newuser", group: "read" }),
    );
  });

  it("dry_run skips creation", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "newuser", group: "read", dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("removes user by name", async () => {
    const ctx = makeContext([USER]);
    await tool().handler({ routerId: "test-router", action: "remove", name: "netops" }, ctx);
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("user", "*1");
  });

  it("throws NOT_FOUND on remove of missing user", async () => {
    const ctx = makeContext([]);
    await expect(
      tool().handler({ routerId: "test-router", action: "remove", name: "ghost" }, ctx),
    ).rejects.toMatchObject({ code: "USER_NOT_FOUND" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/user-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement user-tools.ts**

Create `src/domain/tools/user-tools.ts`:

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

const listUsersSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    group: z.string().optional().describe("Filter by group name"),
    includeDisabled: z.boolean().default(false).describe("Include disabled user accounts"),
  })
  .strict();

const manageUserSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("Router OS username"),
    group: z.string().optional().describe("User group (e.g. full, read, write) — required for add"),
    password: z.string().optional().describe("Password for the new user"),
    allowedAddress: z.string().optional().describe("IP/CIDR allowed to log in from (empty = any)"),
    dryRun: z.boolean().default(false).describe("Preview without applying changes"),
  })
  .strict();

const listUsersTool: ToolDefinition = {
  name: "list_users",
  title: "List Users",
  description: "List local user accounts on the router (`/user`). Shows name, group, allowed address, and disabled status.",
  inputSchema: listUsersSchema,
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listUsersSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing users");
    try {
      let users = await context.routerClient.get<RouterOSRecord>("user", {});
      if (parsed.group) users = users.filter((u) => u.group === parsed.group);
      if (!parsed.includeDisabled) users = users.filter((u) => u.disabled !== "true");
      return {
        content: `Found ${users.length} user(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, users, total: users.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_users" });
    }
  },
};

const manageUserTool: ToolDefinition = {
  name: "manage_user",
  title: "Manage User",
  description:
    "Add or remove a local user account on the router (`/user`). Idempotent by name: adding a user that already exists in the same group is a no-op. Marked destructive — changes authentication configuration. Supports dry-run.",
  inputSchema: manageUserSchema,
  snapshotPaths: ["user"],
  annotations: {
    readOnlyHint: false,
    destructiveHint: true,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageUserSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing user");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("user", {
        filter: { name: parsed.name },
      });
      const user = existing.find((u) => u.name === parsed.name);

      if (parsed.action === "add") {
        if (user && user.group === parsed.group) {
          return {
            content: `User "${parsed.name}" already exists in group "${parsed.group}" on ${context.routerId}.`,
            structuredContent: { action: "already_exists", user },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create user "${parsed.name}" in group "${parsed.group ?? "read"}".`,
            structuredContent: { action: "dry_run", diff: { name: parsed.name, group: parsed.group ?? "read" } },
          };
        }
        const created = await context.routerClient.create("user", {
          name: parsed.name,
          group: parsed.group ?? "read",
          ...(parsed.password ? { password: parsed.password } : {}),
          ...(parsed.allowedAddress ? { address: parsed.allowedAddress } : {}),
        });
        return {
          content: `Created user "${parsed.name}" in group "${parsed.group ?? "read"}" on ${context.routerId}.`,
          structuredContent: { action: "created", user: created },
        };
      }

      // action === "remove"
      if (!user) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "USER_NOT_FOUND",
          message: `User "${parsed.name}" not found on router "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check the name with list_users." },
        });
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: would remove user "${parsed.name}" (id ${user[".id"]}).`,
          structuredContent: { action: "dry_run", user },
        };
      }
      await context.routerClient.remove("user", user[".id"]);
      return {
        content: `Removed user "${parsed.name}" from ${context.routerId}.`,
        structuredContent: { action: "removed", name: parsed.name, id: user[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_user" });
    }
  },
};

export const userTools: ToolDefinition[] = [listUsersTool, manageUserTool];
```

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

```bash
npm test -- test/unit/domain/tools/user-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/user-tools.ts test/unit/domain/tools/user-tools.test.ts
git commit -m "feat(tools): add user tools — list_users, manage_user"
```

---

## Task 4: DHCP server and IP pool tools

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

RouterOS paths: `ip/dhcp-server` (servers), `ip/pool` (address pools). Note: `dhcp-tools.ts` already handles DHCP leases — this task covers server configuration and pools. Idempotency by `name`.

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

Create `test/unit/domain/tools/dhcp-server-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { dhcpServerTools } from "../../../src/domain/tools/dhcp-server-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const SERVER = { ".id": "*1", name: "dhcp1", interface: "bridge1", "address-pool": "pool1", "lease-time": "10m", disabled: "false" };
const POOL = { ".id": "*1", name: "pool1", ranges: "192.168.1.100-192.168.1.200" };

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("dhcpServerTools metadata", () => {
  it("exports 4 tools", () => expect(dhcpServerTools).toHaveLength(4));

  it("has correct names", () => {
    const names = dhcpServerTools.map((t) => t.name);
    expect(names).toContain("list_dhcp_servers");
    expect(names).toContain("manage_dhcp_server");
    expect(names).toContain("list_ip_pools");
    expect(names).toContain("manage_ip_pool");
  });

  it("list tools are readOnly", () => {
    expect(dhcpServerTools.find((t) => t.name === "list_dhcp_servers")!.annotations.readOnlyHint).toBe(true);
    expect(dhcpServerTools.find((t) => t.name === "list_ip_pools")!.annotations.readOnlyHint).toBe(true);
  });

  it("manage tools have snapshotPaths", () => {
    expect(dhcpServerTools.find((t) => t.name === "manage_dhcp_server")!.snapshotPaths).toContain("ip/dhcp-server");
    expect(dhcpServerTools.find((t) => t.name === "manage_ip_pool")!.snapshotPaths).toContain("ip/pool");
  });
});

describe("list_dhcp_servers handler", () => {
  const tool = () => dhcpServerTools.find((t) => t.name === "list_dhcp_servers")!;

  it("fetches servers", async () => {
    const ctx = makeContext([SERVER]);
    await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/dhcp-server", expect.any(Object));
  });
});

describe("manage_dhcp_server handler", () => {
  const tool = () => dhcpServerTools.find((t) => t.name === "manage_dhcp_server")!;

  it("returns already_exists for same server", async () => {
    const ctx = makeContext([SERVER]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "dhcp1", interface: "bridge1", addressPool: "pool1" },
      ctx,
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates server", async () => {
    const ctx = makeContext([]);
    await tool().handler(
      { routerId: "test-router", action: "add", name: "dhcp2", interface: "bridge2", addressPool: "pool2" },
      ctx,
    );
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "ip/dhcp-server",
      expect.objectContaining({ name: "dhcp2", interface: "bridge2", "address-pool": "pool2" }),
    );
  });

  it("dry_run skips create", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "dhcp2", interface: "bridge2", addressPool: "pool2", dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });
});

describe("list_ip_pools handler", () => {
  const tool = () => dhcpServerTools.find((t) => t.name === "list_ip_pools")!;

  it("fetches pools", async () => {
    const ctx = makeContext([POOL]);
    await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/pool", expect.any(Object));
  });
});

describe("manage_ip_pool handler", () => {
  const tool = () => dhcpServerTools.find((t) => t.name === "manage_ip_pool")!;

  it("creates pool", async () => {
    const ctx = makeContext([]);
    await tool().handler(
      { routerId: "test-router", action: "add", name: "pool2", ranges: "10.0.0.10-10.0.0.100" },
      ctx,
    );
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "ip/pool",
      expect.objectContaining({ name: "pool2", ranges: "10.0.0.10-10.0.0.100" }),
    );
  });

  it("throws NOT_FOUND on remove of missing pool", async () => {
    const ctx = makeContext([]);
    await expect(
      tool().handler({ routerId: "test-router", action: "remove", name: "ghost" }, ctx),
    ).rejects.toMatchObject({ code: "IP_POOL_NOT_FOUND" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/dhcp-server-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement dhcp-server-tools.ts**

Create `src/domain/tools/dhcp-server-tools.ts`:

```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("dhcp-server-tools");

const listDhcpServersSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    includeDisabled: z.boolean().default(false).describe("Include disabled DHCP servers"),
  })
  .strict();

const manageDhcpServerSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("DHCP server name"),
    interface: z.string().optional().describe("Interface to serve DHCP on (required for add)"),
    addressPool: z.string().optional().describe("IP pool name (required for add)"),
    leaseTime: z.string().default("10m").describe("Default lease time (e.g. 10m, 1h, 1d)"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const listIpPoolsSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    nameFilter: z.string().optional().describe("Filter by pool name (substring)"),
  })
  .strict();

const manageIpPoolSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("Pool name"),
    ranges: z.string().optional().describe("Address ranges, e.g. 192.168.1.100-192.168.1.200 (required for add)"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const listDhcpServersTool: ToolDefinition = {
  name: "list_dhcp_servers",
  title: "List DHCP Servers",
  description: "List DHCP server instances configured on the router (`/ip/dhcp-server`). Note: use `list_dhcp_leases` for active lease data.",
  inputSchema: listDhcpServersSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listDhcpServersSchema.parse(params);
    try {
      let servers = await context.routerClient.get<RouterOSRecord>("ip/dhcp-server", {});
      if (!parsed.includeDisabled) servers = servers.filter((s) => s.disabled !== "true");
      return {
        content: `Found ${servers.length} DHCP server(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, servers, total: servers.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_dhcp_servers" });
    }
  },
};

const manageDhcpServerTool: ToolDefinition = {
  name: "manage_dhcp_server",
  title: "Manage DHCP Server",
  description: "Add or remove a DHCP server instance (`/ip/dhcp-server`). Idempotent by name. Supports dry-run.",
  inputSchema: manageDhcpServerSchema,
  snapshotPaths: ["ip/dhcp-server"],
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageDhcpServerSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing DHCP server");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("ip/dhcp-server", { filter: { name: parsed.name } });
      const server = existing.find((s) => s.name === parsed.name);

      if (parsed.action === "add") {
        if (server && server.interface === parsed.interface && server["address-pool"] === parsed.addressPool) {
          return { content: `DHCP server "${parsed.name}" already exists.`, structuredContent: { action: "already_exists", server } };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create DHCP server "${parsed.name}" on interface "${parsed.interface}" using pool "${parsed.addressPool}".`,
            structuredContent: { action: "dry_run", diff: { name: parsed.name, interface: parsed.interface, addressPool: parsed.addressPool } },
          };
        }
        const created = await context.routerClient.create("ip/dhcp-server", {
          name: parsed.name,
          interface: parsed.interface ?? "",
          "address-pool": parsed.addressPool ?? "",
          "lease-time": parsed.leaseTime,
        });
        return { content: `Created DHCP server "${parsed.name}" on ${context.routerId}.`, structuredContent: { action: "created", server: created } };
      }

      if (!server) {
        throw new MikroMCPError({ category: ErrorCategory.NOT_FOUND, code: "DHCP_SERVER_NOT_FOUND",
          message: `DHCP server "${parsed.name}" not found on "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check name with list_dhcp_servers." } });
      }
      if (parsed.dryRun) {
        return { content: `Dry run: would remove DHCP server "${parsed.name}".`, structuredContent: { action: "dry_run", server } };
      }
      await context.routerClient.remove("ip/dhcp-server", server[".id"]);
      return { content: `Removed DHCP server "${parsed.name}" from ${context.routerId}.`, structuredContent: { action: "removed", name: parsed.name } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_dhcp_server" });
    }
  },
};

const listIpPoolsTool: ToolDefinition = {
  name: "list_ip_pools",
  title: "List IP Pools",
  description: "List IP address pools on the router (`/ip/pool`). Used by DHCP servers to assign addresses.",
  inputSchema: listIpPoolsSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listIpPoolsSchema.parse(params);
    try {
      let pools = await context.routerClient.get<RouterOSRecord>("ip/pool", {});
      if (parsed.nameFilter) pools = pools.filter((p) => p.name?.includes(parsed.nameFilter!));
      return {
        content: `Found ${pools.length} IP pool(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, pools, total: pools.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_ip_pools" });
    }
  },
};

const manageIpPoolTool: ToolDefinition = {
  name: "manage_ip_pool",
  title: "Manage IP Pool",
  description: "Add or remove an IP address pool (`/ip/pool`). Idempotent by name. Supports dry-run.",
  inputSchema: manageIpPoolSchema,
  snapshotPaths: ["ip/pool"],
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageIpPoolSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing IP pool");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("ip/pool", { filter: { name: parsed.name } });
      const pool = existing.find((p) => p.name === parsed.name);

      if (parsed.action === "add") {
        if (pool && pool.ranges === parsed.ranges) {
          return { content: `IP pool "${parsed.name}" already exists.`, structuredContent: { action: "already_exists", pool } };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create IP pool "${parsed.name}" with ranges ${parsed.ranges}.`,
            structuredContent: { action: "dry_run", diff: { name: parsed.name, ranges: parsed.ranges } },
          };
        }
        const created = await context.routerClient.create("ip/pool", { name: parsed.name, ranges: parsed.ranges ?? "" });
        return { content: `Created IP pool "${parsed.name}" on ${context.routerId}.`, structuredContent: { action: "created", pool: created } };
      }

      if (!pool) {
        throw new MikroMCPError({ category: ErrorCategory.NOT_FOUND, code: "IP_POOL_NOT_FOUND",
          message: `IP pool "${parsed.name}" not found on "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check name with list_ip_pools." } });
      }
      if (parsed.dryRun) {
        return { content: `Dry run: would remove IP pool "${parsed.name}".`, structuredContent: { action: "dry_run", pool } };
      }
      await context.routerClient.remove("ip/pool", pool[".id"]);
      return { content: `Removed IP pool "${parsed.name}" from ${context.routerId}.`, structuredContent: { action: "removed", name: parsed.name } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_ip_pool" });
    }
  },
};

export const dhcpServerTools: ToolDefinition[] = [
  listDhcpServersTool,
  manageDhcpServerTool,
  listIpPoolsTool,
  manageIpPoolTool,
];
```

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

```bash
npm test -- test/unit/domain/tools/dhcp-server-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/dhcp-server-tools.ts test/unit/domain/tools/dhcp-server-tools.test.ts
git commit -m "feat(tools): add DHCP server and IP pool tools — list_dhcp_servers, manage_dhcp_server, list_ip_pools, manage_ip_pool"
```

---

## Task 5: Queue tools

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

RouterOS path: `queue/simple`. Idempotency by `name`.

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

Create `test/unit/domain/tools/queue-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { queueTools } from "../../../src/domain/tools/queue-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const QUEUE = { ".id": "*1", name: "limit-lan", target: "192.168.1.0/24", "max-limit": "10M/10M", disabled: "false" };

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("queueTools metadata", () => {
  it("exports 2 tools", () => expect(queueTools).toHaveLength(2));
  it("list_queues is readOnly", () => expect(queueTools.find((t) => t.name === "list_queues")!.annotations.readOnlyHint).toBe(true));
  it("manage_queue has snapshotPaths", () => expect(queueTools.find((t) => t.name === "manage_queue")!.snapshotPaths).toContain("queue/simple"));
});

describe("list_queues handler", () => {
  const tool = () => queueTools.find((t) => t.name === "list_queues")!;

  it("fetches queues", async () => {
    const ctx = makeContext([QUEUE]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("queue/simple", expect.any(Object));
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("manage_queue handler", () => {
  const tool = () => queueTools.find((t) => t.name === "manage_queue")!;

  it("returns already_exists for same name+target", async () => {
    const ctx = makeContext([QUEUE]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "limit-lan", target: "192.168.1.0/24", maxLimit: "10M/10M" },
      ctx,
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates queue", async () => {
    const ctx = makeContext([]);
    await tool().handler({ routerId: "test-router", action: "add", name: "new-q", target: "10.0.0.0/8", maxLimit: "5M/5M" }, ctx);
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "queue/simple",
      expect.objectContaining({ name: "new-q", target: "10.0.0.0/8", "max-limit": "5M/5M" }),
    );
  });

  it("dry_run skips creation", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "new-q", target: "10.0.0.0/8", dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("removes queue", async () => {
    const ctx = makeContext([QUEUE]);
    await tool().handler({ routerId: "test-router", action: "remove", name: "limit-lan" }, ctx);
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("queue/simple", "*1");
  });

  it("throws NOT_FOUND on remove of missing queue", async () => {
    const ctx = makeContext([]);
    await expect(
      tool().handler({ routerId: "test-router", action: "remove", name: "ghost" }, ctx),
    ).rejects.toMatchObject({ code: "QUEUE_NOT_FOUND" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/queue-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement queue-tools.ts**

Create `src/domain/tools/queue-tools.ts`:

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

const listQueuesSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    targetFilter: z.string().optional().describe("Filter by target IP/CIDR (substring match)"),
    includeDisabled: z.boolean().default(false).describe("Include disabled queues"),
  })
  .strict();

const manageQueueSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("Queue name"),
    target: z.string().optional().describe("Target IP/CIDR or interface (required for add)"),
    maxLimit: z.string().default("0/0").describe("Max upload/download limit, e.g. 10M/10M (0/0 = unlimited)"),
    burstLimit: z.string().optional().describe("Burst limit, e.g. 20M/20M"),
    comment: z.string().optional().describe("Queue description"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const listQueuesTool: ToolDefinition = {
  name: "list_queues",
  title: "List Queues",
  description: "List simple queues on the router (`/queue/simple`). Shows name, target, max-limit, and disabled status.",
  inputSchema: listQueuesSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listQueuesSchema.parse(params);
    try {
      let queues = await context.routerClient.get<RouterOSRecord>("queue/simple", {});
      if (!parsed.includeDisabled) queues = queues.filter((q) => q.disabled !== "true");
      if (parsed.targetFilter) queues = queues.filter((q) => q.target?.includes(parsed.targetFilter!));
      return {
        content: `Found ${queues.length} queue(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, queues, total: queues.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_queues" });
    }
  },
};

const manageQueueTool: ToolDefinition = {
  name: "manage_queue",
  title: "Manage Queue",
  description: "Add or remove a simple queue (`/queue/simple`). Idempotent by name+target. Supports dry-run.",
  inputSchema: manageQueueSchema,
  snapshotPaths: ["queue/simple"],
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageQueueSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing queue");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("queue/simple", { filter: { name: parsed.name } });
      const queue = existing.find((q) => q.name === parsed.name);

      if (parsed.action === "add") {
        if (queue && queue.target === parsed.target && queue["max-limit"] === parsed.maxLimit) {
          return { content: `Queue "${parsed.name}" already exists.`, structuredContent: { action: "already_exists", queue } };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create queue "${parsed.name}" targeting ${parsed.target} at ${parsed.maxLimit}.`,
            structuredContent: { action: "dry_run", diff: { name: parsed.name, target: parsed.target, maxLimit: parsed.maxLimit } },
          };
        }
        const created = await context.routerClient.create("queue/simple", {
          name: parsed.name,
          target: parsed.target ?? "",
          "max-limit": parsed.maxLimit,
          ...(parsed.burstLimit ? { "burst-limit": parsed.burstLimit } : {}),
          ...(parsed.comment ? { comment: parsed.comment } : {}),
        });
        return { content: `Created queue "${parsed.name}" on ${context.routerId}.`, structuredContent: { action: "created", queue: created } };
      }

      if (!queue) {
        throw new MikroMCPError({ category: ErrorCategory.NOT_FOUND, code: "QUEUE_NOT_FOUND",
          message: `Queue "${parsed.name}" not found on "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check name with list_queues." } });
      }
      if (parsed.dryRun) {
        return { content: `Dry run: would remove queue "${parsed.name}".`, structuredContent: { action: "dry_run", queue } };
      }
      await context.routerClient.remove("queue/simple", queue[".id"]);
      return { content: `Removed queue "${parsed.name}" from ${context.routerId}.`, structuredContent: { action: "removed", name: parsed.name } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_queue" });
    }
  },
};

export const queueTools: ToolDefinition[] = [listQueuesTool, manageQueueTool];
```

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

```bash
npm test -- test/unit/domain/tools/queue-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/queue-tools.ts test/unit/domain/tools/queue-tools.test.ts
git commit -m "feat(tools): add queue tools — list_queues, manage_queue"
```

---

## Task 6: VRRP tools

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

RouterOS path: `interface/vrrp`. Idempotency by `name`.

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

Create `test/unit/domain/tools/vrrp-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { vrrpTools } from "../../../src/domain/tools/vrrp-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const VRRP = { ".id": "*1", name: "vrrp1", interface: "ether1", vrid: "1", priority: "100", "master-ip": "10.0.0.1", "backup-ip": "10.0.0.2", "running": "true" };

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("vrrpTools metadata", () => {
  it("exports 2 tools", () => expect(vrrpTools).toHaveLength(2));
  it("list_vrrp_instances is readOnly", () =>
    expect(vrrpTools.find((t) => t.name === "list_vrrp_instances")!.annotations.readOnlyHint).toBe(true));
  it("manage_vrrp_instance has snapshotPaths", () =>
    expect(vrrpTools.find((t) => t.name === "manage_vrrp_instance")!.snapshotPaths).toContain("interface/vrrp"));
});

describe("list_vrrp_instances handler", () => {
  const tool = () => vrrpTools.find((t) => t.name === "list_vrrp_instances")!;

  it("fetches VRRP instances", async () => {
    const ctx = makeContext([VRRP]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("interface/vrrp", expect.any(Object));
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("manage_vrrp_instance handler", () => {
  const tool = () => vrrpTools.find((t) => t.name === "manage_vrrp_instance")!;

  it("returns already_exists when same name+vrid+interface exists", async () => {
    const ctx = makeContext([VRRP]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "vrrp1", interface: "ether1", vrid: 1 },
      ctx,
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates VRRP instance", async () => {
    const ctx = makeContext([]);
    await tool().handler({ routerId: "test-router", action: "add", name: "vrrp2", interface: "ether2", vrid: 2 }, ctx);
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "interface/vrrp",
      expect.objectContaining({ name: "vrrp2", interface: "ether2", vrid: "2" }),
    );
  });

  it("dry_run skips creation", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "vrrp2", interface: "ether2", vrid: 2, dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("removes instance", async () => {
    const ctx = makeContext([VRRP]);
    await tool().handler({ routerId: "test-router", action: "remove", name: "vrrp1" }, ctx);
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("interface/vrrp", "*1");
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/vrrp-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement vrrp-tools.ts**

Create `src/domain/tools/vrrp-tools.ts`:

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

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

const manageVrrpSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("VRRP interface name"),
    interface: z.string().optional().describe("Physical interface (required for add)"),
    vrid: z.number().int().min(1).max(255).optional().describe("VRRP ID 1-255 (required for add)"),
    priority: z.number().int().min(1).max(254).default(100).describe("Router priority — higher = more likely master"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const listVrrpTool: ToolDefinition = {
  name: "list_vrrp_instances",
  title: "List VRRP Instances",
  description: "List VRRP interface instances on the router (`/interface/vrrp`). Shows VRID, priority, and running state.",
  inputSchema: listVrrpSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    listVrrpSchema.parse(params);
    try {
      const instances = await context.routerClient.get<RouterOSRecord>("interface/vrrp", {});
      return {
        content: `Found ${instances.length} VRRP instance(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, instances, total: instances.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_vrrp_instances" });
    }
  },
};

const manageVrrpTool: ToolDefinition = {
  name: "manage_vrrp_instance",
  title: "Manage VRRP Instance",
  description: "Add or remove a VRRP interface (`/interface/vrrp`). Idempotent by name+vrid+interface. Supports dry-run.",
  inputSchema: manageVrrpSchema,
  snapshotPaths: ["interface/vrrp"],
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageVrrpSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing VRRP instance");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("interface/vrrp", { filter: { name: parsed.name } });
      const inst = existing.find((v) => v.name === parsed.name);

      if (parsed.action === "add") {
        if (inst && inst.interface === parsed.interface && inst.vrid === String(parsed.vrid)) {
          return { content: `VRRP instance "${parsed.name}" already exists.`, structuredContent: { action: "already_exists", instance: inst } };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create VRRP instance "${parsed.name}" on ${parsed.interface} with VRID ${parsed.vrid}.`,
            structuredContent: { action: "dry_run", diff: { name: parsed.name, interface: parsed.interface, vrid: parsed.vrid, priority: parsed.priority } },
          };
        }
        const created = await context.routerClient.create("interface/vrrp", {
          name: parsed.name,
          interface: parsed.interface ?? "",
          vrid: String(parsed.vrid ?? 1),
          priority: String(parsed.priority),
        });
        return { content: `Created VRRP instance "${parsed.name}" on ${context.routerId}.`, structuredContent: { action: "created", instance: created } };
      }

      if (!inst) {
        throw new MikroMCPError({ category: ErrorCategory.NOT_FOUND, code: "VRRP_NOT_FOUND",
          message: `VRRP instance "${parsed.name}" not found on "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check with list_vrrp_instances." } });
      }
      if (parsed.dryRun) {
        return { content: `Dry run: would remove VRRP instance "${parsed.name}".`, structuredContent: { action: "dry_run", instance: inst } };
      }
      await context.routerClient.remove("interface/vrrp", inst[".id"]);
      return { content: `Removed VRRP instance "${parsed.name}" from ${context.routerId}.`, structuredContent: { action: "removed", name: parsed.name } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_vrrp_instance" });
    }
  },
};

export const vrrpTools: ToolDefinition[] = [listVrrpTool, manageVrrpTool];
```

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

```bash
npm test -- test/unit/domain/tools/vrrp-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/vrrp-tools.ts test/unit/domain/tools/vrrp-tools.test.ts
git commit -m "feat(tools): add VRRP tools — list_vrrp_instances, manage_vrrp_instance"
```

---

## Task 7: SNMP tools

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

RouterOS paths: `snmp` (global settings, singleton), `snmp/community` (community strings). `get_snmp_settings` reads both. `manage_snmp_community` idempotent by community `name`.

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

Create `test/unit/domain/tools/snmp-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { snmpTools } from "../../../src/domain/tools/snmp-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const SETTINGS = [{ enabled: "true", contact: "noc@example.com", location: "DC-1" }];
const COMMUNITY = { ".id": "*1", name: "public", security: "none", "read-access": "true", "write-access": "false" };

function makeContext(
  getImpl: (path: string) => unknown[] = () => [],
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockImplementation((path: string) => Promise.resolve(getImpl(path))),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("snmpTools metadata", () => {
  it("exports 2 tools", () => expect(snmpTools).toHaveLength(2));
  it("get_snmp_settings is readOnly", () =>
    expect(snmpTools.find((t) => t.name === "get_snmp_settings")!.annotations.readOnlyHint).toBe(true));
  it("manage_snmp_community has snapshotPaths", () =>
    expect(snmpTools.find((t) => t.name === "manage_snmp_community")!.snapshotPaths).toContain("snmp/community"));
});

describe("get_snmp_settings handler", () => {
  const tool = () => snmpTools.find((t) => t.name === "get_snmp_settings")!;

  it("fetches snmp settings and communities", async () => {
    const ctx = makeContext((path) => (path === "snmp" ? SETTINGS : [COMMUNITY]));
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.settings).toBeDefined();
    expect((structured.communities as unknown[]).length).toBe(1);
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("manage_snmp_community handler", () => {
  const tool = () => snmpTools.find((t) => t.name === "manage_snmp_community")!;

  it("returns already_exists when same name exists", async () => {
    const ctx = makeContext(() => [COMMUNITY]);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "public" },
      ctx,
    );
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates community", async () => {
    const ctx = makeContext(() => []);
    await tool().handler({ routerId: "test-router", action: "add", name: "private" }, ctx);
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "snmp/community",
      expect.objectContaining({ name: "private" }),
    );
  });

  it("dry_run skips creation", async () => {
    const ctx = makeContext(() => []);
    const result = await tool().handler(
      { routerId: "test-router", action: "add", name: "private", dryRun: true },
      ctx,
    );
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("removes community", async () => {
    const ctx = makeContext(() => [COMMUNITY]);
    await tool().handler({ routerId: "test-router", action: "remove", name: "public" }, ctx);
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("snmp/community", "*1");
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/snmp-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement snmp-tools.ts**

Create `src/domain/tools/snmp-tools.ts`:

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

const getSnmpSettingsSchema = z.object({ routerId: z.string().describe("Target router identifier") }).strict();

const manageSnmpCommunitySchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    name: z.string().min(1).describe("Community string name"),
    security: z.enum(["none", "private", "public"]).default("none").describe("SNMPv3 security level"),
    readAccess: z.boolean().default(true).describe("Allow read access"),
    writeAccess: z.boolean().default(false).describe("Allow write access"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const getSnmpSettingsTool: ToolDefinition = {
  name: "get_snmp_settings",
  title: "Get SNMP Settings",
  description:
    "Read SNMP global settings (`/snmp`) and all community strings (`/snmp/community`). Shows enabled state, contact, location, and communities.",
  inputSchema: getSnmpSettingsSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    getSnmpSettingsSchema.parse(params);
    try {
      const [settingsArr, communities] = await Promise.all([
        context.routerClient.get<RouterOSRecord>("snmp", {}),
        context.routerClient.get<RouterOSRecord>("snmp/community", {}),
      ]);
      const settings = settingsArr[0] ?? {};
      return {
        content: `SNMP on ${context.routerId}: enabled=${settings.enabled ?? "false"}, ${communities.length} community string(s).`,
        structuredContent: { routerId: context.routerId, settings, communities },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "get_snmp_settings" });
    }
  },
};

const manageSnmpCommunityTool: ToolDefinition = {
  name: "manage_snmp_community",
  title: "Manage SNMP Community",
  description: "Add or remove an SNMP community string (`/snmp/community`). Idempotent by name. Supports dry-run.",
  inputSchema: manageSnmpCommunitySchema,
  snapshotPaths: ["snmp/community"],
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageSnmpCommunitySchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing SNMP community");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("snmp/community", {
        filter: { name: parsed.name },
      });
      const community = existing.find((c) => c.name === parsed.name);

      if (parsed.action === "add") {
        if (community) {
          return { content: `SNMP community "${parsed.name}" already exists.`, structuredContent: { action: "already_exists", community } };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would create SNMP community "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff: { name: parsed.name, security: parsed.security } },
          };
        }
        const created = await context.routerClient.create("snmp/community", {
          name: parsed.name,
          security: parsed.security,
          "read-access": String(parsed.readAccess),
          "write-access": String(parsed.writeAccess),
        });
        return { content: `Created SNMP community "${parsed.name}" on ${context.routerId}.`, structuredContent: { action: "created", community: created } };
      }

      if (!community) {
        throw new MikroMCPError({ category: ErrorCategory.NOT_FOUND, code: "SNMP_COMMUNITY_NOT_FOUND",
          message: `SNMP community "${parsed.name}" not found on "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check with get_snmp_settings." } });
      }
      if (parsed.dryRun) {
        return { content: `Dry run: would remove SNMP community "${parsed.name}".`, structuredContent: { action: "dry_run", community } };
      }
      await context.routerClient.remove("snmp/community", community[".id"]);
      return { content: `Removed SNMP community "${parsed.name}" from ${context.routerId}.`, structuredContent: { action: "removed", name: parsed.name } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_snmp_community" });
    }
  },
};

export const snmpTools: ToolDefinition[] = [getSnmpSettingsTool, manageSnmpCommunityTool];
```

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

```bash
npm test -- test/unit/domain/tools/snmp-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/snmp-tools.ts test/unit/domain/tools/snmp-tools.test.ts
git commit -m "feat(tools): add SNMP tools — get_snmp_settings, manage_snmp_community"
```

---

## Task 8: Netwatch tools

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

RouterOS path: `tool/netwatch`. Idempotency by `host`.

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

Create `test/unit/domain/tools/netwatch-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { netwatchTools } from "../../../src/domain/tools/netwatch-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const ENTRY = { ".id": "*1", host: "8.8.8.8", interval: "1m", timeout: "1000ms", status: "up", disabled: "false" };

function makeContext(records: Record<string, unknown>[] = []): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

describe("netwatchTools metadata", () => {
  it("exports 2 tools", () => expect(netwatchTools).toHaveLength(2));
  it("list_netwatch_entries is readOnly", () =>
    expect(netwatchTools.find((t) => t.name === "list_netwatch_entries")!.annotations.readOnlyHint).toBe(true));
  it("manage_netwatch_entry has snapshotPaths", () =>
    expect(netwatchTools.find((t) => t.name === "manage_netwatch_entry")!.snapshotPaths).toContain("tool/netwatch"));
});

describe("list_netwatch_entries handler", () => {
  const tool = () => netwatchTools.find((t) => t.name === "list_netwatch_entries")!;

  it("fetches entries", async () => {
    const ctx = makeContext([ENTRY]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("tool/netwatch", expect.any(Object));
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });

  it("filters by status", async () => {
    const down = { ...ENTRY, ".id": "*2", host: "1.2.3.4", status: "down" };
    const ctx = makeContext([ENTRY, down]);
    const result = await tool().handler({ routerId: "test-router", statusFilter: "down" }, ctx);
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });
});

describe("manage_netwatch_entry handler", () => {
  const tool = () => netwatchTools.find((t) => t.name === "manage_netwatch_entry")!;

  it("returns already_exists for same host", async () => {
    const ctx = makeContext([ENTRY]);
    const result = await tool().handler({ routerId: "test-router", action: "add", host: "8.8.8.8" }, ctx);
    expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
  });

  it("creates entry", async () => {
    const ctx = makeContext([]);
    await tool().handler({ routerId: "test-router", action: "add", host: "1.1.1.1" }, ctx);
    expect(ctx.routerClient.create).toHaveBeenCalledWith(
      "tool/netwatch",
      expect.objectContaining({ host: "1.1.1.1" }),
    );
  });

  it("dry_run skips creation", async () => {
    const ctx = makeContext([]);
    const result = await tool().handler({ routerId: "test-router", action: "add", host: "1.1.1.1", dryRun: true }, ctx);
    expect(ctx.routerClient.create).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("removes entry", async () => {
    const ctx = makeContext([ENTRY]);
    await tool().handler({ routerId: "test-router", action: "remove", host: "8.8.8.8" }, ctx);
    expect(ctx.routerClient.remove).toHaveBeenCalledWith("tool/netwatch", "*1");
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/netwatch-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement netwatch-tools.ts**

Create `src/domain/tools/netwatch-tools.ts`:

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

const listNetwatchSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    statusFilter: z.enum(["up", "down", "unknown"]).optional().describe("Filter by current reachability status"),
    includeDisabled: z.boolean().default(false).describe("Include disabled entries"),
  })
  .strict();

const manageNetwatchSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    action: z.enum(["add", "remove"]).describe("Operation to perform"),
    host: z.string().min(1).describe("IP address or hostname to monitor"),
    interval: z.string().default("1m").describe("Check interval (e.g. 10s, 1m, 5m)"),
    timeout: z.string().default("1000ms").describe("Ping timeout (e.g. 500ms, 1s)"),
    comment: z.string().optional().describe("Description"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const listNetwatchTool: ToolDefinition = {
  name: "list_netwatch_entries",
  title: "List Netwatch Entries",
  description: "List Netwatch monitoring entries (`/tool/netwatch`). Shows host, status (up/down), interval, and last-changed.",
  inputSchema: listNetwatchSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listNetwatchSchema.parse(params);
    try {
      let entries = await context.routerClient.get<RouterOSRecord>("tool/netwatch", {});
      if (!parsed.includeDisabled) entries = entries.filter((e) => e.disabled !== "true");
      if (parsed.statusFilter) entries = entries.filter((e) => e.status === parsed.statusFilter);
      return {
        content: `Found ${entries.length} Netwatch entr(ies) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, entries, total: entries.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_netwatch_entries" });
    }
  },
};

const manageNetwatchTool: ToolDefinition = {
  name: "manage_netwatch_entry",
  title: "Manage Netwatch Entry",
  description: "Add or remove a Netwatch monitoring entry (`/tool/netwatch`). Idempotent by host address. Supports dry-run.",
  inputSchema: manageNetwatchSchema,
  snapshotPaths: ["tool/netwatch"],
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageNetwatchSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, host: parsed.host }, "Managing Netwatch entry");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("tool/netwatch", {
        filter: { host: parsed.host },
      });
      const entry = existing.find((e) => e.host === parsed.host);

      if (parsed.action === "add") {
        if (entry) {
          return { content: `Netwatch entry for "${parsed.host}" already exists.`, structuredContent: { action: "already_exists", entry } };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: would add Netwatch entry for "${parsed.host}" (interval ${parsed.interval}).`,
            structuredContent: { action: "dry_run", diff: { host: parsed.host, interval: parsed.interval, timeout: parsed.timeout } },
          };
        }
        const created = await context.routerClient.create("tool/netwatch", {
          host: parsed.host,
          interval: parsed.interval,
          timeout: parsed.timeout,
          ...(parsed.comment ? { comment: parsed.comment } : {}),
        });
        return { content: `Added Netwatch entry for "${parsed.host}" on ${context.routerId}.`, structuredContent: { action: "created", entry: created } };
      }

      if (!entry) {
        throw new MikroMCPError({ category: ErrorCategory.NOT_FOUND, code: "NETWATCH_ENTRY_NOT_FOUND",
          message: `Netwatch entry for host "${parsed.host}" not found on "${context.routerId}".`,
          recoverability: { retryable: false, suggestedAction: "Check with list_netwatch_entries." } });
      }
      if (parsed.dryRun) {
        return { content: `Dry run: would remove Netwatch entry for "${parsed.host}".`, structuredContent: { action: "dry_run", entry } };
      }
      await context.routerClient.remove("tool/netwatch", entry[".id"]);
      return { content: `Removed Netwatch entry for "${parsed.host}" from ${context.routerId}.`, structuredContent: { action: "removed", host: parsed.host } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_netwatch_entry" });
    }
  },
};

export const netwatchTools: ToolDefinition[] = [listNetwatchTool, manageNetwatchTool];
```

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

```bash
npm test -- test/unit/domain/tools/netwatch-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/netwatch-tools.ts test/unit/domain/tools/netwatch-tools.test.ts
git commit -m "feat(tools): add Netwatch tools — list_netwatch_entries, manage_netwatch_entry"
```

---

## Task 9: NTP, neighbor discovery, and ARP tools

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

RouterOS paths: `ip/ntp/client` (NTP client, RouterOS 7), `ip/neighbor` (LLDP/CDP/MNDP discoveries), `ip/arp` (ARP table). NTP settings use `execute("ip/ntp/client/set", ...)` since it's a singleton config. All list tools are read-only.

- [ ] **Step 1: Write failing tests for ntp-tools**

Create `test/unit/domain/tools/ntp-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { ntpTools } from "../../../src/domain/tools/ntp-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const NTP_SETTINGS = [{ enabled: "true", servers: "pool.ntp.org", mode: "unicast" }];

function makeContext(records: Record<string, unknown>[] = NTP_SETTINGS): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      execute: vi.fn().mockResolvedValue({}),
    } as unknown as RouterOSRestClient,
  };
}

describe("ntpTools metadata", () => {
  it("exports 2 tools", () => expect(ntpTools).toHaveLength(2));
  it("get_ntp_settings is readOnly", () =>
    expect(ntpTools.find((t) => t.name === "get_ntp_settings")!.annotations.readOnlyHint).toBe(true));
  it("manage_ntp_settings is not readOnly", () =>
    expect(ntpTools.find((t) => t.name === "manage_ntp_settings")!.annotations.readOnlyHint).toBe(false));
});

describe("get_ntp_settings handler", () => {
  const tool = () => ntpTools.find((t) => t.name === "get_ntp_settings")!;

  it("fetches NTP settings", async () => {
    const ctx = makeContext();
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/ntp/client", expect.any(Object));
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.settings).toBeDefined();
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("manage_ntp_settings handler", () => {
  const tool = () => ntpTools.find((t) => t.name === "manage_ntp_settings")!;

  it("applies NTP server update", async () => {
    const ctx = makeContext();
    await tool().handler({ routerId: "test-router", servers: "time.cloudflare.com" }, ctx);
    expect(ctx.routerClient.execute).toHaveBeenCalledWith(
      "ip/ntp/client/set",
      expect.objectContaining({ servers: "time.cloudflare.com" }),
    );
  });

  it("dry_run skips execute", async () => {
    const ctx = makeContext();
    const result = await tool().handler({ routerId: "test-router", servers: "time.cloudflare.com", dryRun: true }, ctx);
    expect(ctx.routerClient.execute).not.toHaveBeenCalled();
    expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});
```

- [ ] **Step 2: Write failing tests for neighbor-tools**

Create `test/unit/domain/tools/neighbor-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { neighborTools } from "../../../src/domain/tools/neighbor-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const NEIGHBOR = { ".id": "*1", interface: "ether1", address: "10.0.0.2", mac: "AA:BB:CC:DD:EE:FF", identity: "switch-01", platform: "MikroTik" };
const ARP = { ".id": "*1", address: "10.0.0.2", mac: "AA:BB:CC:DD:EE:FF", interface: "bridge1", dynamic: "true", complete: "true" };

function makeContext(
  getImpl: (path: string) => unknown[] = () => [],
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockImplementation((path: string) => Promise.resolve(getImpl(path))),
    } as unknown as RouterOSRestClient,
  };
}

describe("neighborTools metadata", () => {
  it("exports 2 tools", () => expect(neighborTools).toHaveLength(2));
  it("both tools are readOnly", () => {
    expect(neighborTools.every((t) => t.annotations.readOnlyHint)).toBe(true);
  });
});

describe("list_neighbors handler", () => {
  const tool = () => neighborTools.find((t) => t.name === "list_neighbors")!;

  it("fetches IP neighbors", async () => {
    const ctx = makeContext(() => [NEIGHBOR]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/neighbor", expect.any(Object));
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });

  it("filters by interface", async () => {
    const ctx = makeContext(() => [NEIGHBOR]);
    const result = await tool().handler({ routerId: "test-router", interfaceFilter: "ether1" }, ctx);
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });
});

describe("list_arp_entries handler", () => {
  const tool = () => neighborTools.find((t) => t.name === "list_arp_entries")!;

  it("fetches ARP table", async () => {
    const ctx = makeContext(() => [ARP]);
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    expect(ctx.routerClient.get).toHaveBeenCalledWith("ip/arp", expect.any(Object));
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });

  it("filters to static entries only", async () => {
    const staticArp = { ...ARP, dynamic: "false" };
    const ctx = makeContext(() => [ARP, staticArp]);
    const result = await tool().handler({ routerId: "test-router", staticOnly: true }, ctx);
    expect((result.structuredContent as Record<string, unknown>).total).toBe(1);
  });
});
```

- [ ] **Step 3: Run to verify failures**

```bash
npm test -- test/unit/domain/tools/ntp-tools.test.ts test/unit/domain/tools/neighbor-tools.test.ts
```
Expected: both fail — modules not found

- [ ] **Step 4: Implement ntp-tools.ts**

Create `src/domain/tools/ntp-tools.ts`:

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

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

const getNtpSchema = z.object({ routerId: z.string().describe("Target router identifier") }).strict();

const manageNtpSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    servers: z.string().optional().describe("Comma-separated NTP server addresses (e.g. pool.ntp.org,time.cloudflare.com)"),
    enabled: z.boolean().optional().describe("Enable or disable NTP client"),
    dryRun: z.boolean().default(false).describe("Preview without applying"),
  })
  .strict();

const getNtpTool: ToolDefinition = {
  name: "get_ntp_settings",
  title: "Get NTP Settings",
  description: "Read NTP client configuration from the router (`/ip/ntp/client`). Requires RouterOS 7.",
  inputSchema: getNtpSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    getNtpSchema.parse(params);
    try {
      const settingsArr = await context.routerClient.get<RouterOSRecord>("ip/ntp/client", {});
      const settings = settingsArr[0] ?? {};
      return {
        content: `NTP on ${context.routerId}: enabled=${settings.enabled ?? "unknown"}, servers=${settings.servers ?? "none"}.`,
        structuredContent: { routerId: context.routerId, settings },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "get_ntp_settings" });
    }
  },
};

const manageNtpTool: ToolDefinition = {
  name: "manage_ntp_settings",
  title: "Manage NTP Settings",
  description:
    "Update NTP client settings on the router (`/ip/ntp/client/set`). Applies partial updates — omitted fields are unchanged. Supports dry-run. Requires RouterOS 7.",
  inputSchema: manageNtpSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageNtpSchema.parse(params);
    log.info({ routerId: context.routerId }, "Managing NTP settings");
    try {
      const updates: Record<string, string> = {};
      if (parsed.servers !== undefined) updates.servers = parsed.servers;
      if (parsed.enabled !== undefined) updates.enabled = String(parsed.enabled);

      if (parsed.dryRun) {
        return {
          content: `Dry run: would update NTP settings on ${context.routerId}: ${JSON.stringify(updates)}.`,
          structuredContent: { action: "dry_run", diff: updates },
        };
      }
      await context.routerClient.execute("ip/ntp/client/set", updates);
      return {
        content: `Updated NTP settings on ${context.routerId}.`,
        structuredContent: { action: "updated", routerId: context.routerId, applied: updates },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "manage_ntp_settings" });
    }
  },
};

export const ntpTools: ToolDefinition[] = [getNtpTool, manageNtpTool];
```

- [ ] **Step 5: Implement neighbor-tools.ts**

Create `src/domain/tools/neighbor-tools.ts`:

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

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

const listNeighborsSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    interfaceFilter: z.string().optional().describe("Filter by interface name (exact match)"),
  })
  .strict();

const listArpSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    interfaceFilter: z.string().optional().describe("Filter by interface name (exact match)"),
    staticOnly: z.boolean().default(false).describe("Return only static (non-dynamic) ARP entries"),
    macFilter: z.string().optional().describe("Filter by MAC address (substring match)"),
  })
  .strict();

const listNeighborsTool: ToolDefinition = {
  name: "list_neighbors",
  title: "List Neighbors",
  description:
    "List discovered IP neighbors via LLDP/CDP/MNDP (`/ip/neighbor`). Shows interface, address, MAC, platform identity, and capabilities.",
  inputSchema: listNeighborsSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listNeighborsSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing neighbors");
    try {
      let neighbors = await context.routerClient.get<RouterOSRecord>("ip/neighbor", {});
      if (parsed.interfaceFilter) neighbors = neighbors.filter((n) => n.interface === parsed.interfaceFilter);
      return {
        content: `Found ${neighbors.length} neighbor(s) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, neighbors, total: neighbors.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_neighbors" });
    }
  },
};

const listArpTool: ToolDefinition = {
  name: "list_arp_entries",
  title: "List ARP Entries",
  description: "List ARP table entries on the router (`/ip/arp`). Shows IP address, MAC, interface, and whether the entry is dynamic or static.",
  inputSchema: listArpSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listArpSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing ARP entries");
    try {
      let entries = await context.routerClient.get<RouterOSRecord>("ip/arp", {});
      if (parsed.interfaceFilter) entries = entries.filter((e) => e.interface === parsed.interfaceFilter);
      if (parsed.staticOnly) entries = entries.filter((e) => e.dynamic !== "true");
      if (parsed.macFilter) entries = entries.filter((e) => e.mac?.includes(parsed.macFilter!));
      return {
        content: `Found ${entries.length} ARP entr(ies) on ${context.routerId}.`,
        structuredContent: { routerId: context.routerId, entries, total: entries.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_arp_entries" });
    }
  },
};

export const neighborTools: ToolDefinition[] = [listNeighborsTool, listArpTool];
```

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

```bash
npm test -- test/unit/domain/tools/ntp-tools.test.ts test/unit/domain/tools/neighbor-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 7: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/ntp-tools.ts src/domain/tools/neighbor-tools.ts \
  test/unit/domain/tools/ntp-tools.test.ts test/unit/domain/tools/neighbor-tools.test.ts
git commit -m "feat(tools): add NTP, neighbor discovery, and ARP tools — get_ntp_settings, manage_ntp_settings, list_neighbors, list_arp_entries"
```

---

## Task 10: ToolContext extension + bulk_execute tool

**Files:**
- Modify: `src/domain/tools/tool-definition.ts`
- Create: `src/domain/tools/bulk-tools.ts`
- Modify: `src/mcp/tool-registry.ts`
- Create: `test/unit/domain/tools/bulk-tools.test.ts`

`bulk_execute` fans out any tool call across multiple routers by ID or tag. It needs per-router REST clients, which requires the `ConnectionPool` and `RouterRegistry`. These are added as optional fields to `ToolContext` and populated by `tool-registry.ts`. A `routerId` anchor is still required (used for the normal authz check). The handler then creates per-router contexts using the pool and registry.

- [ ] **Step 1: Extend ToolContext in tool-definition.ts**

In `src/domain/tools/tool-definition.ts`, add two `import type` lines at the top (after the existing imports):

```typescript
import type { ConnectionPool } from "../../adapter/connection-pool.js";
import type { RouterRegistry } from "../../config/router-registry.js";
```

Add two optional fields inside the `ToolContext` interface (after `identity`):

```typescript
  connectionPool?: ConnectionPool;
  routerRegistry?: RouterRegistry;
```

- [ ] **Step 2: Typecheck after ToolContext change**

```bash
npm run typecheck
```
Expected: no errors (fields are optional — no existing callsites break)

- [ ] **Step 3: Update tool-registry.ts to populate new fields**

In `src/mcp/tool-registry.ts`, find the `runHandler` assignment (around line 156):

```typescript
            const runHandler = () =>
              tool.handler(handlerArgs, {
                routerClient: client,
                routerId,
                correlationId,
                routerConfig,
                sshClient,
                ftpClient,
                identity,
              });
```

Replace it with:

```typescript
            const runHandler = () =>
              tool.handler(handlerArgs, {
                routerClient: client,
                routerId,
                correlationId,
                routerConfig,
                sshClient,
                ftpClient,
                identity,
                connectionPool: pool,
                routerRegistry: registry,
              });
```

- [ ] **Step 4: Typecheck**

```bash
npm run typecheck
```
Expected: no errors

- [ ] **Step 5: Write failing tests for bulk_execute**

Create `test/unit/domain/tools/bulk-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { createBulkTools } from "../../../src/domain/tools/bulk-tools.js";
import type { ToolDefinition, ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";
import type { ConnectionPool } from "../../../src/adapter/connection-pool.js";
import type { RouterRegistry } from "../../../src/config/router-registry.js";

function makeListTool(name: string, result: string): ToolDefinition {
  return {
    name,
    title: name,
    description: "",
    inputSchema: { parse: (p: unknown) => p } as unknown as import("zod").ZodType,
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    handler: vi.fn().mockResolvedValue({ content: result, structuredContent: { data: [] } }),
  };
}

function makeRouterConfig(id: string): RouterConfig {
  return {
    id,
    host: "10.0.0.1",
    port: 443,
    tls: { enabled: true, rejectUnauthorized: true },
    credentials: { source: "env", envPrefix: "ROUTER_TEST" },
    tags: ["edge"],
    rosVersion: "7.14",
  } as RouterConfig;
}

function makeContext(routerIds: string[]): ToolContext {
  const mockClient = {
    get: vi.fn().mockResolvedValue([]),
  } as unknown as RouterOSRestClient;

  const mockPool = {
    getClient: vi.fn().mockReturnValue(mockClient),
  } as unknown as ConnectionPool;

  const mockRegistry = {
    getRouter: vi.fn().mockImplementation((id: string) => makeRouterConfig(id)),
    listRouters: vi.fn().mockImplementation((tags?: string[]) =>
      routerIds.map((id) => makeRouterConfig(id)),
    ),
  } as unknown as RouterRegistry;

  return {
    routerId: routerIds[0] ?? "test-router",
    correlationId: "corr-1",
    routerConfig: makeRouterConfig(routerIds[0] ?? "test-router"),
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: mockClient,
    connectionPool: mockPool,
    routerRegistry: mockRegistry,
  };
}

describe("bulk_execute metadata", () => {
  const tool = () => createBulkTools([makeListTool("list_routes", "ok")]).find((t) => t.name === "bulk_execute")!;

  it("is registered correctly", () => {
    expect(tool()).toBeDefined();
    expect(tool().annotations.readOnlyHint).toBe(false);
    expect(tool().annotations.destructiveHint).toBe(false);
  });

  it("rejects extra schema fields", () => {
    expect(() =>
      tool().inputSchema.parse({ routerId: "r1", tool: "list_routes", params: {}, routerIds: ["r1"], extra: true }),
    ).toThrow();
  });

  it("rejects when neither routerIds nor tags are provided", async () => {
    const ctx = makeContext(["edge-01"]);
    await expect(
      tool().handler({ routerId: "edge-01", tool: "list_routes", params: {} }, ctx),
    ).rejects.toMatchObject({ code: "BULK_TARGET_REQUIRED" });
  });
});

describe("bulk_execute handler", () => {
  it("fans out to all routerIds and aggregates results", async () => {
    const listRoutes = makeListTool("list_routes", "found 5 routes");
    const tools = createBulkTools([listRoutes]);
    const bulkTool = tools.find((t) => t.name === "bulk_execute")!;
    const ctx = makeContext(["edge-01", "edge-02"]);

    const result = await bulkTool.handler(
      { routerId: "edge-01", tool: "list_routes", params: {}, routerIds: ["edge-01", "edge-02"] },
      ctx,
    );

    expect(listRoutes.handler).toHaveBeenCalledTimes(2);
    const structured = result.structuredContent as Record<string, unknown>;
    expect((structured.results as unknown[]).length).toBe(2);
    expect((structured.results as Array<Record<string, unknown>>).every((r) => r.status === "success")).toBe(true);
  });

  it("reports per-router failures without aborting the whole batch", async () => {
    const failingTool: ToolDefinition = {
      ...makeListTool("list_routes", ""),
      handler: vi.fn()
        .mockResolvedValueOnce({ content: "ok", structuredContent: {} })
        .mockRejectedValueOnce(new Error("Router unreachable")),
    };
    const tools = createBulkTools([failingTool]);
    const bulkTool = tools.find((t) => t.name === "bulk_execute")!;
    const ctx = makeContext(["edge-01", "edge-02"]);

    const result = await bulkTool.handler(
      { routerId: "edge-01", tool: "list_routes", params: {}, routerIds: ["edge-01", "edge-02"] },
      ctx,
    );

    const structured = result.structuredContent as Record<string, unknown>;
    const results = structured.results as Array<Record<string, unknown>>;
    expect(results.find((r) => r.routerId === "edge-01")!.status).toBe("success");
    expect(results.find((r) => r.routerId === "edge-02")!.status).toBe("failed");
  });

  it("throws TOOL_NOT_FOUND for unknown tool name", async () => {
    const tools = createBulkTools([]);
    const bulkTool = tools.find((t) => t.name === "bulk_execute")!;
    const ctx = makeContext(["edge-01"]);
    await expect(
      bulkTool.handler({ routerId: "edge-01", tool: "nonexistent", params: {}, routerIds: ["edge-01"] }, ctx),
    ).rejects.toMatchObject({ code: "TOOL_NOT_FOUND" });
  });

  it("fans out by tag using routerRegistry.listRouters", async () => {
    const listRoutes = makeListTool("list_routes", "ok");
    const tools = createBulkTools([listRoutes]);
    const bulkTool = tools.find((t) => t.name === "bulk_execute")!;
    const ctx = makeContext(["edge-01", "edge-02"]);

    await bulkTool.handler(
      { routerId: "edge-01", tool: "list_routes", params: {}, tags: ["edge"] },
      ctx,
    );

    expect((ctx.routerRegistry as RouterRegistry).listRouters).toHaveBeenCalledWith(["edge"]);
  });

  it("throws INFRASTRUCTURE_UNAVAILABLE if connectionPool is missing", async () => {
    const tools = createBulkTools([makeListTool("list_routes", "ok")]);
    const bulkTool = tools.find((t) => t.name === "bulk_execute")!;
    const ctx = makeContext(["edge-01"]);
    delete (ctx as Record<string, unknown>).connectionPool;

    await expect(
      bulkTool.handler({ routerId: "edge-01", tool: "list_routes", params: {}, routerIds: ["edge-01"] }, ctx),
    ).rejects.toMatchObject({ code: "INFRASTRUCTURE_UNAVAILABLE" });
  });
});
```

- [ ] **Step 6: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/bulk-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 7: Implement bulk-tools.ts**

Create `src/domain/tools/bulk-tools.ts`:

```typescript
import { z } from "zod";
import type { ToolDefinition, ToolContext, ToolResult } from "./tool-definition.js";
import { enrichError } from "../errors/error-enricher.js";
import { MikroMCPError, ErrorCategory } from "../errors/error-types.js";
import { getCredentials } from "../../config/secrets.js";
import { createSshClient, createFtpClient } from "../../adapter/adapter-factory.js";
import { createLogger } from "../../observability/logger.js";

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

const bulkExecuteSchema = z
  .object({
    routerId: z.string().describe("Anchor router ID (used for authorization check — must be one the caller has access to)"),
    tool: z.string().min(1).describe("Name of the MikroMCP tool to invoke on each router"),
    params: z
      .record(z.unknown())
      .default({})
      .describe("Tool parameters to pass to each invocation (routerId is injected automatically per router)"),
    routerIds: z.array(z.string()).optional().describe("Explicit list of router IDs to target"),
    tags: z.array(z.string()).optional().describe("Router tags — all routers with any of these tags are targeted"),
    concurrency: z
      .number()
      .int()
      .min(1)
      .max(20)
      .default(5)
      .describe("Maximum number of simultaneous router calls (1–20, default 5)"),
  })
  .strict();

export function createBulkTools(baseTools: ToolDefinition[]): ToolDefinition[] {
  const toolMap = new Map(baseTools.map((t) => [t.name, t]));

  function requireTool(name: string): ToolDefinition {
    const tool = toolMap.get(name);
    if (!tool) {
      throw new MikroMCPError({
        category: ErrorCategory.NOT_FOUND,
        code: "TOOL_NOT_FOUND",
        message: `Tool "${name}" not found. Available: ${[...toolMap.keys()].join(", ")}`,
        recoverability: { retryable: false, suggestedAction: "Check the tool name." },
      });
    }
    return tool;
  }

  const bulkExecuteTool: ToolDefinition = {
    name: "bulk_execute",
    title: "Bulk Execute",
    description:
      "Fan out a tool call across multiple routers by explicit ID list or by tag. All routers are called concurrently up to the configured concurrency limit. Results are aggregated per router — partial failures do not abort the batch. Requires routerIds or tags (or both). Note: sub-tool calls bypass per-tool confirmation gates and circuit breakers.",
    inputSchema: bulkExecuteSchema,
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
      const parsed = bulkExecuteSchema.parse(params);

      if (!context.connectionPool || !context.routerRegistry) {
        throw new MikroMCPError({
          category: ErrorCategory.INTERNAL,
          code: "INFRASTRUCTURE_UNAVAILABLE",
          message: "bulk_execute requires connectionPool and routerRegistry in ToolContext — ensure you are running through tool-registry.",
          recoverability: { retryable: false, suggestedAction: "This is a server configuration issue." },
        });
      }

      if (!parsed.routerIds?.length && !parsed.tags?.length) {
        throw new MikroMCPError({
          category: ErrorCategory.VALIDATION,
          code: "BULK_TARGET_REQUIRED",
          message: "Provide at least one of: routerIds or tags.",
          recoverability: { retryable: false, suggestedAction: "Specify routerIds: ['r1','r2'] or tags: ['edge']." },
        });
      }

      const targetTool = requireTool(parsed.tool);

      // Resolve router IDs from tags, then merge with explicit IDs (dedup)
      const resolvedIds = new Set<string>(parsed.routerIds ?? []);
      if (parsed.tags?.length) {
        const taggedRouters = context.routerRegistry.listRouters(parsed.tags);
        for (const r of taggedRouters) resolvedIds.add(r.id);
      }
      const targetRouterIds = [...resolvedIds];

      log.info(
        { routerId: context.routerId, targetCount: targetRouterIds.length, tool: parsed.tool },
        "Starting bulk execution",
      );

      const results: Array<{ routerId: string; status: "success" | "failed"; result?: string; error?: string }> = [];

      // Process in batches of `concurrency`
      for (let i = 0; i < targetRouterIds.length; i += parsed.concurrency) {
        const batch = targetRouterIds.slice(i, i + parsed.concurrency);
        const settled = await Promise.allSettled(
          batch.map(async (targetRouterId) => {
            const routerConfig = context.routerRegistry!.getRouter(targetRouterId);
            const credentials = getCredentials(routerConfig);
            const client = context.connectionPool!.getClient(routerConfig, credentials);
            const sshClient = createSshClient(routerConfig, undefined);
            const ftpClient = createFtpClient(routerConfig);

            const subContext: ToolContext = {
              ...context,
              routerId: targetRouterId,
              routerClient: client,
              routerConfig,
              sshClient,
              ftpClient,
            };

            return targetTool.handler({ ...parsed.params, routerId: targetRouterId }, subContext);
          }),
        );

        for (const [j, outcome] of settled.entries()) {
          const targetRouterId = batch[j];
          if (outcome.status === "fulfilled") {
            results.push({ routerId: targetRouterId, status: "success", result: outcome.value.content });
          } else {
            const err = outcome.reason instanceof MikroMCPError
              ? outcome.reason
              : enrichError(outcome.reason, { tool: parsed.tool, routerId: targetRouterId });
            log.warn({ err, routerId: targetRouterId, tool: parsed.tool }, "Bulk sub-call failed");
            results.push({ routerId: targetRouterId, status: "failed", error: err.message });
          }
        }
      }

      const succeeded = results.filter((r) => r.status === "success").length;
      const failed = results.filter((r) => r.status === "failed").length;

      return {
        content: `Bulk execute '${parsed.tool}' on ${targetRouterIds.length} router(s): ${succeeded} succeeded, ${failed} failed.`,
        structuredContent: {
          tool: parsed.tool,
          totalRouters: targetRouterIds.length,
          succeeded,
          failed,
          results,
        },
      };
    },
  };

  return [bulkExecuteTool];
}
```

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

```bash
npm test -- test/unit/domain/tools/bulk-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 9: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/tool-definition.ts src/domain/tools/bulk-tools.ts \
  src/mcp/tool-registry.ts test/unit/domain/tools/bulk-tools.test.ts
git commit -m "feat(tools): add bulk_execute tool with multi-router fan-out; extend ToolContext with connectionPool and routerRegistry"
```

---

## Task 11: Health check tool

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

`check_router_health` probes the router via the existing `routerClient`: fetches `system/resource` (verifies REST reachability + reads version/uptime) and `system/identity` (name). Returns a structured health report.

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

Create `test/unit/domain/tools/health-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { healthTools } from "../../../src/domain/tools/health-tools.js";
import type { ToolContext } from "../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../src/adapter/rest-client.js";
import type { RouterConfig } from "../../../src/types.js";
import type { SshClient } from "../../../src/adapter/ssh-client.js";
import type { FtpClient } from "../../../src/adapter/ftp-client.js";

const RESOURCE = [{
  "version": "7.14",
  "uptime": "1w2d3h",
  "cpu-load": "5",
  "free-memory": "512000000",
  "total-memory": "1024000000",
  "platform": "MikroTik",
  "board-name": "RB5009UPr+S+",
}];
const IDENTITY = [{ name: "core-01" }];

function makeContext(
  getImpl: (path: string) => unknown[] = (path) => (path === "system/resource" ? RESOURCE : IDENTITY),
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr-1",
    routerConfig: { rosVersion: "7.14" } as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: vi.fn().mockImplementation((path: string) => Promise.resolve(getImpl(path))),
    } as unknown as RouterOSRestClient,
  };
}

describe("healthTools metadata", () => {
  it("exports 1 tool", () => expect(healthTools).toHaveLength(1));
  it("check_router_health is readOnly", () =>
    expect(healthTools.find((t) => t.name === "check_router_health")!.annotations.readOnlyHint).toBe(true));
});

describe("check_router_health handler", () => {
  const tool = () => healthTools.find((t) => t.name === "check_router_health")!;

  it("returns reachable=true and version when REST is reachable", async () => {
    const ctx = makeContext();
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.reachable).toBe(true);
    expect(structured.version).toBe("7.14");
    expect(structured.identity).toBe("core-01");
    expect(structured.uptimeRaw).toBe("1w2d3h");
  });

  it("returns reachable=false when REST call fails", async () => {
    const ctx: ToolContext = {
      ...makeContext(),
      routerClient: {
        get: vi.fn().mockRejectedValue(new Error("Connection refused")),
      } as unknown as RouterOSRestClient,
    };
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.reachable).toBe(false);
    expect(result.isError).toBeUndefined();
  });

  it("rejects extra schema fields", () => {
    expect(() => tool().inputSchema.parse({ routerId: "r1", extra: true })).toThrow();
  });

  it("includes cpuLoad and memoryUsedPercent in structured output", async () => {
    const ctx = makeContext();
    const result = await tool().handler({ routerId: "test-router" }, ctx);
    const structured = result.structuredContent as Record<string, unknown>;
    expect(typeof structured.cpuLoadPercent).toBe("number");
    expect(typeof structured.memoryUsedPercent).toBe("number");
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/health-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement health-tools.ts**

Create `src/domain/tools/health-tools.ts`:

```typescript
import { z } from "zod";
import type { ToolDefinition, ToolContext, ToolResult } from "./tool-definition.js";
import type { RouterOSRecord } from "../../types.js";
import { createLogger } from "../../observability/logger.js";

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

const checkHealthSchema = z.object({ routerId: z.string().describe("Target router identifier") }).strict();

const checkRouterHealthTool: ToolDefinition = {
  name: "check_router_health",
  title: "Check Router Health",
  description:
    "Probe router reachability and report version, uptime, CPU load, and memory usage. Returns reachable=false (not an error) if the REST API is unreachable — safe to call on potentially-down routers.",
  inputSchema: checkHealthSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    checkHealthSchema.parse(params);
    log.info({ routerId: context.routerId }, "Checking router health");

    try {
      const [resourceArr, identityArr] = await Promise.all([
        context.routerClient.get<RouterOSRecord>("system/resource", {}),
        context.routerClient.get<RouterOSRecord>("system/identity", {}),
      ]);

      const resource = resourceArr[0] ?? {};
      const identity = identityArr[0] ?? {};

      const freeMemory = Number(resource["free-memory"] ?? 0);
      const totalMemory = Number(resource["total-memory"] ?? 1);
      const memoryUsedPercent = totalMemory > 0
        ? Math.round(((totalMemory - freeMemory) / totalMemory) * 100)
        : 0;

      return {
        content: `Router ${context.routerId} (${identity.name ?? "unknown"}) is reachable. RouterOS ${resource.version ?? "?"}, uptime ${resource.uptime ?? "?"}, CPU ${resource["cpu-load"] ?? "?"}%, memory ${memoryUsedPercent}% used.`,
        structuredContent: {
          routerId: context.routerId,
          reachable: true,
          identity: identity.name,
          version: resource.version,
          platform: resource.platform,
          boardName: resource["board-name"],
          uptimeRaw: resource.uptime,
          cpuLoadPercent: Number(resource["cpu-load"] ?? 0),
          freeMemoryBytes: freeMemory,
          totalMemoryBytes: totalMemory,
          memoryUsedPercent,
        },
      };
    } catch {
      return {
        content: `Router ${context.routerId} is unreachable via REST API.`,
        structuredContent: {
          routerId: context.routerId,
          reachable: false,
        },
      };
    }
  },
};

export const healthTools: ToolDefinition[] = [checkRouterHealthTool];
```

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

```bash
npm test -- test/unit/domain/tools/health-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/health-tools.ts test/unit/domain/tools/health-tools.test.ts
git commit -m "feat(tools): add check_router_health — REST reachability probe with version, uptime, CPU, and memory"
```

---

## Task 12: Wire into index.ts, update tool-registry, and update docs

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

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

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

```typescript
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";
import { diagnosticTools } from "./diagnostic-tools.js";
import { systemOpsTools } from "./system-ops-tools.js";
import { bridgeTools } from "./bridge-tools.js";
import { wifiTools } from "./wifi-tools.js";
import { wireguardTools } from "./wireguard-tools.js";
import { dnsTools } from "./dns-tools.js";
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";
import { scriptsTools } from "./scripts-tools.js";
import { schedulerTools } from "./scheduler-tools.js";
import { packagesTools } from "./packages-tools.js";
import { filesTools } from "./files-tools.js";
import { containerTools } from "./container-tools.js";
import { ipsecTools } from "./ipsec-tools.js";
import { certificateTools } from "./certificate-tools.js";
import { userTools } from "./user-tools.js";
import { dhcpServerTools } from "./dhcp-server-tools.js";
import { queueTools } from "./queue-tools.js";
import { vrrpTools } from "./vrrp-tools.js";
import { snmpTools } from "./snmp-tools.js";
import { netwatchTools } from "./netwatch-tools.js";
import { ntpTools } from "./ntp-tools.js";
import { neighborTools } from "./neighbor-tools.js";
import { healthTools } from "./health-tools.js";
import { createChangeManagementTools } from "./change-management-tools.js";
import { createBulkTools } from "./bulk-tools.js";

const baseTools: ToolDefinition[] = [
  ...systemTools,
  ...interfaceTools,
  ...ipTools,
  ...dhcpTools,
  ...routeTools,
  ...firewallTools,
  ...diagnosticTools,
  ...systemOpsTools,
  ...bridgeTools,
  ...wifiTools,
  ...wireguardTools,
  ...dnsTools,
  ...mangleTools,
  ...addressListTools,
  ...policyRoutingTools,
  ...routingProtocolTools,
  ...scriptsTools,
  ...schedulerTools,
  ...packagesTools,
  ...filesTools,
  ...containerTools,
  ...ipsecTools,
  ...certificateTools,
  ...userTools,
  ...dhcpServerTools,
  ...queueTools,
  ...vrrpTools,
  ...snmpTools,
  ...netwatchTools,
  ...ntpTools,
  ...neighborTools,
  ...healthTools,
];

export const allTools: ToolDefinition[] = [
  ...baseTools,
  ...createChangeManagementTools(baseTools),
  ...createBulkTools(baseTools),
];

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

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

```bash
npm test && npm run typecheck
```
Expected: all existing tests pass, no type errors. Total test count will be higher due to new test files.

- [ ] **Step 3: Flip v0.9 in ROADMAP.md**

In `ROADMAP.md`, change the line:

```
## 🔜 v0.9 — Fleet Operations & Remaining RouterOS Surface
```

to:

```
## ✅ v0.9 — Fleet Operations & Remaining RouterOS Surface
```

- [ ] **Step 4: Update README.md tool count and table**

Find the tool count in `README.md` (currently 54) and update it to **79**. Add the following rows to the Available Tools table (find the existing table and append these sections):

```
| `list_ipsec_peers` | List IPSec IKE peers | routerId, nameFilter? |
| `list_ipsec_policies` | List IPSec policies | routerId |
| `manage_ipsec_peer` | Add/remove IPSec peer | routerId, action, name, address?, profile?, exchangeMode?, dryRun |
| `list_certificates` | List certificates | routerId, nameFilter?, expiredOnly? |
| `manage_certificate` | Generate/remove certificate | routerId, action, name, commonName?, keySize?, daysValid?, dryRun |
| `list_users` | List router users | routerId, group?, includeDisabled? |
| `manage_user` | Add/remove user | routerId, action, name, group?, password?, allowedAddress?, dryRun |
| `list_dhcp_servers` | List DHCP servers | routerId, includeDisabled? |
| `manage_dhcp_server` | Add/remove DHCP server | routerId, action, name, interface?, addressPool?, leaseTime?, dryRun |
| `list_ip_pools` | List IP address pools | routerId, nameFilter? |
| `manage_ip_pool` | Add/remove IP pool | routerId, action, name, ranges?, dryRun |
| `list_queues` | List simple queues | routerId, targetFilter?, includeDisabled? |
| `manage_queue` | Add/remove simple queue | routerId, action, name, target?, maxLimit?, dryRun |
| `list_vrrp_instances` | List VRRP instances | routerId |
| `manage_vrrp_instance` | Add/remove VRRP instance | routerId, action, name, interface?, vrid?, priority?, dryRun |
| `get_snmp_settings` | Read SNMP settings and communities | routerId |
| `manage_snmp_community` | Add/remove SNMP community | routerId, action, name, security?, readAccess?, writeAccess?, dryRun |
| `list_netwatch_entries` | List Netwatch monitoring entries | routerId, statusFilter?, includeDisabled? |
| `manage_netwatch_entry` | Add/remove Netwatch entry | routerId, action, host, interval?, timeout?, dryRun |
| `get_ntp_settings` | Read NTP client config | routerId |
| `manage_ntp_settings` | Update NTP servers/enabled state | routerId, servers?, enabled?, dryRun |
| `list_neighbors` | List LLDP/CDP/MNDP neighbors | routerId, interfaceFilter? |
| `list_arp_entries` | List ARP table | routerId, interfaceFilter?, staticOnly?, macFilter? |
| `bulk_execute` | Fan out tool across multiple routers | routerId, tool, params?, routerIds?, tags?, concurrency? |
| `check_router_health` | Probe router reachability | routerId |
```

- [ ] **Step 5: Final commit**

```bash
git add src/domain/tools/index.ts ROADMAP.md README.md
git commit -m "feat(v0.9): wire all v0.9 tools into allTools; flip v0.9 ✅ in docs (79 tools total)"
```

---

## Self-Review

**Spec coverage:**
- ✅ IPSec/VPN: `list_ipsec_peers`, `list_ipsec_policies`, `manage_ipsec_peer` — Task 1
- ✅ Certificates: `list_certificates`, `manage_certificate` — Task 2
- ✅ Users: `list_users`, `manage_user` — Task 3
- ✅ DHCP server/pools: `list_dhcp_servers`, `manage_dhcp_server`, `list_ip_pools`, `manage_ip_pool` — Task 4
- ✅ Queues/QoS: `list_queues`, `manage_queue` — Task 5
- ✅ VRRP: `list_vrrp_instances`, `manage_vrrp_instance` — Task 6
- ✅ SNMP: `get_snmp_settings`, `manage_snmp_community` — Task 7
- ✅ Netwatch: `list_netwatch_entries`, `manage_netwatch_entry` — Task 8
- ✅ NTP: `get_ntp_settings`, `manage_ntp_settings` — Task 9
- ✅ Neighbor/LLDP discovery: `list_neighbors` — Task 9
- ✅ ARP table: `list_arp_entries` — Task 9
- ✅ Bulk operations: `bulk_execute` with fan-out, concurrency, and partial-failure aggregation — Task 10
- ✅ Health checks: `check_router_health` with REST reachability probe — Task 11

**Placeholder scan:** No TBDs, TODOs, or "similar to Task N" references. All code blocks are complete.

**Type consistency:**
- `ToolDefinition`, `ToolContext`, `ToolResult` used uniformly across all 12 tasks
- `connectionPool?: ConnectionPool` and `routerRegistry?: RouterRegistry` added in Task 10 Step 1 and consumed in bulk-tools.ts (Task 10 Step 7)
- `createBulkTools(baseTools)` factory signature defined in Task 10, wired in Task 12
- `enrichError` + `MikroMCPError` re-throw pattern consistent across all write-tool handlers
- `snapshotPaths` arrays added to all write tools (matching patterns from SEMANTIC_KEYS in diff-engine.ts for rollback support)

**Edge cases covered:**
- `bulk_execute` throws `BULK_TARGET_REQUIRED` if neither `routerIds` nor `tags` are provided
- `bulk_execute` throws `INFRASTRUCTURE_UNAVAILABLE` if `connectionPool` is absent (development safety net)
- `bulk_execute` de-duplicates router IDs when both `routerIds` and `tags` are provided
- `check_router_health` returns `reachable=false` (not an error) on REST failure — safe for monitoring
- `manage_certificate` calls both `create` and `execute("certificate/sign")` for self-signed generation
- NTP uses `execute("ip/ntp/client/set", ...)` since `/ip/ntp/client` is a singleton settings endpoint, not a collection
- All write tools have `snapshotPaths` populated for rollback compatibility with v0.8's `rollback_change`
