# MikroMCP v1.5 — Container Depth, Network Gap-Fill & Diagnostics

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

**Goal:** Add 18 new tools covering container config/envs/mounts, DNS write, file delete, IPSec policy write, WireGuard interface management, bandwidth test, URL fetch, connection tracking, interface lists, and PPP profiles (99 → 117 tools).

**Architecture:** Each new subsystem gets its own file. Existing files (`dns-tools.ts`, `files-tools.ts`, `ipsec-tools.ts`, `wireguard-tools.ts`) are extended by appending new `ToolDefinition` objects and updating their export array. Four new files are created: `container-config-tools.ts`, `network-test-tools.ts`, `interface-list-tools.ts`, `ppp-tools.ts`. All follow the established pattern: Zod `.strict()` schema, idempotency check before write, dry-run on every write action, `enrichError` in every catch.

**Tech Stack:** TypeScript (ESM), Zod, Vitest, RouterOS REST API via `RouterOSRestClient`, `MikroMCPError` / `enrichError`.

---

## File Map

| Action | Path |
|---|---|
| Modify | `src/domain/tools/dns-tools.ts` |
| Modify | `src/domain/tools/files-tools.ts` |
| Modify | `src/domain/tools/ipsec-tools.ts` |
| Modify | `src/domain/tools/wireguard-tools.ts` |
| Create | `src/domain/tools/container-config-tools.ts` |
| Create | `src/domain/tools/network-test-tools.ts` |
| Create | `src/domain/tools/interface-list-tools.ts` |
| Create | `src/domain/tools/ppp-tools.ts` |
| Modify | `src/domain/tools/index.ts` |
| Modify | `test/unit/domain/dns-tools.test.ts` |
| Modify | `test/unit/domain/files-tools.test.ts` |
| Modify | `test/unit/domain/ipsec-tools.test.ts` |
| Modify | `test/unit/domain/wireguard-tools.test.ts` |
| Create | `test/unit/domain/container-config-tools.test.ts` |
| Create | `test/unit/domain/network-test-tools.test.ts` |
| Create | `test/unit/domain/interface-list-tools.test.ts` |
| Create | `test/unit/domain/ppp-tools.test.ts` |
| Modify | `ROADMAP.md` |
| Modify | `README.md` |
| Modify | `CHANGELOG.md` |
| Modify | `docs/wiki/Available-Tools.md` |

---

## Task 1: `manage_dns_settings`

**Files:**
- Modify: `src/domain/tools/dns-tools.ts`
- Modify: `test/unit/domain/dns-tools.test.ts`

RouterOS path: `ip/dns` (singleton). Read with `get`, write with `update(path, id, changes)` using the `.id` from the fetched record — same pattern as `manage_ntp_client`. Fields: `servers`, `allow-remote-requests`, `max-udp-packet-size`, `cache-max-ttl`, `cache-size`.

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

Append this `describe` block to `test/unit/domain/dns-tools.test.ts`:

```typescript
// At top of file, add execute to the routerClient mock where needed.
// Add this describe block at the end, before the closing of the outer describe or at top level:

describe("manage_dns_settings", () => {
  const manageDnsSettingsTool = dnsTools.find((t) => t.name === "manage_dns_settings")!;

  const CURRENT_SETTINGS = {
    ".id": "*1",
    servers: "8.8.8.8,8.8.4.4",
    "allow-remote-requests": "false",
    "max-udp-packet-size": "4096",
    "cache-max-ttl": "1w",
    "cache-size": "2048",
  };

  function makeSettingsContext(settings = CURRENT_SETTINGS) {
    return {
      routerId: "test-router",
      correlationId: "test-corr",
      routerConfig: {} as RouterConfig,
      sshClient: {} as SshClient,
      ftpClient: {} as FtpClient,
      identity: { id: "superadmin-builtin", role: "superadmin" as const, allowedRouters: [], allowedToolPatterns: [] },
      routerClient: {
        get: vi.fn().mockResolvedValue([settings]),
        update: vi.fn().mockResolvedValue(undefined),
      } as unknown as RouterOSRestClient,
    } as unknown as ToolContext;
  }

  describe("metadata", () => {
    it("exists in dnsTools", () => expect(manageDnsSettingsTool).toBeDefined());
    it("is not readOnly", () => expect(manageDnsSettingsTool.annotations.readOnlyHint).toBe(false));
    it("is not destructive", () => expect(manageDnsSettingsTool.annotations.destructiveHint).toBe(false));
    it("is idempotent", () => expect(manageDnsSettingsTool.annotations.idempotentHint).toBe(true));
  });

  describe("input schema", () => {
    it("accepts minimal input", () => {
      expect(manageDnsSettingsTool.inputSchema.safeParse({ routerId: "r1" }).success).toBe(true);
    });
    it("dryRun defaults false", () => {
      expect(manageDnsSettingsTool.inputSchema.parse({ routerId: "r1" }).dryRun).toBe(false);
    });
    it("rejects extra fields", () => {
      expect(manageDnsSettingsTool.inputSchema.safeParse({ routerId: "r1", extra: true }).success).toBe(false);
    });
    it("rejects maxUdpPacketSize out of range", () => {
      expect(manageDnsSettingsTool.inputSchema.safeParse({ routerId: "r1", maxUdpPacketSize: 100 }).success).toBe(false);
    });
  });

  describe("handler", () => {
    it("returns no_change when nothing differs", async () => {
      const ctx = makeSettingsContext();
      const result = await manageDnsSettingsTool.handler(
        { routerId: "test-router", servers: "8.8.8.8,8.8.4.4" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("no_change");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });

    it("updates changed fields", async () => {
      const ctx = makeSettingsContext();
      const result = await manageDnsSettingsTool.handler(
        { routerId: "test-router", servers: "1.1.1.1", allowRemoteRequests: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("updated");
      expect(ctx.routerClient.update).toHaveBeenCalledWith(
        "ip/dns",
        "*1",
        expect.objectContaining({ servers: "1.1.1.1", "allow-remote-requests": "true" }),
      );
    });

    it("dry_run returns diff without calling update", async () => {
      const ctx = makeSettingsContext();
      const result = await manageDnsSettingsTool.handler(
        { routerId: "test-router", servers: "1.1.1.1", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });

    it("propagates network errors", async () => {
      const ctx = makeSettingsContext();
      (ctx.routerClient.get as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("net"));
      await expect(manageDnsSettingsTool.handler({ routerId: "test-router" }, ctx)).rejects.toThrow();
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/dns-tools.test.ts 2>&1 | tail -20
```

Expected: FAIL — `manage_dns_settings` not found in `dnsTools`.

- [ ] **Step 3: Implement `manage_dns_settings` in `src/domain/tools/dns-tools.ts`**

Append before the final export line:

```typescript
const manageDnsSettingsInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    servers: z.string().optional().describe("Comma-separated upstream DNS server IPs (e.g. '8.8.8.8,1.1.1.1')"),
    allowRemoteRequests: z.boolean().optional().describe("Allow router to answer DNS queries from the network"),
    maxUdpPacketSize: z.number().int().min(512).max(65535).optional().describe("Maximum UDP packet size in bytes"),
    cacheMaxTtl: z.string().optional().describe("Maximum cache TTL (e.g. '1d', '00:30:00')"),
    cacheSize: z.number().int().min(1).optional().describe("DNS cache size in KiB"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageDnsSettingsTool: ToolDefinition = {
  name: "manage_dns_settings",
  title: "Manage DNS Settings",
  description:
    "Update DNS resolver settings (upstream servers, cache size, cache TTL, allow-remote-requests). Idempotent: returns no_change if nothing differs.",
  inputSchema: manageDnsSettingsInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  snapshotPaths: ["ip/dns"],
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageDnsSettingsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Managing DNS settings");
    try {
      const results = await context.routerClient.get<RouterOSRecord>("ip/dns");
      const current = (Array.isArray(results) && results.length > 0 ? results[0] : results) as Record<string, string>;
      const id = current[".id"];

      const changes: Record<string, string> = {};
      const diff: { property: string; before: string | null; after: string }[] = [];

      if (parsed.servers !== undefined && current.servers !== parsed.servers) {
        changes.servers = parsed.servers;
        diff.push({ property: "servers", before: current.servers ?? null, after: parsed.servers });
      }
      if (parsed.allowRemoteRequests !== undefined) {
        const next = String(parsed.allowRemoteRequests);
        if (current["allow-remote-requests"] !== next) {
          changes["allow-remote-requests"] = next;
          diff.push({ property: "allow-remote-requests", before: current["allow-remote-requests"] ?? null, after: next });
        }
      }
      if (parsed.maxUdpPacketSize !== undefined) {
        const next = String(parsed.maxUdpPacketSize);
        if (current["max-udp-packet-size"] !== next) {
          changes["max-udp-packet-size"] = next;
          diff.push({ property: "max-udp-packet-size", before: current["max-udp-packet-size"] ?? null, after: next });
        }
      }
      if (parsed.cacheMaxTtl !== undefined && current["cache-max-ttl"] !== parsed.cacheMaxTtl) {
        changes["cache-max-ttl"] = parsed.cacheMaxTtl;
        diff.push({ property: "cache-max-ttl", before: current["cache-max-ttl"] ?? null, after: parsed.cacheMaxTtl });
      }
      if (parsed.cacheSize !== undefined) {
        const next = String(parsed.cacheSize);
        if (current["cache-size"] !== next) {
          changes["cache-size"] = next;
          diff.push({ property: "cache-size", before: current["cache-size"] ?? null, after: next });
        }
      }

      if (Object.keys(changes).length === 0) {
        return {
          content: "DNS settings already match requested values. No changes made.",
          structuredContent: { action: "no_change", routerId: context.routerId },
        };
      }

      if (parsed.dryRun) {
        return {
          content: `Dry run: Would update DNS settings on ${context.routerId}.`,
          structuredContent: { action: "dry_run", diff },
        };
      }

      await context.routerClient.update("ip/dns", id, changes);
      log.info({ routerId: context.routerId, changes: Object.keys(changes) }, "DNS settings updated");
      return {
        content: `Updated DNS settings on ${context.routerId}.`,
        structuredContent: { action: "updated", routerId: context.routerId, diff },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "manage_dns_settings" });
    }
  },
};
```

Update the export at the bottom of `dns-tools.ts`:

```typescript
export const dnsTools: ToolDefinition[] = [listDnsTool, manageDnsTool, getDnsSettingsTool, manageDnsSettingsTool];
```

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

```bash
npx vitest run test/unit/domain/dns-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/dns-tools.ts test/unit/domain/dns-tools.test.ts
git commit -m "feat(tools): add manage_dns_settings"
```

---

## Task 2: `delete_file`

**Files:**
- Modify: `src/domain/tools/files-tools.ts`
- Modify: `test/unit/domain/files-tools.test.ts`

RouterOS path: `file`. Looks up by `name` field, then calls `remove(path, id)`. Returns `not_found` gracefully if already gone.

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

Append to `test/unit/domain/files-tools.test.ts`:

```typescript
describe("delete_file", () => {
  const deleteFileTool = filesTools.find((t) => t.name === "delete_file")!;

  const FILE_RECORD = { ".id": "*A", name: "flash/my-backup.backup", type: "backup", size: "1024" };

  function makeDeleteContext(files: Record<string, unknown>[] = [FILE_RECORD]) {
    return {
      routerId: "test-router",
      correlationId: "test-corr",
      routerConfig: {} as RouterConfig,
      sshClient: {} as SshClient,
      ftpClient: {} as FtpClient,
      identity: { id: "superadmin-builtin", role: "superadmin" as const, allowedRouters: [], allowedToolPatterns: [] },
      routerClient: {
        get: vi.fn().mockResolvedValue(files),
        remove: vi.fn().mockResolvedValue(undefined),
      } as unknown as RouterOSRestClient,
    } as unknown as ToolContext;
  }

  describe("metadata", () => {
    it("exists in filesTools", () => expect(deleteFileTool).toBeDefined());
    it("is not readOnly", () => expect(deleteFileTool.annotations.readOnlyHint).toBe(false));
    it("is not destructive", () => expect(deleteFileTool.annotations.destructiveHint).toBe(false));
    it("is idempotent", () => expect(deleteFileTool.annotations.idempotentHint).toBe(true));
  });

  describe("input schema", () => {
    it("requires name", () => {
      expect(deleteFileTool.inputSchema.safeParse({ routerId: "r1" }).success).toBe(false);
    });
    it("dryRun defaults false", () => {
      expect(deleteFileTool.inputSchema.parse({ routerId: "r1", name: "f.txt" }).dryRun).toBe(false);
    });
    it("rejects extra fields", () => {
      expect(deleteFileTool.inputSchema.safeParse({ routerId: "r1", name: "f.txt", extra: true }).success).toBe(false);
    });
  });

  describe("handler", () => {
    it("deletes file when found", async () => {
      const ctx = makeDeleteContext();
      const result = await deleteFileTool.handler({ routerId: "test-router", name: "flash/my-backup.backup" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("deleted");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("file", "*A");
    });

    it("returns not_found gracefully when file missing", async () => {
      const ctx = makeDeleteContext([]);
      const result = await deleteFileTool.handler({ routerId: "test-router", name: "missing.rsc" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
      expect(ctx.routerClient.remove).not.toHaveBeenCalled();
    });

    it("dry_run returns preview without calling remove", async () => {
      const ctx = makeDeleteContext();
      const result = await deleteFileTool.handler(
        { routerId: "test-router", name: "flash/my-backup.backup", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.remove).not.toHaveBeenCalled();
    });

    it("propagates network errors", async () => {
      const ctx = makeDeleteContext();
      (ctx.routerClient.get as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("net"));
      await expect(deleteFileTool.handler({ routerId: "test-router", name: "f.txt" }, ctx)).rejects.toThrow();
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/files-tools.test.ts 2>&1 | tail -20
```

Expected: FAIL — `delete_file` not found in `filesTools`.

- [ ] **Step 3: Implement `delete_file` in `src/domain/tools/files-tools.ts`**

Append before the final export line:

```typescript
const deleteFileInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    name: z.string().describe("Exact file name on the router (e.g. flash/backup.backup)"),
    dryRun: z.boolean().default(false).describe("Preview deletion without removing the file"),
  })
  .strict();

const deleteFileTool: ToolDefinition = {
  name: "delete_file",
  title: "Delete File",
  description:
    "Delete a file from the router filesystem by name. Idempotent: returns not_found gracefully if the file does not exist.",
  inputSchema: deleteFileInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = deleteFileInputSchema.parse(params);
    log.info({ routerId: context.routerId, name: parsed.name }, "Deleting file");
    try {
      const all = await context.routerClient.get<RouterOSRecord>(FILE_PATH, { limit: undefined, offset: undefined });
      const file = all.find((f) => (f as Record<string, string>).name === parsed.name) as Record<string, string> | undefined;

      if (!file) {
        return {
          content: `File "${parsed.name}" not found on ${context.routerId}. Nothing to delete.`,
          structuredContent: { action: "not_found", name: parsed.name, routerId: context.routerId },
        };
      }

      if (parsed.dryRun) {
        return {
          content: `Dry run: Would delete "${parsed.name}" from ${context.routerId}.`,
          structuredContent: { action: "dry_run", name: parsed.name, id: file[".id"], routerId: context.routerId },
        };
      }

      await context.routerClient.remove(FILE_PATH, file[".id"]);
      log.info({ name: parsed.name, id: file[".id"] }, "File deleted");
      return {
        content: `Deleted "${parsed.name}" from ${context.routerId}.`,
        structuredContent: { action: "deleted", name: parsed.name, id: file[".id"], routerId: context.routerId },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "delete_file" });
    }
  },
};
```

Update export:

```typescript
export const filesTools: ToolDefinition[] = [listFilesTool, getFileContentTool, uploadFileTool, deleteFileTool];
```

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

```bash
npx vitest run test/unit/domain/files-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/files-tools.ts test/unit/domain/files-tools.test.ts
git commit -m "feat(tools): add delete_file"
```

---

## Task 3: `manage_ipsec_policy`

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

RouterOS path: `ip/ipsec/policy`. Idempotency key: composite `src-address` + `dst-address` + `tunnel`. Actions: add, remove, enable, disable.

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

Append to `test/unit/domain/ipsec-tools.test.ts`:

```typescript
describe("manage_ipsec_policy", () => {
  const managePolicyTool = ipsecTools.find((t) => t.name === "manage_ipsec_policy")!;

  const POLICY = {
    ".id": "*1",
    "src-address": "10.0.0.0/24",
    "dst-address": "192.168.1.0/24",
    tunnel: "true",
    action: "encrypt",
    level: "require",
    disabled: "false",
  };

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

  describe("metadata", () => {
    it("exists in ipsecTools", () => expect(managePolicyTool).toBeDefined());
    it("is not readOnly", () => expect(managePolicyTool.annotations.readOnlyHint).toBe(false));
    it("is not destructive", () => expect(managePolicyTool.annotations.destructiveHint).toBe(false));
  });

  describe("input schema", () => {
    it("parses valid add input", () => {
      expect(managePolicyTool.inputSchema.safeParse({
        routerId: "r1", action: "add",
        srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", ipsecAction: "encrypt",
      }).success).toBe(true);
    });
    it("tunnel defaults false", () => {
      expect(managePolicyTool.inputSchema.parse({
        routerId: "r1", action: "add",
        srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", ipsecAction: "encrypt",
      }).tunnel).toBe(false);
    });
    it("rejects invalid action", () => {
      expect(managePolicyTool.inputSchema.safeParse({ routerId: "r1", action: "update", srcAddress: "0.0.0.0/0", dstAddress: "0.0.0.0/0" }).success).toBe(false);
    });
    it("rejects extra fields", () => {
      expect(managePolicyTool.inputSchema.safeParse({ routerId: "r1", action: "remove", srcAddress: "0.0.0.0/0", dstAddress: "0.0.0.0/0", extra: true }).success).toBe(false);
    });
  });

  describe("handler — add", () => {
    it("creates policy when not found", async () => {
      const ctx = makePolicyContext([]);
      const result = await managePolicyTool.handler(
        { routerId: "test-router", action: "add", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", tunnel: true, ipsecAction: "encrypt" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      expect(ctx.routerClient.create).toHaveBeenCalledWith("ip/ipsec/policy", expect.objectContaining({ "src-address": "10.0.0.0/24", "dst-address": "192.168.1.0/24" }));
    });

    it("returns already_exists when policy matches", async () => {
      const ctx = makePolicyContext([POLICY]);
      const result = await managePolicyTool.handler(
        { routerId: "test-router", action: "add", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", tunnel: true, ipsecAction: "encrypt" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("dry_run returns preview without create", async () => {
      const ctx = makePolicyContext([]);
      const result = await managePolicyTool.handler(
        { routerId: "test-router", action: "add", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", ipsecAction: "encrypt", dryRun: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });
  });

  describe("handler — remove", () => {
    it("removes policy when found", async () => {
      const ctx = makePolicyContext([POLICY]);
      const result = await managePolicyTool.handler(
        { routerId: "test-router", action: "remove", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", tunnel: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("ip/ipsec/policy", "*1");
    });

    it("returns not_found gracefully when already gone", async () => {
      const ctx = makePolicyContext([]);
      const result = await managePolicyTool.handler(
        { routerId: "test-router", action: "remove", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24" },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
    });
  });

  describe("handler — enable/disable", () => {
    it("disables a policy", async () => {
      const ctx = makePolicyContext([POLICY]);
      const result = await managePolicyTool.handler(
        { routerId: "test-router", action: "disable", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24", tunnel: true },
        ctx,
      );
      expect((result.structuredContent as Record<string, unknown>).action).toBe("disabled");
      expect(ctx.routerClient.update).toHaveBeenCalledWith("ip/ipsec/policy", "*1", { disabled: "true" });
    });

    it("throws NOT_FOUND on enable/disable when missing", async () => {
      const { ErrorCategory } = await import("../../../src/domain/errors/error-types.js");
      const ctx = makePolicyContext([]);
      await expect(
        managePolicyTool.handler({ routerId: "test-router", action: "enable", srcAddress: "10.0.0.0/24", dstAddress: "192.168.1.0/24" }, ctx),
      ).rejects.toMatchObject({ category: ErrorCategory.NOT_FOUND });
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/ipsec-tools.test.ts 2>&1 | tail -20
```

Expected: FAIL — `manage_ipsec_policy` not found.

- [ ] **Step 3: Implement `manage_ipsec_policy` in `src/domain/tools/ipsec-tools.ts`**

Append before the export line:

```typescript
const manageIpsecPolicyInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
    srcAddress: z.string().describe("Source CIDR — part of composite idempotency key"),
    dstAddress: z.string().describe("Destination CIDR — part of composite idempotency key"),
    tunnel: z.boolean().default(false).describe("Tunnel mode — part of composite idempotency key"),
    ipsecAction: z.enum(["encrypt", "discard", "none"]).optional().describe("IPSec action (required for add)"),
    level: z.enum(["require", "use", "unique"]).default("require").describe("SA level"),
    saSourceAddress: z.string().optional().describe("SA source IP for tunnel mode"),
    saDstAddress: z.string().optional().describe("SA destination IP for tunnel mode"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageIpsecPolicyTool: ToolDefinition = {
  name: "manage_ipsec_policy",
  title: "Manage IPSec Policy",
  description:
    "Add, remove, enable, or disable an IPSec policy. Idempotent by composite key (srcAddress + dstAddress + tunnel).",
  inputSchema: manageIpsecPolicyInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  snapshotPaths: ["ip/ipsec/policy"],
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageIpsecPolicyInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action }, "Managing IPSec policy");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("ip/ipsec/policy", { limit: undefined, offset: undefined });
      const tunnelStr = String(parsed.tunnel);
      const existing = (all as Record<string, string>[]).find(
        (p) => p["src-address"] === parsed.srcAddress && p["dst-address"] === parsed.dstAddress && p.tunnel === tunnelStr,
      );

      if (parsed.action === "add") {
        if (existing) {
          return {
            content: `IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress} already exists. No changes made.`,
            structuredContent: { action: "already_exists", policy: existing },
          };
        }
        if (!parsed.ipsecAction) {
          throw new MikroMCPError({
            category: ErrorCategory.VALIDATION,
            code: "IPSEC_POLICY_ACTION_REQUIRED",
            message: "ipsecAction is required when adding an IPSec policy.",
            recoverability: { retryable: false, suggestedAction: "Provide ipsecAction: encrypt, discard, or none." },
          });
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would add IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress}.`,
            structuredContent: {
              action: "dry_run",
              diff: [
                { property: "src-address", before: null, after: parsed.srcAddress },
                { property: "dst-address", before: null, after: parsed.dstAddress },
                { property: "action", before: null, after: parsed.ipsecAction },
              ],
            },
          };
        }
        const body: Record<string, string> = {
          "src-address": parsed.srcAddress,
          "dst-address": parsed.dstAddress,
          tunnel: tunnelStr,
          action: parsed.ipsecAction,
          level: parsed.level,
        };
        if (parsed.saSourceAddress) body["sa-src-address"] = parsed.saSourceAddress;
        if (parsed.saDstAddress) body["sa-dst-address"] = parsed.saDstAddress;
        const created = await context.routerClient.create("ip/ipsec/policy", body);
        log.info({ id: created[".id"] }, "IPSec policy added");
        return {
          content: `Added IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress}.`,
          structuredContent: { action: "created", policy: created },
        };
      }

      if (parsed.action === "remove") {
        if (!existing) {
          return {
            content: `IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress} not found. Nothing to remove.`,
            structuredContent: { action: "not_found", srcAddress: parsed.srcAddress, dstAddress: parsed.dstAddress },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress}.`,
            structuredContent: { action: "dry_run", diff: [{ property: "src-address", before: parsed.srcAddress, after: null }] },
          };
        }
        await context.routerClient.remove("ip/ipsec/policy", existing[".id"]);
        log.info({ id: existing[".id"] }, "IPSec policy removed");
        return {
          content: `Removed IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress}.`,
          structuredContent: { action: "removed", id: existing[".id"] },
        };
      }

      // enable / disable
      if (!existing) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "IPSEC_POLICY_NOT_FOUND",
          message: `IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress} not found.`,
          recoverability: { retryable: false, suggestedAction: "Verify with list_ipsec_policies.", alternativeTools: ["list_ipsec_policies"] },
        });
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would ${parsed.action} IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress}.`,
          structuredContent: { action: "dry_run", diff: [{ property: "disabled", before: existing.disabled, after: parsed.action === "disable" ? "true" : "false" }] },
        };
      }
      await context.routerClient.update("ip/ipsec/policy", existing[".id"], { disabled: parsed.action === "disable" ? "true" : "false" });
      const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
      log.info({ id: existing[".id"], action: resultAction }, "IPSec policy updated");
      return {
        content: `IPSec policy ${parsed.srcAddress} → ${parsed.dstAddress} ${resultAction}.`,
        structuredContent: { action: resultAction, id: existing[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_ipsec_policy" });
    }
  },
};
```

Update export:

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

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

```bash
npx vitest run test/unit/domain/ipsec-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/ipsec-tools.ts test/unit/domain/ipsec-tools.test.ts
git commit -m "feat(tools): add manage_ipsec_policy"
```

---

## Task 4: `manage_wireguard_interface`

**Files:**
- Modify: `src/domain/tools/wireguard-tools.ts`
- Modify: `test/unit/domain/wireguard-tools.test.ts`

RouterOS path: `interface/wireguard`. Idempotency key: `name`. RouterOS auto-generates the private key on create — never pass it in. Return `public-key` from the created record.

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

Append to `test/unit/domain/wireguard-tools.test.ts`:

```typescript
describe("manage_wireguard_interface", () => {
  const manageWgIfaceTool = wireguardTools.find((t) => t.name === "manage_wireguard_interface")!;

  const WG_IFACE = { ".id": "*1", name: "wg0", "listen-port": "51820", mtu: "1420", "public-key": "AAAA==", disabled: "false", running: "true" };

  function makeWgIfaceContext(ifaces: Record<string, unknown>[] = []) {
    return {
      routerId: "test-router",
      correlationId: "test-corr",
      routerConfig: {} as RouterConfig,
      sshClient: {} as SshClient,
      ftpClient: {} as FtpClient,
      identity: { id: "superadmin-builtin", role: "superadmin" as const, allowedRouters: [], allowedToolPatterns: [] },
      routerClient: {
        get: vi.fn().mockResolvedValue(ifaces),
        create: vi.fn().mockResolvedValue({ ".id": "*2", name: "wg1", "public-key": "BBBB==" }),
        update: vi.fn().mockResolvedValue(undefined),
        remove: vi.fn().mockResolvedValue(undefined),
      } as unknown as RouterOSRestClient,
    } as unknown as ToolContext;
  }

  describe("metadata", () => {
    it("exists in wireguardTools", () => expect(manageWgIfaceTool).toBeDefined());
    it("is not readOnly", () => expect(manageWgIfaceTool.annotations.readOnlyHint).toBe(false));
    it("is not destructive", () => expect(manageWgIfaceTool.annotations.destructiveHint).toBe(false));
  });

  describe("input schema", () => {
    it("parses valid add", () => {
      expect(manageWgIfaceTool.inputSchema.safeParse({ routerId: "r1", action: "add", name: "wg1" }).success).toBe(true);
    });
    it("mtu defaults 1420", () => {
      expect(manageWgIfaceTool.inputSchema.parse({ routerId: "r1", action: "add", name: "wg1" }).mtu).toBe(1420);
    });
    it("rejects extra fields", () => {
      expect(manageWgIfaceTool.inputSchema.safeParse({ routerId: "r1", action: "add", name: "wg1", extra: true }).success).toBe(false);
    });
  });

  describe("handler — add", () => {
    it("creates interface when not found", async () => {
      const ctx = makeWgIfaceContext([]);
      const result = await manageWgIfaceTool.handler({ routerId: "test-router", action: "add", name: "wg1" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      expect((result.structuredContent as Record<string, unknown>).publicKey).toBe("BBBB==");
      expect(ctx.routerClient.create).toHaveBeenCalledWith("interface/wireguard", expect.objectContaining({ name: "wg1" }));
    });

    it("returns already_exists when found", async () => {
      const ctx = makeWgIfaceContext([WG_IFACE]);
      const result = await manageWgIfaceTool.handler({ routerId: "test-router", action: "add", name: "wg0" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("dry_run returns preview without create", async () => {
      const ctx = makeWgIfaceContext([]);
      const result = await manageWgIfaceTool.handler({ routerId: "test-router", action: "add", name: "wg1", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });
  });

  describe("handler — remove", () => {
    it("removes interface when found", async () => {
      const ctx = makeWgIfaceContext([WG_IFACE]);
      const result = await manageWgIfaceTool.handler({ routerId: "test-router", action: "remove", name: "wg0" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("interface/wireguard", "*1");
    });

    it("returns not_found gracefully when missing", async () => {
      const ctx = makeWgIfaceContext([]);
      const result = await manageWgIfaceTool.handler({ routerId: "test-router", action: "remove", name: "wg1" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
    });
  });

  describe("handler — enable/disable", () => {
    it("disables an interface", async () => {
      const ctx = makeWgIfaceContext([WG_IFACE]);
      const result = await manageWgIfaceTool.handler({ routerId: "test-router", action: "disable", name: "wg0" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("disabled");
      expect(ctx.routerClient.update).toHaveBeenCalledWith("interface/wireguard", "*1", { disabled: "true" });
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/wireguard-tools.test.ts 2>&1 | tail -20
```

Expected: FAIL — `manage_wireguard_interface` not found.

- [ ] **Step 3: Implement `manage_wireguard_interface` in `src/domain/tools/wireguard-tools.ts`**

Append before the export line:

```typescript
const manageWgIfaceInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove", "enable", "disable"]).describe("Action to perform"),
    name: z.string().describe("Interface name — idempotency key (e.g. wg0)"),
    listenPort: z.number().int().min(1).max(65535).optional().describe("UDP listen port (RouterOS picks one if omitted)"),
    mtu: z.number().int().min(1280).max(65535).default(1420).describe("MTU (default 1420)"),
    comment: z.string().optional().describe("Optional comment"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageWgIfaceTool: ToolDefinition = {
  name: "manage_wireguard_interface",
  title: "Manage WireGuard Interface",
  description:
    "Add, remove, enable, or disable a WireGuard interface. Idempotent by name. RouterOS generates the private key on create — it is never passed in. The public key is returned after creation.",
  inputSchema: manageWgIfaceInputSchema,
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageWgIfaceInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing WireGuard interface");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("interface/wireguard", { limit: undefined, offset: undefined });
      const existing = (all as Record<string, string>[]).find((i) => i.name === parsed.name);

      if (parsed.action === "add") {
        if (existing) {
          return {
            content: `WireGuard interface "${parsed.name}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", name: parsed.name, publicKey: existing["public-key"] ?? null },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would create WireGuard interface "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff: [{ property: "name", before: null, after: parsed.name }] },
          };
        }
        const body: Record<string, string> = { name: parsed.name, mtu: String(parsed.mtu) };
        if (parsed.listenPort !== undefined) body["listen-port"] = String(parsed.listenPort);
        if (parsed.comment) body.comment = parsed.comment;
        const created = await context.routerClient.create("interface/wireguard", body) as Record<string, string>;
        log.info({ name: parsed.name, id: created[".id"] }, "WireGuard interface created");
        return {
          content: `Created WireGuard interface "${parsed.name}". Public key: ${created["public-key"] ?? "unknown"}`,
          structuredContent: { action: "created", name: parsed.name, id: created[".id"], publicKey: created["public-key"] ?? null },
        };
      }

      if (parsed.action === "remove") {
        if (!existing) {
          return {
            content: `WireGuard interface "${parsed.name}" not found. Nothing to remove.`,
            structuredContent: { action: "not_found", name: parsed.name },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would remove WireGuard interface "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] },
          };
        }
        await context.routerClient.remove("interface/wireguard", existing[".id"]);
        log.info({ name: parsed.name }, "WireGuard interface removed");
        return {
          content: `Removed WireGuard interface "${parsed.name}".`,
          structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] },
        };
      }

      // enable / disable
      if (!existing) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "WIREGUARD_IFACE_NOT_FOUND",
          message: `WireGuard interface "${parsed.name}" not found.`,
          recoverability: { retryable: false, suggestedAction: "Verify with list_wireguard_interfaces.", alternativeTools: ["list_wireguard_interfaces"] },
        });
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would ${parsed.action} WireGuard interface "${parsed.name}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "disabled", before: existing.disabled, after: parsed.action === "disable" ? "true" : "false" }] },
        };
      }
      await context.routerClient.update("interface/wireguard", existing[".id"], { disabled: parsed.action === "disable" ? "true" : "false" });
      const resultAction = parsed.action === "disable" ? "disabled" : "enabled";
      log.info({ name: parsed.name, action: resultAction }, "WireGuard interface updated");
      return {
        content: `WireGuard interface "${parsed.name}" ${resultAction}.`,
        structuredContent: { action: resultAction, name: parsed.name, id: existing[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_wireguard_interface" });
    }
  },
};
```

Update export:

```typescript
export const wireguardTools: ToolDefinition[] = [listWgTool, listPeersTool, managePeerTool, manageWgIfaceTool];
```

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

```bash
npx vitest run test/unit/domain/wireguard-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/wireguard-tools.ts test/unit/domain/wireguard-tools.test.ts
git commit -m "feat(tools): add manage_wireguard_interface"
```

---

## Task 5: Container Config Tools

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

RouterOS paths: `container/config` (singleton), `container/envs`, `container/mounts`. Six tools: `get_container_config`, `manage_container_config`, `list_container_envs`, `manage_container_env`, `list_container_mounts`, `manage_container_mount`.

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

```typescript
// test/unit/domain/container-config-tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { containerConfigTools } from "../../../src/domain/tools/container-config-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";
import { ErrorCategory } from "../../../src/domain/errors/error-types.js";

const CONFIG = { ".id": "*1", "registry-url": "https://registry-1.docker.io", "ram-high": "128", "veth-interface": "veth1" };
const ENV1 = { ".id": "*1", name: "my-app", key: "DEBUG", value: "true" };
const MOUNT1 = { ".id": "*1", name: "app-data", src: "/mnt/data", dst: "/data" };

function makeContext(
  configRecords: Record<string, unknown>[] = [CONFIG],
  envRecords: Record<string, unknown>[] = [],
  mountRecords: Record<string, unknown>[] = [],
): ToolContext {
  const getMock = vi.fn().mockImplementation((path: string) => {
    if (path === "container/config") return Promise.resolve(configRecords);
    if (path === "container/envs") return Promise.resolve(envRecords);
    if (path === "container/mounts") return Promise.resolve(mountRecords);
    return Promise.resolve([]);
  });
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "superadmin-builtin", role: "superadmin" as const, allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: getMock,
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      update: vi.fn().mockResolvedValue(undefined),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  } as unknown as ToolContext;
}

const [getConfigTool, manageConfigTool, listEnvsTool, manageEnvTool, listMountsTool, manageMountTool] = containerConfigTools;

describe("containerConfigTools", () => {
  describe("metadata", () => {
    it("exports 6 tools", () => expect(containerConfigTools).toHaveLength(6));
    it("has correct names", () => {
      expect(getConfigTool.name).toBe("get_container_config");
      expect(manageConfigTool.name).toBe("manage_container_config");
      expect(listEnvsTool.name).toBe("list_container_envs");
      expect(manageEnvTool.name).toBe("manage_container_env");
      expect(listMountsTool.name).toBe("list_container_mounts");
      expect(manageMountTool.name).toBe("manage_container_mount");
    });
    it("get/list tools are readOnly", () => {
      expect(getConfigTool.annotations.readOnlyHint).toBe(true);
      expect(listEnvsTool.annotations.readOnlyHint).toBe(true);
      expect(listMountsTool.annotations.readOnlyHint).toBe(true);
    });
    it("manage tools are not readOnly", () => {
      expect(manageConfigTool.annotations.readOnlyHint).toBe(false);
      expect(manageEnvTool.annotations.readOnlyHint).toBe(false);
      expect(manageMountTool.annotations.readOnlyHint).toBe(false);
    });
  });

  describe("get_container_config", () => {
    it("returns config fields", async () => {
      const ctx = makeContext();
      const result = await getConfigTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.registryUrl).toBe("https://registry-1.docker.io");
      expect(sc.ramHighMb).toBe("128");
    });
  });

  describe("manage_container_config", () => {
    it("returns no_change when nothing differs", async () => {
      const ctx = makeContext();
      const result = await manageConfigTool.handler({ routerId: "test-router", registryUrl: "https://registry-1.docker.io" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("no_change");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });

    it("updates changed fields", async () => {
      const ctx = makeContext();
      const result = await manageConfigTool.handler({ routerId: "test-router", registryUrl: "https://my.registry.io" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("updated");
      expect(ctx.routerClient.update).toHaveBeenCalledWith("container/config", "*1", expect.objectContaining({ "registry-url": "https://my.registry.io" }));
    });

    it("dry_run returns diff without update", async () => {
      const ctx = makeContext();
      const result = await manageConfigTool.handler({ routerId: "test-router", registryUrl: "https://my.registry.io", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });
  });

  describe("list_container_envs", () => {
    it("returns all envs without filter", async () => {
      const ctx = makeContext([], [ENV1, { ".id": "*2", name: "other-app", key: "PORT", value: "8080" }]);
      const result = await listEnvsTool.handler({ routerId: "test-router" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).envs as unknown[]).length).toBe(2);
    });

    it("filters by container name", async () => {
      const ctx = makeContext([], [ENV1, { ".id": "*2", name: "other-app", key: "PORT", value: "8080" }]);
      const result = await listEnvsTool.handler({ routerId: "test-router", name: "my-app" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).envs as unknown[]).length).toBe(1);
    });
  });

  describe("manage_container_env", () => {
    it("creates env when not found", async () => {
      const ctx = makeContext([], []);
      const result = await manageEnvTool.handler({ routerId: "test-router", action: "add", name: "my-app", key: "DEBUG", value: "true" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
    });

    it("returns already_exists with same value", async () => {
      const ctx = makeContext([], [ENV1]);
      const result = await manageEnvTool.handler({ routerId: "test-router", action: "add", name: "my-app", key: "DEBUG", value: "true" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("throws CONFLICT with different value", async () => {
      const ctx = makeContext([], [ENV1]);
      await expect(
        manageEnvTool.handler({ routerId: "test-router", action: "add", name: "my-app", key: "DEBUG", value: "false" }, ctx),
      ).rejects.toMatchObject({ category: ErrorCategory.CONFLICT });
    });

    it("removes env when found", async () => {
      const ctx = makeContext([], [ENV1]);
      const result = await manageEnvTool.handler({ routerId: "test-router", action: "remove", name: "my-app", key: "DEBUG" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("container/envs", "*1");
    });

    it("returns not_found gracefully on remove when missing", async () => {
      const ctx = makeContext([], []);
      const result = await manageEnvTool.handler({ routerId: "test-router", action: "remove", name: "my-app", key: "DEBUG" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
    });

    it("dry_run returns preview without create", async () => {
      const ctx = makeContext([], []);
      const result = await manageEnvTool.handler({ routerId: "test-router", action: "add", name: "my-app", key: "DEBUG", value: "true", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });
  });

  describe("list_container_mounts", () => {
    it("returns all mounts", async () => {
      const ctx = makeContext([], [], [MOUNT1]);
      const result = await listMountsTool.handler({ routerId: "test-router" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).mounts as unknown[]).length).toBe(1);
    });
  });

  describe("manage_container_mount", () => {
    it("creates mount when not found", async () => {
      const ctx = makeContext([], [], []);
      const result = await manageMountTool.handler({ routerId: "test-router", action: "add", name: "app-data", src: "/mnt/data", dst: "/data" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
    });

    it("returns already_exists when mount matches src+dst", async () => {
      const ctx = makeContext([], [], [MOUNT1]);
      const result = await manageMountTool.handler({ routerId: "test-router", action: "add", name: "app-data", src: "/mnt/data", dst: "/data" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
    });

    it("throws CONFLICT when name exists with different paths", async () => {
      const ctx = makeContext([], [], [MOUNT1]);
      await expect(
        manageMountTool.handler({ routerId: "test-router", action: "add", name: "app-data", src: "/other", dst: "/data" }, ctx),
      ).rejects.toMatchObject({ category: ErrorCategory.CONFLICT });
    });

    it("removes mount when found", async () => {
      const ctx = makeContext([], [], [MOUNT1]);
      const result = await manageMountTool.handler({ routerId: "test-router", action: "remove", name: "app-data" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("container/mounts", "*1");
    });

    it("dry_run returns preview without create", async () => {
      const ctx = makeContext([], [], []);
      const result = await manageMountTool.handler({ routerId: "test-router", action: "add", name: "app-data", src: "/mnt/data", dst: "/data", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/container-config-tools.test.ts 2>&1 | tail -10
```

Expected: FAIL — cannot find module `container-config-tools.js`.

- [ ] **Step 3: Implement `src/domain/tools/container-config-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("container-config-tools");

// ---------------------------------------------------------------------------
// get_container_config
// ---------------------------------------------------------------------------

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

const getContainerConfigTool: ToolDefinition = {
  name: "get_container_config",
  title: "Get Container Config",
  description: "Read global container configuration: registry URL, RAM high-water mark, and veth interface.",
  inputSchema: getContainerConfigInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    getContainerConfigInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Getting container config");
    try {
      const results = await context.routerClient.get<RouterOSRecord>("container/config");
      const cfg = (Array.isArray(results) && results.length > 0 ? results[0] : results) as Record<string, string>;
      return {
        content: `Container config on ${context.routerId}: registry=${cfg["registry-url"] ?? "none"} ram-high=${cfg["ram-high"] ?? "?"}MB veth=${cfg["veth-interface"] ?? "none"}`,
        structuredContent: { routerId: context.routerId, registryUrl: cfg["registry-url"] ?? null, ramHighMb: cfg["ram-high"] ?? null, vethInterface: cfg["veth-interface"] ?? null },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "get_container_config" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_container_config
// ---------------------------------------------------------------------------

const manageContainerConfigInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    registryUrl: z.string().optional().describe("Container registry URL"),
    ramHighMb: z.number().int().min(1).optional().describe("RAM high-water mark in MB"),
    vethInterface: z.string().optional().describe("Veth interface name for container networking"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageContainerConfigTool: ToolDefinition = {
  name: "manage_container_config",
  title: "Manage Container Config",
  description: "Update global container settings. Idempotent: returns no_change if nothing differs.",
  inputSchema: manageContainerConfigInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  snapshotPaths: ["container/config"],
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageContainerConfigInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Managing container config");
    try {
      const results = await context.routerClient.get<RouterOSRecord>("container/config");
      const current = (Array.isArray(results) && results.length > 0 ? results[0] : results) as Record<string, string>;
      const id = current[".id"];

      const changes: Record<string, string> = {};
      const diff: { property: string; before: string | null; after: string }[] = [];

      if (parsed.registryUrl !== undefined && current["registry-url"] !== parsed.registryUrl) {
        changes["registry-url"] = parsed.registryUrl;
        diff.push({ property: "registry-url", before: current["registry-url"] ?? null, after: parsed.registryUrl });
      }
      if (parsed.ramHighMb !== undefined) {
        const next = String(parsed.ramHighMb);
        if (current["ram-high"] !== next) {
          changes["ram-high"] = next;
          diff.push({ property: "ram-high", before: current["ram-high"] ?? null, after: next });
        }
      }
      if (parsed.vethInterface !== undefined && current["veth-interface"] !== parsed.vethInterface) {
        changes["veth-interface"] = parsed.vethInterface;
        diff.push({ property: "veth-interface", before: current["veth-interface"] ?? null, after: parsed.vethInterface });
      }

      if (Object.keys(changes).length === 0) {
        return { content: "Container config already matches. No changes made.", structuredContent: { action: "no_change", routerId: context.routerId } };
      }
      if (parsed.dryRun) {
        return { content: `Dry run: Would update container config on ${context.routerId}.`, structuredContent: { action: "dry_run", diff } };
      }
      await context.routerClient.update("container/config", id, changes);
      log.info({ routerId: context.routerId }, "Container config updated");
      return { content: `Updated container config on ${context.routerId}.`, structuredContent: { action: "updated", routerId: context.routerId, diff } };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "manage_container_config" });
    }
  },
};

// ---------------------------------------------------------------------------
// list_container_envs
// ---------------------------------------------------------------------------

const listContainerEnvsInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    name: z.string().optional().describe("Filter by container name (exact match)"),
    limit: z.number().int().min(1).max(500).default(100).describe("Maximum entries to return"),
  })
  .strict();

const listContainerEnvsTool: ToolDefinition = {
  name: "list_container_envs",
  title: "List Container Envs",
  description: "List container environment variable entries, optionally filtered by container name.",
  inputSchema: listContainerEnvsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listContainerEnvsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing container envs");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("container/envs", { limit: undefined, offset: undefined });
      const filtered = parsed.name ? (all as Record<string, string>[]).filter((e) => e.name === parsed.name) : (all as Record<string, string>[]);
      const envs = filtered.slice(0, parsed.limit);
      return { content: `Container envs on ${context.routerId}: ${envs.length} returned`, structuredContent: { routerId: context.routerId, envs, total: all.length, returned: envs.length } };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_container_envs" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_container_env
// ---------------------------------------------------------------------------

const manageContainerEnvInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Action to perform"),
    name: z.string().describe("Container name"),
    key: z.string().describe("Environment variable name — part of idempotency key"),
    value: z.string().optional().describe("Environment variable value (required for add)"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageContainerEnvTool: ToolDefinition = {
  name: "manage_container_env",
  title: "Manage Container Env",
  description: "Add or remove a container environment variable. Idempotent by name+key. add returns already_exists if the entry exists with the same value; throws CONFLICT if the value differs.",
  inputSchema: manageContainerEnvInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageContainerEnvInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name, key: parsed.key }, "Managing container env");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("container/envs", { limit: undefined, offset: undefined });
      const existing = (all as Record<string, string>[]).find((e) => e.name === parsed.name && e.key === parsed.key);

      if (parsed.action === "add") {
        if (existing) {
          if (existing.value === parsed.value) {
            return { content: `Env ${parsed.name}.${parsed.key} already exists with same value.`, structuredContent: { action: "already_exists", name: parsed.name, key: parsed.key } };
          }
          throw new MikroMCPError({
            category: ErrorCategory.CONFLICT,
            code: "CONTAINER_ENV_CONFLICT",
            message: `Env ${parsed.name}.${parsed.key} exists with a different value.`,
            details: { existingValue: existing.value, requestedValue: parsed.value },
            recoverability: { retryable: false, suggestedAction: "Remove the existing entry first.", alternativeTools: ["manage_container_env"] },
          });
        }
        if (!parsed.value) {
          throw new MikroMCPError({ category: ErrorCategory.VALIDATION, code: "ENV_VALUE_REQUIRED", message: "value is required for add.", recoverability: { retryable: false, suggestedAction: "Provide the value parameter." } });
        }
        if (parsed.dryRun) {
          return { content: `Dry run: Would add env ${parsed.name}.${parsed.key}=${parsed.value}.`, structuredContent: { action: "dry_run", diff: [{ property: "value", before: null, after: parsed.value }] } };
        }
        const created = await context.routerClient.create("container/envs", { name: parsed.name, key: parsed.key, value: parsed.value });
        log.info({ name: parsed.name, key: parsed.key, id: created[".id"] }, "Container env added");
        return { content: `Added env ${parsed.name}.${parsed.key}.`, structuredContent: { action: "created", name: parsed.name, key: parsed.key, id: created[".id"] } };
      }

      // remove
      if (!existing) {
        return { content: `Env ${parsed.name}.${parsed.key} not found. Nothing to remove.`, structuredContent: { action: "not_found", name: parsed.name, key: parsed.key } };
      }
      if (parsed.dryRun) {
        return { content: `Dry run: Would remove env ${parsed.name}.${parsed.key}.`, structuredContent: { action: "dry_run", diff: [{ property: "key", before: parsed.key, after: null }] } };
      }
      await context.routerClient.remove("container/envs", existing[".id"]);
      log.info({ name: parsed.name, key: parsed.key }, "Container env removed");
      return { content: `Removed env ${parsed.name}.${parsed.key}.`, structuredContent: { action: "removed", name: parsed.name, key: parsed.key } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_container_env" });
    }
  },
};

// ---------------------------------------------------------------------------
// list_container_mounts
// ---------------------------------------------------------------------------

const listContainerMountsInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    name: z.string().optional().describe("Filter by mount name (exact match)"),
    limit: z.number().int().min(1).max(500).default(100).describe("Maximum entries to return"),
  })
  .strict();

const listContainerMountsTool: ToolDefinition = {
  name: "list_container_mounts",
  title: "List Container Mounts",
  description: "List container volume mount definitions with source path, destination path, and mount name.",
  inputSchema: listContainerMountsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listContainerMountsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing container mounts");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("container/mounts", { limit: undefined, offset: undefined });
      const filtered = parsed.name ? (all as Record<string, string>[]).filter((m) => m.name === parsed.name) : (all as Record<string, string>[]);
      const mounts = filtered.slice(0, parsed.limit);
      return { content: `Container mounts on ${context.routerId}: ${mounts.length} returned`, structuredContent: { routerId: context.routerId, mounts, total: all.length, returned: mounts.length } };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_container_mounts" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_container_mount
// ---------------------------------------------------------------------------

const manageContainerMountInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Action to perform"),
    name: z.string().describe("Mount name — idempotency key"),
    src: z.string().optional().describe("Host source path (required for add)"),
    dst: z.string().optional().describe("Container destination path (required for add)"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageContainerMountTool: ToolDefinition = {
  name: "manage_container_mount",
  title: "Manage Container Mount",
  description: "Add or remove a container volume mount. Idempotent by name: add returns already_exists if the mount exists with matching src/dst; throws CONFLICT if name exists with different paths.",
  inputSchema: manageContainerMountInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageContainerMountInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing container mount");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("container/mounts", { limit: undefined, offset: undefined });
      const existing = (all as Record<string, string>[]).find((m) => m.name === parsed.name);

      if (parsed.action === "add") {
        if (existing) {
          if (existing.src === parsed.src && existing.dst === parsed.dst) {
            return { content: `Mount "${parsed.name}" already exists with same paths.`, structuredContent: { action: "already_exists", name: parsed.name } };
          }
          throw new MikroMCPError({
            category: ErrorCategory.CONFLICT,
            code: "CONTAINER_MOUNT_CONFLICT",
            message: `Mount "${parsed.name}" exists with different src/dst paths.`,
            details: { existing: { src: existing.src, dst: existing.dst }, requested: { src: parsed.src, dst: parsed.dst } },
            recoverability: { retryable: false, suggestedAction: "Remove the existing mount first.", alternativeTools: ["manage_container_mount"] },
          });
        }
        if (!parsed.src || !parsed.dst) {
          throw new MikroMCPError({ category: ErrorCategory.VALIDATION, code: "MOUNT_PATHS_REQUIRED", message: "src and dst are required for add.", recoverability: { retryable: false, suggestedAction: "Provide src and dst parameters." } });
        }
        if (parsed.dryRun) {
          return { content: `Dry run: Would add mount "${parsed.name}" ${parsed.src} → ${parsed.dst}.`, structuredContent: { action: "dry_run", diff: [{ property: "name", before: null, after: parsed.name }] } };
        }
        const created = await context.routerClient.create("container/mounts", { name: parsed.name, src: parsed.src, dst: parsed.dst });
        log.info({ name: parsed.name, id: created[".id"] }, "Container mount added");
        return { content: `Added mount "${parsed.name}".`, structuredContent: { action: "created", name: parsed.name, id: created[".id"] } };
      }

      // remove
      if (!existing) {
        return { content: `Mount "${parsed.name}" not found. Nothing to remove.`, structuredContent: { action: "not_found", name: parsed.name } };
      }
      if (parsed.dryRun) {
        return { content: `Dry run: Would remove mount "${parsed.name}".`, structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] } };
      }
      await context.routerClient.remove("container/mounts", existing[".id"]);
      log.info({ name: parsed.name }, "Container mount removed");
      return { content: `Removed mount "${parsed.name}".`, structuredContent: { action: "removed", name: parsed.name } };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_container_mount" });
    }
  },
};

export const containerConfigTools: ToolDefinition[] = [
  getContainerConfigTool,
  manageContainerConfigTool,
  listContainerEnvsTool,
  manageContainerEnvTool,
  listContainerMountsTool,
  manageContainerMountTool,
];
```

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

```bash
npx vitest run test/unit/domain/container-config-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/container-config-tools.ts test/unit/domain/container-config-tools.test.ts
git commit -m "feat(tools): add container config, env, and mount tools"
```

---

## Task 6: Network Test Tools

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

Note: `diagnostic-tools.ts` already exists with ping/traceroute/torch/etc. This is a separate file for bandwidth_test, fetch_url, and list_connections. RouterOS paths: `tool/bandwidth-test` (execute), `tool/fetch` (execute), `ip/firewall/connection` (get).

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

```typescript
// test/unit/domain/network-test-tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { networkTestTools } from "../../../src/domain/tools/network-test-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 BW_RESULT = { "tx-current": "100000000", "rx-current": "50000000", "lost-packets": "0" };
const FETCH_RESULT = { status: "200", data: "Hello World" };
const CONN1 = { ".id": "*1", "src-address": "10.0.0.1:12345", "dst-address": "8.8.8.8:443", protocol: "tcp" };

function makeContext(executeResult: unknown = {}, getResult: unknown[] = []) {
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "superadmin-builtin", role: "superadmin" as const, allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      execute: vi.fn().mockResolvedValue(executeResult),
      get: vi.fn().mockResolvedValue(getResult),
    } as unknown as RouterOSRestClient,
  } as unknown as ToolContext;
}

const [bandwidthTestTool, fetchUrlTool, listConnectionsTool] = networkTestTools;

describe("networkTestTools", () => {
  describe("metadata", () => {
    it("exports 3 tools", () => expect(networkTestTools).toHaveLength(3));
    it("has correct names", () => {
      expect(bandwidthTestTool.name).toBe("bandwidth_test");
      expect(fetchUrlTool.name).toBe("fetch_url");
      expect(listConnectionsTool.name).toBe("list_connections");
    });
    it("all tools are readOnly", () => {
      expect(bandwidthTestTool.annotations.readOnlyHint).toBe(true);
      expect(fetchUrlTool.annotations.readOnlyHint).toBe(true);
      expect(listConnectionsTool.annotations.readOnlyHint).toBe(true);
    });
    it("none are destructive", () => {
      expect(bandwidthTestTool.annotations.destructiveHint).toBe(false);
      expect(fetchUrlTool.annotations.destructiveHint).toBe(false);
      expect(listConnectionsTool.annotations.destructiveHint).toBe(false);
    });
  });

  describe("bandwidth_test input schema", () => {
    it("requires address", () => expect(bandwidthTestTool.inputSchema.safeParse({ routerId: "r1" }).success).toBe(false));
    it("defaults protocol tcp", () => expect(bandwidthTestTool.inputSchema.parse({ routerId: "r1", address: "10.0.0.1" }).protocol).toBe("tcp"));
    it("defaults direction both", () => expect(bandwidthTestTool.inputSchema.parse({ routerId: "r1", address: "10.0.0.1" }).direction).toBe("both"));
    it("defaults duration 5", () => expect(bandwidthTestTool.inputSchema.parse({ routerId: "r1", address: "10.0.0.1" }).duration).toBe(5));
    it("rejects duration above 30", () => expect(bandwidthTestTool.inputSchema.safeParse({ routerId: "r1", address: "10.0.0.1", duration: 60 }).success).toBe(false));
    it("rejects extra fields", () => expect(bandwidthTestTool.inputSchema.safeParse({ routerId: "r1", address: "10.0.0.1", extra: true }).success).toBe(false));
  });

  describe("bandwidth_test handler", () => {
    it("calls tool/bandwidth-test and returns throughput", async () => {
      const ctx = makeContext(BW_RESULT);
      const result = await bandwidthTestTool.handler({ routerId: "test-router", address: "10.0.0.2" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.txMbps).toBeDefined();
      expect(sc.rxMbps).toBeDefined();
      expect(ctx.routerClient.execute).toHaveBeenCalledWith(
        "tool/bandwidth-test",
        expect.objectContaining({ address: "10.0.0.2", protocol: "tcp", direction: "both" }),
      );
    });

    it("propagates errors", async () => {
      const ctx = makeContext();
      (ctx.routerClient.execute as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("net"));
      await expect(bandwidthTestTool.handler({ routerId: "test-router", address: "10.0.0.2" }, ctx)).rejects.toThrow();
    });
  });

  describe("fetch_url input schema", () => {
    it("requires url", () => expect(fetchUrlTool.inputSchema.safeParse({ routerId: "r1" }).success).toBe(false));
    it("defaults method GET", () => expect(fetchUrlTool.inputSchema.parse({ routerId: "r1", url: "http://example.com" }).method).toBe("GET"));
    it("rejects extra fields", () => expect(fetchUrlTool.inputSchema.safeParse({ routerId: "r1", url: "http://x.com", extra: true }).success).toBe(false));
  });

  describe("fetch_url handler", () => {
    it("calls tool/fetch and returns status + body", async () => {
      const ctx = makeContext(FETCH_RESULT);
      const result = await fetchUrlTool.handler({ routerId: "test-router", url: "http://example.com" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.statusCode).toBe("200");
      expect(sc.body).toBe("Hello World");
      expect(ctx.routerClient.execute).toHaveBeenCalledWith(
        "tool/fetch",
        expect.objectContaining({ url: "http://example.com", "http-method": "GET" }),
      );
    });

    it("returns outputFile path instead of body when outputFile provided", async () => {
      const ctx = makeContext({ status: "200" });
      const result = await fetchUrlTool.handler({ routerId: "test-router", url: "http://example.com", outputFile: "flash/response.txt" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.outputFile).toBe("flash/response.txt");
      expect(sc.body).toBeUndefined();
    });

    it("truncates body over 64KB", async () => {
      const bigBody = "x".repeat(70000);
      const ctx = makeContext({ status: "200", data: bigBody });
      const result = await fetchUrlTool.handler({ routerId: "test-router", url: "http://example.com" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.body as string).length).toBeLessThanOrEqual(65537 + 15);
      expect(sc.body as string).toContain("[TRUNCATED]");
    });
  });

  describe("list_connections", () => {
    it("returns all connections without filter", async () => {
      const ctx = makeContext({}, [CONN1, { ".id": "*2", "src-address": "10.0.0.2:9000", "dst-address": "1.1.1.1:80", protocol: "tcp" }]);
      const result = await listConnectionsTool.handler({ routerId: "test-router" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).connections as unknown[]).length).toBe(2);
    });

    it("filters by protocol", async () => {
      const ctx = makeContext({}, [CONN1, { ".id": "*2", "src-address": "10.0.0.2:9000", "dst-address": "1.1.1.1:53", protocol: "udp" }]);
      const result = await listConnectionsTool.handler({ routerId: "test-router", protocol: "tcp" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).connections as unknown[]).length).toBe(1);
    });

    it("filters by srcAddress substring", async () => {
      const ctx = makeContext({}, [CONN1, { ".id": "*2", "src-address": "192.168.1.5:8080", "dst-address": "1.1.1.1:80", protocol: "tcp" }]);
      const result = await listConnectionsTool.handler({ routerId: "test-router", srcAddress: "192.168" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).connections as unknown[]).length).toBe(1);
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/network-test-tools.test.ts 2>&1 | tail -10
```

Expected: FAIL — cannot find module `network-test-tools.js`.

- [ ] **Step 3: Implement `src/domain/tools/network-test-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("network-test-tools");
const BODY_CAP = 65536;

// ---------------------------------------------------------------------------
// bandwidth_test
// ---------------------------------------------------------------------------

const bandwidthTestInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    address: z.string().describe("Remote host running RouterOS btest server"),
    protocol: z.enum(["tcp", "udp"]).default("tcp").describe("Test protocol"),
    direction: z.enum(["send", "receive", "both"]).default("both").describe("Test direction"),
    duration: z.number().int().min(1).max(30).default(5).describe("Test duration in seconds (max 30)"),
  })
  .strict();

const bandwidthTestTool: ToolDefinition = {
  name: "bandwidth_test",
  title: "Bandwidth Test",
  description:
    "Run a RouterOS bandwidth test from the router to a remote host running a RouterOS btest server. Returns TX and RX throughput in Mbps. Duration capped at 30 seconds.",
  inputSchema: bandwidthTestInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = bandwidthTestInputSchema.parse(params);
    log.info({ routerId: context.routerId, address: parsed.address }, "Running bandwidth test");
    try {
      const result = await context.routerClient.execute<Record<string, string>>("tool/bandwidth-test", {
        address: parsed.address,
        protocol: parsed.protocol,
        direction: parsed.direction,
        duration: String(parsed.duration),
      });
      const txBps = Number(result["tx-current"] ?? 0);
      const rxBps = Number(result["rx-current"] ?? 0);
      const txMbps = Math.round((txBps / 1_000_000) * 100) / 100;
      const rxMbps = Math.round((rxBps / 1_000_000) * 100) / 100;
      return {
        content: `Bandwidth test to ${parsed.address}: TX=${txMbps} Mbps RX=${rxMbps} Mbps`,
        structuredContent: {
          routerId: context.routerId,
          address: parsed.address,
          protocol: parsed.protocol,
          direction: parsed.direction,
          txMbps,
          rxMbps,
          lostPackets: result["lost-packets"] ?? null,
        },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "bandwidth_test" });
    }
  },
};

// ---------------------------------------------------------------------------
// fetch_url
// ---------------------------------------------------------------------------

const fetchUrlInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    url: z.string().url().describe("URL to fetch from the router"),
    method: z.enum(["GET", "POST"]).default("GET").describe("HTTP method"),
    httpData: z.string().optional().describe("Request body for POST"),
    outputFile: z.string().optional().describe("Save response body to this router file path instead of returning inline"),
  })
  .strict();

const fetchUrlTool: ToolDefinition = {
  name: "fetch_url",
  title: "Fetch URL",
  description:
    "Send an HTTP/HTTPS request from the router using /tool/fetch. Response body is returned inline (capped at 64 KB with [TRUNCATED] marker). Use outputFile to save to router filesystem instead.",
  inputSchema: fetchUrlInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = fetchUrlInputSchema.parse(params);
    log.info({ routerId: context.routerId, url: parsed.url, method: parsed.method }, "Fetching URL");
    try {
      const body: Record<string, string> = { url: parsed.url, "http-method": parsed.method };
      if (parsed.httpData) body["http-data"] = parsed.httpData;
      if (parsed.outputFile) body.output = parsed.outputFile;

      const result = await context.routerClient.execute<Record<string, string>>("tool/fetch", body);
      const statusCode = result.status ?? null;

      if (parsed.outputFile) {
        return {
          content: `Fetched ${parsed.url} → saved to ${parsed.outputFile} (status ${statusCode})`,
          structuredContent: { routerId: context.routerId, url: parsed.url, statusCode, outputFile: parsed.outputFile },
        };
      }

      let responseBody = result.data ?? "";
      if (responseBody.length > BODY_CAP) {
        responseBody = responseBody.slice(0, BODY_CAP) + "[TRUNCATED]";
      }
      return {
        content: `Fetched ${parsed.url}: status=${statusCode} body_length=${responseBody.length}`,
        structuredContent: { routerId: context.routerId, url: parsed.url, statusCode, body: responseBody },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "fetch_url" });
    }
  },
};

// ---------------------------------------------------------------------------
// list_connections
// ---------------------------------------------------------------------------

const listConnectionsInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    srcAddress: z.string().optional().describe("Filter by source address (substring match)"),
    dstAddress: z.string().optional().describe("Filter by destination address (substring match)"),
    protocol: z.string().optional().describe("Filter by protocol (exact match, e.g. tcp, udp, icmp)"),
    limit: z.number().int().min(1).max(500).default(100).describe("Maximum connections to return"),
  })
  .strict();

const listConnectionsTool: ToolDefinition = {
  name: "list_connections",
  title: "List Connections",
  description:
    "List active connection tracking entries from the router firewall table. Filters are applied client-side. Useful for diagnosing NAT and firewall behavior.",
  inputSchema: listConnectionsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listConnectionsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing connections");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("ip/firewall/connection", { limit: undefined, offset: undefined });
      const filtered = (all as Record<string, string>[])
        .filter((c) => (parsed.srcAddress ? (c["src-address"] ?? "").includes(parsed.srcAddress) : true))
        .filter((c) => (parsed.dstAddress ? (c["dst-address"] ?? "").includes(parsed.dstAddress) : true))
        .filter((c) => (parsed.protocol ? c.protocol === parsed.protocol : true));
      const connections = filtered.slice(0, parsed.limit);
      return {
        content: `Active connections on ${context.routerId}: ${connections.length} returned (${all.length} total)`,
        structuredContent: { routerId: context.routerId, connections, total: all.length, returned: connections.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_connections" });
    }
  },
};

export const networkTestTools: ToolDefinition[] = [bandwidthTestTool, fetchUrlTool, listConnectionsTool];
```

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

```bash
npx vitest run test/unit/domain/network-test-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/network-test-tools.ts test/unit/domain/network-test-tools.test.ts
git commit -m "feat(tools): add bandwidth_test, fetch_url, list_connections"
```

---

## Task 7: Interface List Tools

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

RouterOS paths: `interface/list` (lists), `interface/list/member` (members). Three tools: `list_interface_lists`, `manage_interface_list`, `manage_interface_list_member`.

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

```typescript
// test/unit/domain/interface-list-tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { interfaceListTools } from "../../../src/domain/tools/interface-list-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";
import { ErrorCategory } from "../../../src/domain/errors/error-types.js";

const LIST1 = { ".id": "*1", name: "WAN", comment: "WAN interfaces" };
const MEMBER1 = { ".id": "*A", list: "WAN", interface: "ether1", comment: "" };

function makeContext(
  lists: Record<string, unknown>[] = [],
  members: Record<string, unknown>[] = [],
): ToolContext {
  const getMock = vi.fn().mockImplementation((path: string) => {
    if (path === "interface/list") return Promise.resolve(lists);
    if (path === "interface/list/member") return Promise.resolve(members);
    return Promise.resolve([]);
  });
  return {
    routerId: "test-router",
    correlationId: "test-corr",
    routerConfig: {} as RouterConfig,
    sshClient: {} as SshClient,
    ftpClient: {} as FtpClient,
    identity: { id: "superadmin-builtin", role: "superadmin" as const, allowedRouters: [], allowedToolPatterns: [] },
    routerClient: {
      get: getMock,
      create: vi.fn().mockResolvedValue({ ".id": "*2" }),
      update: vi.fn().mockResolvedValue(undefined),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  } as unknown as ToolContext;
}

const [listListsTool, manageListTool, manageMemberTool] = interfaceListTools;

describe("interfaceListTools", () => {
  describe("metadata", () => {
    it("exports 3 tools", () => expect(interfaceListTools).toHaveLength(3));
    it("has correct names", () => {
      expect(listListsTool.name).toBe("list_interface_lists");
      expect(manageListTool.name).toBe("manage_interface_list");
      expect(manageMemberTool.name).toBe("manage_interface_list_member");
    });
    it("list_interface_lists is readOnly", () => expect(listListsTool.annotations.readOnlyHint).toBe(true));
    it("manage tools are not readOnly", () => {
      expect(manageListTool.annotations.readOnlyHint).toBe(false);
      expect(manageMemberTool.annotations.readOnlyHint).toBe(false);
    });
    it("none are destructive", () => {
      expect(manageListTool.annotations.destructiveHint).toBe(false);
      expect(manageMemberTool.annotations.destructiveHint).toBe(false);
    });
  });

  describe("list_interface_lists", () => {
    it("returns all lists", async () => {
      const ctx = makeContext([LIST1, { ".id": "*2", name: "LAN", comment: "" }]);
      const result = await listListsTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.lists as unknown[]).length).toBe(2);
      expect(sc.total).toBe(2);
    });

    it("applies default limit 100", () => {
      expect(listListsTool.inputSchema.parse({ routerId: "r1" }).limit).toBe(100);
    });

    it("rejects extra fields", () => {
      expect(listListsTool.inputSchema.safeParse({ routerId: "r1", extra: true }).success).toBe(false);
    });
  });

  describe("manage_interface_list", () => {
    it("creates list when not found", async () => {
      const ctx = makeContext([]);
      const result = await manageListTool.handler({ routerId: "test-router", action: "add", name: "WAN" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      expect(ctx.routerClient.create).toHaveBeenCalledWith("interface/list", expect.objectContaining({ name: "WAN" }));
    });

    it("returns already_exists when list found", async () => {
      const ctx = makeContext([LIST1]);
      const result = await manageListTool.handler({ routerId: "test-router", action: "add", name: "WAN" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("removes list when found", async () => {
      const ctx = makeContext([LIST1]);
      const result = await manageListTool.handler({ routerId: "test-router", action: "remove", name: "WAN" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("interface/list", "*1");
    });

    it("returns not_found gracefully on remove when missing", async () => {
      const ctx = makeContext([]);
      const result = await manageListTool.handler({ routerId: "test-router", action: "remove", name: "WAN" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
    });

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

    it("rejects invalid action", () => {
      expect(manageListTool.inputSchema.safeParse({ routerId: "r1", action: "enable", name: "WAN" }).success).toBe(false);
    });
  });

  describe("manage_interface_list_member", () => {
    it("adds member when not found", async () => {
      const ctx = makeContext([], []);
      const result = await manageMemberTool.handler({ routerId: "test-router", action: "add", list: "WAN", interface: "ether1" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      expect(ctx.routerClient.create).toHaveBeenCalledWith("interface/list/member", expect.objectContaining({ list: "WAN", interface: "ether1" }));
    });

    it("returns already_exists when member found", async () => {
      const ctx = makeContext([], [MEMBER1]);
      const result = await manageMemberTool.handler({ routerId: "test-router", action: "add", list: "WAN", interface: "ether1" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("removes member when found", async () => {
      const ctx = makeContext([], [MEMBER1]);
      const result = await manageMemberTool.handler({ routerId: "test-router", action: "remove", list: "WAN", interface: "ether1" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("interface/list/member", "*A");
    });

    it("returns not_found gracefully on remove when missing", async () => {
      const ctx = makeContext([], []);
      const result = await manageMemberTool.handler({ routerId: "test-router", action: "remove", list: "WAN", interface: "ether1" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
    });

    it("dry_run returns preview without create", async () => {
      const ctx = makeContext([], []);
      const result = await manageMemberTool.handler({ routerId: "test-router", action: "add", list: "WAN", interface: "ether1", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("rejects extra fields", () => {
      expect(manageMemberTool.inputSchema.safeParse({ routerId: "r1", action: "add", list: "WAN", interface: "ether1", extra: true }).success).toBe(false);
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/interface-list-tools.test.ts 2>&1 | tail -10
```

Expected: FAIL — cannot find module `interface-list-tools.js`.

- [ ] **Step 3: Implement `src/domain/tools/interface-list-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("interface-list-tools");

// ---------------------------------------------------------------------------
// list_interface_lists
// ---------------------------------------------------------------------------

const listInterfaceListsInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    limit: z.number().int().min(1).max(500).default(100).describe("Maximum lists to return"),
  })
  .strict();

const listInterfaceListsTool: ToolDefinition = {
  name: "list_interface_lists",
  title: "List Interface Lists",
  description: "List all interface lists defined on the router.",
  inputSchema: listInterfaceListsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listInterfaceListsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing interface lists");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("interface/list", { limit: undefined, offset: undefined });
      const lists = (all as RouterOSRecord[]).slice(0, parsed.limit);
      return {
        content: `Interface lists on ${context.routerId}: ${lists.length} returned`,
        structuredContent: { routerId: context.routerId, lists, total: all.length, returned: lists.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_interface_lists" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_interface_list
// ---------------------------------------------------------------------------

const manageInterfaceListInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Action to perform"),
    name: z.string().describe("Interface list name — idempotency key"),
    comment: z.string().optional().describe("Optional comment"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageInterfaceListTool: ToolDefinition = {
  name: "manage_interface_list",
  title: "Manage Interface List",
  description:
    "Add or remove an interface list. Idempotent by name. Removing a list that has members is blocked by RouterOS — the error is surfaced as-is.",
  inputSchema: manageInterfaceListInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageInterfaceListInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing interface list");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("interface/list", { limit: undefined, offset: undefined });
      const existing = (all as Record<string, string>[]).find((l) => l.name === parsed.name);

      if (parsed.action === "add") {
        if (existing) {
          return {
            content: `Interface list "${parsed.name}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", name: parsed.name },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would create interface list "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff: [{ property: "name", before: null, after: parsed.name }] },
          };
        }
        const body: Record<string, string> = { name: parsed.name };
        if (parsed.comment) body.comment = parsed.comment;
        const created = await context.routerClient.create("interface/list", body);
        log.info({ name: parsed.name, id: created[".id"] }, "Interface list created");
        return {
          content: `Created interface list "${parsed.name}".`,
          structuredContent: { action: "created", name: parsed.name, id: created[".id"] },
        };
      }

      // remove
      if (!existing) {
        return {
          content: `Interface list "${parsed.name}" not found. Nothing to remove.`,
          structuredContent: { action: "not_found", name: parsed.name },
        };
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove interface list "${parsed.name}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] },
        };
      }
      await context.routerClient.remove("interface/list", existing[".id"]);
      log.info({ name: parsed.name }, "Interface list removed");
      return {
        content: `Removed interface list "${parsed.name}".`,
        structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_interface_list" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_interface_list_member
// ---------------------------------------------------------------------------

const manageInterfaceListMemberInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "remove"]).describe("Action to perform"),
    list: z.string().describe("Interface list name — part of composite idempotency key"),
    interface: z.string().describe("Interface name to add/remove — part of composite idempotency key"),
    comment: z.string().optional().describe("Optional comment"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const manageInterfaceListMemberTool: ToolDefinition = {
  name: "manage_interface_list_member",
  title: "Manage Interface List Member",
  description:
    "Add or remove an interface from an interface list. Idempotent by list+interface composite key. add returns already_exists if the membership exists. remove returns not_found gracefully.",
  inputSchema: manageInterfaceListMemberInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageInterfaceListMemberInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, list: parsed.list, interface: parsed.interface }, "Managing interface list member");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("interface/list/member", { limit: undefined, offset: undefined });
      const existing = (all as Record<string, string>[]).find(
        (m) => m.list === parsed.list && m.interface === parsed.interface,
      );

      if (parsed.action === "add") {
        if (existing) {
          return {
            content: `Interface "${parsed.interface}" is already a member of list "${parsed.list}". No changes made.`,
            structuredContent: { action: "already_exists", list: parsed.list, interface: parsed.interface },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would add "${parsed.interface}" to list "${parsed.list}".`,
            structuredContent: { action: "dry_run", diff: [{ property: "interface", before: null, after: parsed.interface }] },
          };
        }
        const body: Record<string, string> = { list: parsed.list, interface: parsed.interface };
        if (parsed.comment) body.comment = parsed.comment;
        const created = await context.routerClient.create("interface/list/member", body);
        log.info({ list: parsed.list, interface: parsed.interface, id: created[".id"] }, "Interface list member added");
        return {
          content: `Added "${parsed.interface}" to list "${parsed.list}".`,
          structuredContent: { action: "created", list: parsed.list, interface: parsed.interface, id: created[".id"] },
        };
      }

      // remove
      if (!existing) {
        return {
          content: `Interface "${parsed.interface}" is not a member of list "${parsed.list}". Nothing to remove.`,
          structuredContent: { action: "not_found", list: parsed.list, interface: parsed.interface },
        };
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove "${parsed.interface}" from list "${parsed.list}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "interface", before: parsed.interface, after: null }] },
        };
      }
      await context.routerClient.remove("interface/list/member", existing[".id"]);
      log.info({ list: parsed.list, interface: parsed.interface }, "Interface list member removed");
      return {
        content: `Removed "${parsed.interface}" from list "${parsed.list}".`,
        structuredContent: { action: "removed", list: parsed.list, interface: parsed.interface, id: existing[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_interface_list_member" });
    }
  },
};

export const interfaceListTools: ToolDefinition[] = [
  listInterfaceListsTool,
  manageInterfaceListTool,
  manageInterfaceListMemberTool,
];
```

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

```bash
npx vitest run test/unit/domain/interface-list-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/interface-list-tools.ts test/unit/domain/interface-list-tools.test.ts
git commit -m "feat(tools): add list_interface_lists, manage_interface_list, manage_interface_list_member"
```

---

## Task 8: PPP Profile Tools

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

RouterOS path: `ppp/profile`. Two tools: `list_ppp_profiles`, `manage_ppp_profile`. `manage_ppp_profile` supports add, update, remove. Built-in profiles (`default`, `default-encryption`) cannot be removed — RouterOS returns an error surfaced via `enrichError`.

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

```typescript
// test/unit/domain/ppp-tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { pppTools } from "../../../src/domain/tools/ppp-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";
import { ErrorCategory } from "../../../src/domain/errors/error-types.js";

const PROFILE1 = {
  ".id": "*1",
  name: "broadband",
  "local-address": "10.0.0.1",
  "remote-address": "ppp-pool",
  "dns-server": "8.8.8.8",
  "rate-limit": "10M/10M",
  "session-timeout": "1h",
  comment: "Broadband plan",
};

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

const [listTool, manageTool] = pppTools;

describe("pppTools", () => {
  describe("metadata", () => {
    it("exports 2 tools", () => expect(pppTools).toHaveLength(2));
    it("has correct names", () => {
      expect(listTool.name).toBe("list_ppp_profiles");
      expect(manageTool.name).toBe("manage_ppp_profile");
    });
    it("list_ppp_profiles is readOnly", () => expect(listTool.annotations.readOnlyHint).toBe(true));
    it("manage_ppp_profile is not readOnly", () => expect(manageTool.annotations.readOnlyHint).toBe(false));
    it("manage_ppp_profile is not destructive", () => expect(manageTool.annotations.destructiveHint).toBe(false));
  });

  describe("list_ppp_profiles input schema", () => {
    it("parses valid input", () => expect(listTool.inputSchema.safeParse({ routerId: "r1" }).success).toBe(true));
    it("applies default limit 100", () => expect(listTool.inputSchema.parse({ routerId: "r1" }).limit).toBe(100));
    it("rejects extra fields", () => expect(listTool.inputSchema.safeParse({ routerId: "r1", extra: true }).success).toBe(false));
  });

  describe("manage_ppp_profile input schema", () => {
    it("parses valid add input", () => {
      expect(manageTool.inputSchema.safeParse({ routerId: "r1", action: "add", name: "basic" }).success).toBe(true);
    });
    it("dryRun defaults false", () => {
      expect(manageTool.inputSchema.parse({ routerId: "r1", action: "add", name: "basic" }).dryRun).toBe(false);
    });
    it("rejects invalid action", () => {
      expect(manageTool.inputSchema.safeParse({ routerId: "r1", action: "enable", name: "basic" }).success).toBe(false);
    });
    it("rejects extra fields", () => {
      expect(manageTool.inputSchema.safeParse({ routerId: "r1", action: "add", name: "basic", extra: true }).success).toBe(false);
    });
  });

  describe("handler — list_ppp_profiles", () => {
    it("returns all profiles", async () => {
      const ctx = makeContext([PROFILE1, { ".id": "*2", name: "default", "local-address": "", "remote-address": "" }]);
      const result = await listTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.profiles as unknown[]).length).toBe(2);
      expect(sc.total).toBe(2);
    });

    it("filters by exact name", async () => {
      const ctx = makeContext([PROFILE1, { ".id": "*2", name: "default", "local-address": "", "remote-address": "" }]);
      const result = await listTool.handler({ routerId: "test-router", name: "broadband" }, ctx);
      expect(((result.structuredContent as Record<string, unknown>).profiles as unknown[]).length).toBe(1);
    });
  });

  describe("handler — manage_ppp_profile add", () => {
    it("creates profile when not found", async () => {
      const ctx = makeContext([]);
      const result = await manageTool.handler({ routerId: "test-router", action: "add", name: "broadband" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("created");
      expect(ctx.routerClient.create).toHaveBeenCalledWith("ppp/profile", expect.objectContaining({ name: "broadband" }));
    });

    it("returns already_exists when profile found", async () => {
      const ctx = makeContext([PROFILE1]);
      const result = await manageTool.handler({ routerId: "test-router", action: "add", name: "broadband" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("already_exists");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

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

  describe("handler — manage_ppp_profile update", () => {
    it("updates profile when found", async () => {
      const ctx = makeContext([PROFILE1]);
      const result = await manageTool.handler({ routerId: "test-router", action: "update", name: "broadband", rateLimit: "20M/20M" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("updated");
      expect(ctx.routerClient.update).toHaveBeenCalledWith("ppp/profile", "*1", expect.objectContaining({ "rate-limit": "20M/20M" }));
    });

    it("returns no_change when all requested values match", async () => {
      const ctx = makeContext([PROFILE1]);
      const result = await manageTool.handler({ routerId: "test-router", action: "update", name: "broadband", rateLimit: "10M/10M" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("no_change");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });

    it("throws NOT_FOUND when profile does not exist", async () => {
      const ctx = makeContext([]);
      await expect(
        manageTool.handler({ routerId: "test-router", action: "update", name: "missing" }, ctx),
      ).rejects.toMatchObject({ category: ErrorCategory.NOT_FOUND });
    });

    it("dry_run returns preview without update", async () => {
      const ctx = makeContext([PROFILE1]);
      const result = await manageTool.handler({ routerId: "test-router", action: "update", name: "broadband", rateLimit: "20M/20M", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });
  });

  describe("handler — manage_ppp_profile remove", () => {
    it("removes profile when found", async () => {
      const ctx = makeContext([PROFILE1]);
      const result = await manageTool.handler({ routerId: "test-router", action: "remove", name: "broadband" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("removed");
      expect(ctx.routerClient.remove).toHaveBeenCalledWith("ppp/profile", "*1");
    });

    it("returns not_found gracefully when already gone", async () => {
      const ctx = makeContext([]);
      const result = await manageTool.handler({ routerId: "test-router", action: "remove", name: "broadband" }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("not_found");
    });

    it("dry_run returns preview without remove", async () => {
      const ctx = makeContext([PROFILE1]);
      const result = await manageTool.handler({ routerId: "test-router", action: "remove", name: "broadband", dryRun: true }, ctx);
      expect((result.structuredContent as Record<string, unknown>).action).toBe("dry_run");
      expect(ctx.routerClient.remove).not.toHaveBeenCalled();
    });
  });
});
```

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

```bash
npx vitest run test/unit/domain/ppp-tools.test.ts 2>&1 | tail -10
```

Expected: FAIL — cannot find module `ppp-tools.js`.

- [ ] **Step 3: Implement `src/domain/tools/ppp-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("ppp-tools");

// ---------------------------------------------------------------------------
// list_ppp_profiles
// ---------------------------------------------------------------------------

const listPppProfilesInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    name: z.string().optional().describe("Filter by profile name (exact match)"),
    limit: z.number().int().min(1).max(500).default(100).describe("Maximum profiles to return"),
  })
  .strict();

const listPppProfilesTool: ToolDefinition = {
  name: "list_ppp_profiles",
  title: "List PPP Profiles",
  description: "List PPP profiles including the built-in default and default-encryption profiles.",
  inputSchema: listPppProfilesInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listPppProfilesInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing PPP profiles");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("ppp/profile", { limit: undefined, offset: undefined });
      const filtered = parsed.name
        ? (all as Record<string, string>[]).filter((p) => p.name === parsed.name)
        : (all as Record<string, string>[]);
      const profiles = filtered.slice(0, parsed.limit);
      return {
        content: `PPP profiles on ${context.routerId}: ${profiles.length} returned`,
        structuredContent: { routerId: context.routerId, profiles, total: all.length, returned: profiles.length },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "list_ppp_profiles" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_ppp_profile
// ---------------------------------------------------------------------------

const managePppProfileInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier from the router registry"),
    action: z.enum(["add", "update", "remove"]).describe("Action to perform"),
    name: z.string().describe("Profile name — idempotency key"),
    localAddress: z.string().optional().describe("Local IP assigned to router end of PPP link"),
    remoteAddress: z.string().optional().describe("IP or pool name assigned to client"),
    dnsServer: z.string().optional().describe("DNS server IP pushed to client"),
    rateLimit: z.string().optional().describe("Rate limit string (e.g. '10M/10M')"),
    sessionTimeout: z.string().optional().describe("Session timeout duration string (e.g. '1h')"),
    comment: z.string().optional().describe("Optional comment"),
    dryRun: z.boolean().default(false).describe("Preview changes without applying"),
  })
  .strict();

const managePppProfileTool: ToolDefinition = {
  name: "manage_ppp_profile",
  title: "Manage PPP Profile",
  description:
    "Add, update, or remove a PPP profile. Idempotent by name. update returns no_change when requested values match. Built-in profiles (default, default-encryption) cannot be removed — RouterOS blocks this and the error is surfaced.",
  inputSchema: managePppProfileInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  snapshotPaths: ["ppp/profile"],
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = managePppProfileInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing PPP profile");
    try {
      const all = await context.routerClient.get<RouterOSRecord>("ppp/profile", { limit: undefined, offset: undefined });
      const existing = (all as Record<string, string>[]).find((p) => p.name === parsed.name);

      if (parsed.action === "add") {
        if (existing) {
          return {
            content: `PPP profile "${parsed.name}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", name: parsed.name },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would create PPP profile "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff: [{ property: "name", before: null, after: parsed.name }] },
          };
        }
        const body: Record<string, string> = { name: parsed.name };
        if (parsed.localAddress) body["local-address"] = parsed.localAddress;
        if (parsed.remoteAddress) body["remote-address"] = parsed.remoteAddress;
        if (parsed.dnsServer) body["dns-server"] = parsed.dnsServer;
        if (parsed.rateLimit) body["rate-limit"] = parsed.rateLimit;
        if (parsed.sessionTimeout) body["session-timeout"] = parsed.sessionTimeout;
        if (parsed.comment) body.comment = parsed.comment;
        const created = await context.routerClient.create("ppp/profile", body);
        log.info({ name: parsed.name, id: created[".id"] }, "PPP profile created");
        return {
          content: `Created PPP profile "${parsed.name}".`,
          structuredContent: { action: "created", name: parsed.name, id: created[".id"] },
        };
      }

      if (parsed.action === "update") {
        if (!existing) {
          throw new MikroMCPError({
            category: ErrorCategory.NOT_FOUND,
            code: "PPP_PROFILE_NOT_FOUND",
            message: `PPP profile "${parsed.name}" not found.`,
            recoverability: { retryable: false, suggestedAction: "Verify with list_ppp_profiles.", alternativeTools: ["list_ppp_profiles"] },
          });
        }
        const changes: Record<string, string> = {};
        const diff: { property: string; before: string | null; after: string }[] = [];

        if (parsed.localAddress !== undefined && existing["local-address"] !== parsed.localAddress) {
          changes["local-address"] = parsed.localAddress;
          diff.push({ property: "local-address", before: existing["local-address"] ?? null, after: parsed.localAddress });
        }
        if (parsed.remoteAddress !== undefined && existing["remote-address"] !== parsed.remoteAddress) {
          changes["remote-address"] = parsed.remoteAddress;
          diff.push({ property: "remote-address", before: existing["remote-address"] ?? null, after: parsed.remoteAddress });
        }
        if (parsed.dnsServer !== undefined && existing["dns-server"] !== parsed.dnsServer) {
          changes["dns-server"] = parsed.dnsServer;
          diff.push({ property: "dns-server", before: existing["dns-server"] ?? null, after: parsed.dnsServer });
        }
        if (parsed.rateLimit !== undefined && existing["rate-limit"] !== parsed.rateLimit) {
          changes["rate-limit"] = parsed.rateLimit;
          diff.push({ property: "rate-limit", before: existing["rate-limit"] ?? null, after: parsed.rateLimit });
        }
        if (parsed.sessionTimeout !== undefined && existing["session-timeout"] !== parsed.sessionTimeout) {
          changes["session-timeout"] = parsed.sessionTimeout;
          diff.push({ property: "session-timeout", before: existing["session-timeout"] ?? null, after: parsed.sessionTimeout });
        }
        if (parsed.comment !== undefined && existing.comment !== parsed.comment) {
          changes.comment = parsed.comment;
          diff.push({ property: "comment", before: existing.comment ?? null, after: parsed.comment });
        }

        if (Object.keys(changes).length === 0) {
          return {
            content: `PPP profile "${parsed.name}" already matches requested values. No changes made.`,
            structuredContent: { action: "no_change", name: parsed.name },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would update PPP profile "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff },
          };
        }
        await context.routerClient.update("ppp/profile", existing[".id"], changes);
        log.info({ name: parsed.name, id: existing[".id"] }, "PPP profile updated");
        return {
          content: `Updated PPP profile "${parsed.name}".`,
          structuredContent: { action: "updated", name: parsed.name, id: existing[".id"], diff },
        };
      }

      // remove
      if (!existing) {
        return {
          content: `PPP profile "${parsed.name}" not found. Nothing to remove.`,
          structuredContent: { action: "not_found", name: parsed.name },
        };
      }
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove PPP profile "${parsed.name}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] },
        };
      }
      await context.routerClient.remove("ppp/profile", existing[".id"]);
      log.info({ name: parsed.name }, "PPP profile removed");
      return {
        content: `Removed PPP profile "${parsed.name}".`,
        structuredContent: { action: "removed", name: parsed.name, id: existing[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_ppp_profile" });
    }
  },
};

export const pppTools: ToolDefinition[] = [listPppProfilesTool, managePppProfileTool];
```

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

```bash
npx vitest run test/unit/domain/ppp-tools.test.ts 2>&1 | tail -20
```

Expected: All tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/domain/tools/ppp-tools.ts test/unit/domain/ppp-tools.test.ts
git commit -m "feat(tools): add list_ppp_profiles, manage_ppp_profile"
```

---

## Task 9: Register All New Tools in `index.ts`

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

Add the four new tool arrays and wire them into `baseTools`.

- [ ] **Step 1: Update `src/domain/tools/index.ts`**

Add four new imports after the existing `import { logTools }` line:

```typescript
import { containerConfigTools } from "./container-config-tools.js";
import { networkTestTools } from "./network-test-tools.js";
import { interfaceListTools } from "./interface-list-tools.js";
import { pppTools } from "./ppp-tools.js";
```

Append the four arrays to `baseTools` (before the closing `]`):

```typescript
  ...containerConfigTools,
  ...networkTestTools,
  ...interfaceListTools,
  ...pppTools,
```

The full updated file:

```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 { createChangeManagementTools } from "./change-management-tools.js";
import { ipsecTools } from "./ipsec-tools.js";
import { certificateTools } from "./certificate-tools.js";
import { userTools } from "./user-tools.js";
import { userGroupTools } from "./user-group-tools.js";
import { dhcpServerTools } from "./dhcp-server-tools.js";
import { ipPoolTools } from "./ip-pool-tools.js";
import { queueTools } from "./queue-tools.js";
import { vrrpTools } from "./vrrp-tools.js";
import { networkServicesTools } from "./network-services-tools.js";
import { vlanTools } from "./vlan-tools.js";
import { dhcpClientTools } from "./dhcp-client-tools.js";
import { ipServiceTools } from "./ip-service-tools.js";
import { pppoeTools } from "./pppoe-tools.js";
import { ovpnTools } from "./ovpn-tools.js";
import { upgradeTools } from "./upgrade-tools.js";
import { backupTools } from "./backup-tools.js";
import { logTools } from "./log-tools.js";
import { containerConfigTools } from "./container-config-tools.js";
import { networkTestTools } from "./network-test-tools.js";
import { interfaceListTools } from "./interface-list-tools.js";
import { pppTools } from "./ppp-tools.js";
import { createFleetTools } from "./fleet-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,
  ...userGroupTools,
  ...dhcpServerTools,
  ...ipPoolTools,
  ...queueTools,
  ...vrrpTools,
  ...networkServicesTools,
  ...vlanTools,
  ...dhcpClientTools,
  ...ipServiceTools,
  ...pppoeTools,
  ...ovpnTools,
  ...upgradeTools,
  ...backupTools,
  ...logTools,
  ...containerConfigTools,
  ...networkTestTools,
  ...interfaceListTools,
  ...pppTools,
];

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

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

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

```bash
npm test 2>&1 | tail -30
```

Expected: All tests pass, no type errors.

- [ ] **Step 3: Commit**

```bash
git add src/domain/tools/index.ts
git commit -m "feat(tools): register v1.5 tool arrays in index (99 → 117 tools)"
```

---

## Task 10: Documentation Updates

**Files:**
- Modify: `ROADMAP.md`
- Modify: `README.md`
- Modify: `CHANGELOG.md`
- Modify: `docs/wiki/Available-Tools.md`

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

Find the v1.5 line and flip `🔜` to `✅` and update the tool count to 117.

The line to find (exact text may vary — check file):
```
🔜 v1.5 — Container Depth, Network Gap-Fill & Diagnostics (99 → 117 tools)
```

Replace with:
```
✅ v1.5 — Container Depth, Network Gap-Fill & Diagnostics (99 → 117 tools)
```

- [ ] **Step 2: Update `README.md`**

Find the tool count badge (`99` tools) and the Available Tools table row count and update both to `117`.

Search for `99` in the badge/header context and replace with `117`. Also update any description referencing the tool count.

- [ ] **Step 3: Update `CHANGELOG.md`**

Under `[Unreleased]` → `Added`, add all 18 new tools:

```markdown
### Added
- `manage_dns_settings` tool — write upstream servers, cache TTL, allow-remote-requests
- `delete_file` tool — delete a file from the router filesystem by name
- `manage_ipsec_policy` tool — add/remove/enable/disable IPSec policies
- `manage_wireguard_interface` tool — add/remove/enable/disable WireGuard interfaces
- `get_container_config` tool — read global container registry/RAM/veth config
- `manage_container_config` tool — write global container config settings
- `list_container_envs` tool — list container environment variable entries
- `manage_container_env` tool — add/remove container environment variables
- `list_container_mounts` tool — list container volume mount definitions
- `manage_container_mount` tool — add/remove container volume mounts
- `bandwidth_test` tool — run RouterOS bandwidth test to a remote btest server
- `fetch_url` tool — send HTTP/HTTPS request from the router using /tool/fetch
- `list_connections` tool — list active firewall connection tracking entries
- `list_interface_lists` tool — list all interface lists
- `manage_interface_list` tool — add/remove interface lists
- `manage_interface_list_member` tool — add/remove interfaces from interface lists
- `list_ppp_profiles` tool — list PPP profiles including built-in defaults
- `manage_ppp_profile` tool — add/update/remove PPP profiles
```

- [ ] **Step 4: Update `docs/wiki/Available-Tools.md`**

Add an entry for each of the 18 new tools. Follow the existing format: section header with Read/Write tag, one-line description, parameter table, and example prompt.

Add the following entries (insert in alphabetical order within their category sections, or append at the end of the file before any index):

**`bandwidth_test` (Read)**
> Run a RouterOS bandwidth test from the router to a remote host running a RouterOS btest server. Returns TX/RX throughput in Mbps.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `address` | string | required | Remote host running RouterOS btest server |
| `protocol` | `tcp`\|`udp` | `tcp` | Test protocol |
| `direction` | `send`\|`receive`\|`both` | `both` | Test direction |
| `duration` | int (1–30) | `5` | Test duration in seconds |

**Example prompt:** "Run a bandwidth test from router home-gw to 192.168.99.2 for 10 seconds"

---

**`delete_file` (Write)**
> Delete a file from the router filesystem by name. Idempotent — returns not_found gracefully if the file does not exist.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `name` | string | required | Exact file name (e.g. `flash/backup.backup`) |
| `dryRun` | bool | `false` | Preview deletion without removing |

**Example prompt:** "Delete flash/old-backup.backup from router branch-1"

---

**`fetch_url` (Read)**
> Send an HTTP/HTTPS GET or POST request from the router. Response body returned inline (capped at 64 KB). Use `outputFile` to save to the router filesystem instead.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `url` | string | required | URL to fetch |
| `method` | `GET`\|`POST` | `GET` | HTTP method |
| `httpData` | string | — | Request body for POST |
| `outputFile` | string | — | Router file path to save response instead of returning inline |

**Example prompt:** "Fetch http://10.0.0.1/status from router core-sw and show me the response body"

---

**`get_container_config` (Read)**
> Read global container configuration: registry URL, RAM high-water mark, and veth interface name.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |

**Example prompt:** "Show me the container configuration on router core"

---

**`list_container_envs` (Read)**
> List container environment variable entries, optionally filtered by container name.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `name` | string | — | Filter by container name (exact match) |
| `limit` | int (1–500) | `100` | Maximum entries to return |

**Example prompt:** "List all environment variables for container my-app on router core"

---

**`list_container_mounts` (Read)**
> List container volume mount definitions with source path, destination path, and mount name.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `name` | string | — | Filter by mount name (exact match) |
| `limit` | int (1–500) | `100` | Maximum entries to return |

**Example prompt:** "Show me all container mounts on router core"

---

**`list_connections` (Read)**
> List active connection tracking entries from the router firewall table. Filters applied client-side.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `srcAddress` | string | — | Substring match on source address |
| `dstAddress` | string | — | Substring match on destination address |
| `protocol` | string | — | Exact match (e.g. `tcp`, `udp`) |
| `limit` | int (1–500) | `100` | Maximum connections to return |

**Example prompt:** "Show me all active TCP connections from 192.168.1.0/24 on router edge"

---

**`list_interface_lists` (Read)**
> List all interface lists defined on the router.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `limit` | int (1–500) | `100` | Maximum lists to return |

**Example prompt:** "List all interface lists on router core"

---

**`list_ppp_profiles` (Read)**
> List PPP profiles including the built-in `default` and `default-encryption` profiles.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `name` | string | — | Filter by profile name (exact match) |
| `limit` | int (1–500) | `100` | Maximum profiles to return |

**Example prompt:** "Show me all PPP profiles on router isp-edge"

---

**`manage_container_config` (Write)**
> Update global container settings (registry URL, RAM high-water mark, veth interface). Idempotent — returns `no_change` if nothing differs.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `registryUrl` | string | — | Container registry URL |
| `ramHighMb` | int | — | RAM high-water mark in MB |
| `vethInterface` | string | — | Veth interface name |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Set the container registry to https://my.registry.io on router core"

---

**`manage_container_env` (Write)**
> Add or remove a container environment variable. Idempotent by `name`+`key`. `add` returns `already_exists` if the entry matches; throws `CONFLICT` if the key exists with a different value.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`remove` | required | Action to perform |
| `name` | string | required | Container name |
| `key` | string | required | Environment variable name |
| `value` | string | — | Environment variable value (required for add) |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Add environment variable DEBUG=true to container my-app on router core"

---

**`manage_container_mount` (Write)**
> Add or remove a container volume mount. Idempotent by `name`. `add` returns `already_exists` if mount exists with matching paths; throws `CONFLICT` if name exists with different paths.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`remove` | required | Action to perform |
| `name` | string | required | Mount name (idempotency key) |
| `src` | string | — | Host source path (required for add) |
| `dst` | string | — | Container destination path (required for add) |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Add a mount named app-data mapping /mnt/data to /data for containers on router core"

---

**`manage_dns_settings` (Write)**
> Update DNS resolver settings. Idempotent — returns `no_change` if nothing differs.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `servers` | string | — | Comma-separated upstream DNS server IPs |
| `allowRemoteRequests` | bool | — | Allow router to answer DNS queries from the network |
| `maxUdpPacketSize` | int (512–65535) | — | Maximum UDP packet size in bytes |
| `cacheMaxTtl` | string | — | Maximum cache TTL (e.g. `1d`) |
| `cacheSize` | int | — | DNS cache size in KiB |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Set the DNS servers on router home-gw to 1.1.1.1 and 8.8.8.8"

---

**`manage_interface_list` (Write)**
> Add or remove an interface list. Idempotent by name. Removing a list that has members is blocked by RouterOS.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`remove` | required | Action to perform |
| `name` | string | required | Interface list name (idempotency key) |
| `comment` | string | — | Optional comment |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Create an interface list named WAN on router edge"

---

**`manage_interface_list_member` (Write)**
> Add or remove an interface from an interface list. Idempotent by `list`+`interface` composite key.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`remove` | required | Action to perform |
| `list` | string | required | Interface list name |
| `interface` | string | required | Interface name to add/remove |
| `comment` | string | — | Optional comment |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Add ether1 to the WAN interface list on router edge"

---

**`manage_ipsec_policy` (Write)**
> Add, remove, enable, or disable an IPSec policy. Idempotent by composite key `srcAddress`+`dstAddress`+`tunnel`.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`remove`\|`enable`\|`disable` | required | Action to perform |
| `srcAddress` | string | required | Source CIDR |
| `dstAddress` | string | required | Destination CIDR |
| `tunnel` | bool | `false` | Tunnel mode (part of idempotency key) |
| `ipsecAction` | `encrypt`\|`discard`\|`none` | — | IPSec action (required for add) |
| `level` | `require`\|`use`\|`unique` | `require` | SA level |
| `saSourceAddress` | string | — | SA source IP for tunnel mode |
| `saDstAddress` | string | — | SA destination IP for tunnel mode |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Add an IPSec encrypt policy from 10.0.0.0/24 to 192.168.1.0/24 in tunnel mode on router vpn-hub"

---

**`manage_ppp_profile` (Write)**
> Add, update, or remove a PPP profile. Idempotent by name. `update` returns `no_change` when values already match. Built-in profiles cannot be removed.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`update`\|`remove` | required | Action to perform |
| `name` | string | required | Profile name (idempotency key) |
| `localAddress` | string | — | Local IP for router end of PPP link |
| `remoteAddress` | string | — | IP or pool name assigned to client |
| `dnsServer` | string | — | DNS server IP pushed to client |
| `rateLimit` | string | — | Rate limit string (e.g. `10M/10M`) |
| `sessionTimeout` | string | — | Session timeout (e.g. `1h`) |
| `comment` | string | — | Optional comment |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Create a PPP profile named broadband with rate limit 10M/10M and session timeout 24h on router isp-edge"

---

**`manage_wireguard_interface` (Write)**
> Add, remove, enable, or disable a WireGuard interface. Idempotent by name. RouterOS generates the private key on create — it is never passed in. The public key is returned after creation.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `routerId` | string | required | Target router |
| `action` | `add`\|`remove`\|`enable`\|`disable` | required | Action to perform |
| `name` | string | required | Interface name (idempotency key) |
| `listenPort` | int (1–65535) | — | UDP listen port (RouterOS picks one if omitted) |
| `mtu` | int (1280–65535) | `1420` | MTU |
| `comment` | string | — | Optional comment |
| `dryRun` | bool | `false` | Preview changes without applying |

**Example prompt:** "Create a new WireGuard interface named wg1 listening on port 51821 on router vpn-hub"

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

```bash
npm test 2>&1 | tail -30
```

Expected: All tests pass, typecheck passes, lint passes.

- [ ] **Step 6: Commit**

```bash
git add ROADMAP.md README.md CHANGELOG.md docs/wiki/Available-Tools.md
git commit -m "docs: update tool count, CHANGELOG, roadmap, and wiki for v1.5 (99 → 117 tools)"
```
