# v0.4 Network Services Implementation Plan

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

**Goal:** Ship WiFi, bridging, WireGuard, and DNS tools; fix six infrastructure bugs identified in the v0.4 roadmap.

**Architecture:** Infrastructure fixes land first (Tasks 1–7) so new tools build on correct foundations. New tools follow the established pattern: Zod `.strict()` schema → idempotency check → dry-run → REST call → structured result. Shared CIDR validation moves to a new utility file consumed by ip-tools, route-tools, and new tools.

**Tech Stack:** TypeScript/ESM, Zod, `RouterOSRestClient`, `MikroMCPError`/`enrichError`, Vitest

---

## Phase 1 — Infrastructure Fixes

### Task 1: Map ZodError to VALIDATION in enrichError

**Files:**
- Modify: `src/domain/errors/error-enricher.ts`
- Test: `test/unit/domain/error-enricher.test.ts` (create)

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

```typescript
// test/unit/domain/error-enricher.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { enrichError } from "../../../src/domain/errors/error-enricher.js";
import { ErrorCategory } from "../../../src/domain/errors/error-types.js";

describe("enrichError", () => {
  it("maps ZodError to VALIDATION category", () => {
    let zodErr: unknown;
    try {
      z.object({ x: z.number() }).strict().parse({ x: "bad" });
    } catch (e) {
      zodErr = e;
    }
    const result = enrichError(zodErr, { tool: "test_tool" });
    expect(result.category).toBe(ErrorCategory.VALIDATION);
    expect(result.code).toBe("VALIDATION_ERROR");
  });

  it("preserves MikroMCPError unchanged", () => {
    const { MikroMCPError } = await import("../../../src/domain/errors/error-types.js");
    const err = new MikroMCPError({
      category: ErrorCategory.NOT_FOUND,
      code: "X",
      message: "msg",
      recoverability: { retryable: false, suggestedAction: "n/a" },
    });
    expect(enrichError(err)).toBe(err);
  });
});
```

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

```bash
npm test -- test/unit/domain/error-enricher.test.ts
```
Expected: FAIL — first test fails because ZodError currently becomes `INTERNAL`.

- [ ] **Step 3: Add ZodError detection in enrichError**

In `src/domain/errors/error-enricher.ts`, after the `if (error instanceof MikroMCPError)` guard and before the HTTP-style error check, add:

```typescript
import { ZodError } from "zod";
```

Add at the top of `enrichError`, after the MikroMCPError passthrough:

```typescript
  // --- Zod validation errors ---
  if (error instanceof ZodError) {
    const category = ErrorCategory.VALIDATION;
    const message = error.errors
      .map((e) => `${e.path.join(".") || "input"}: ${e.message}`)
      .join("; ");
    return new MikroMCPError({
      category,
      code: "VALIDATION_ERROR",
      message,
      details: buildDetails({ issues: error.errors }, context),
      recoverability: defaultRecoverability(category),
      cause: error,
    });
  }
```

- [ ] **Step 4: Run test to verify it passes**

```bash
npm test -- test/unit/domain/error-enricher.test.ts
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/domain/errors/error-enricher.ts test/unit/domain/error-enricher.test.ts
git commit -m "fix: map ZodError to VALIDATION category in enrichError"
```

---

### Task 2: Circuit breaker — only trip on transient router errors

**Files:**
- Modify: `src/adapter/circuit-breaker.ts`
- Test: `test/unit/adapter/circuit-breaker.test.ts`

The current `execute()` calls `onFailure()` for every thrown error, including VALIDATION and NOT_FOUND which are client bugs, not router faults. Only `ROUTER_UNREACHABLE`, `ROUTER_TIMEOUT`, `ROUTER_ERROR`, and `ROUTER_BUSY` should trip the breaker.

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

In `test/unit/adapter/circuit-breaker.test.ts`, add the following test (do not remove existing tests):

```typescript
import { MikroMCPError, ErrorCategory } from "../../../src/domain/errors/error-types.js";

describe("circuit breaker - failure filtering", () => {
  it("does not count VALIDATION errors as failures", async () => {
    const cb = new CircuitBreaker("r1", { failureThreshold: 2, cooldownMs: 30000 });
    const validationErr = new MikroMCPError({
      category: ErrorCategory.VALIDATION,
      code: "BAD",
      message: "bad input",
      recoverability: { retryable: false, suggestedAction: "fix it" },
    });
    // Throw VALIDATION twice — should NOT trip the circuit
    for (let i = 0; i < 2; i++) {
      await expect(cb.execute(() => Promise.reject(validationErr))).rejects.toThrow();
    }
    expect(cb.state).toBe("closed");
  });

  it("trips on ROUTER_UNREACHABLE errors", async () => {
    const cb = new CircuitBreaker("r1", { failureThreshold: 2, cooldownMs: 30000 });
    const unreachable = new MikroMCPError({
      category: ErrorCategory.ROUTER_UNREACHABLE,
      code: "ECONNREFUSED",
      message: "unreachable",
      recoverability: { retryable: true, suggestedAction: "retry" },
    });
    for (let i = 0; i < 2; i++) {
      await expect(cb.execute(() => Promise.reject(unreachable))).rejects.toThrow();
    }
    expect(cb.state).toBe("open");
  });
});
```

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

```bash
npm test -- test/unit/adapter/circuit-breaker.test.ts
```
Expected: FAIL — VALIDATION errors currently trip the circuit.

- [ ] **Step 3: Add failure filtering to CircuitBreaker**

In `src/adapter/circuit-breaker.ts`:

Add this import at the top:
```typescript
import { MikroMCPError, ErrorCategory } from "../domain/errors/error-types.js";
```

Add this helper before the class:
```typescript
const TRANSIENT_CATEGORIES = new Set([
  ErrorCategory.ROUTER_UNREACHABLE,
  ErrorCategory.ROUTER_TIMEOUT,
  ErrorCategory.ROUTER_ERROR,
  ErrorCategory.ROUTER_BUSY,
]);

function isTransientFailure(error: unknown): boolean {
  return error instanceof MikroMCPError && TRANSIENT_CATEGORIES.has(error.category);
}
```

Replace the `catch` block in `execute()`:
```typescript
    } catch (error) {
      if (isTransientFailure(error)) {
        this.onFailure();
      }
      throw error;
    }
```

- [ ] **Step 4: Run tests**

```bash
npm test -- test/unit/adapter/circuit-breaker.test.ts
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/adapter/circuit-breaker.ts test/unit/adapter/circuit-breaker.test.ts
git commit -m "fix: circuit breaker only trips on transient router errors, not VALIDATION/NOT_FOUND/CONFLICT"
```

---

### Task 3: Restructure retry/circuit-breaker ordering in tool-registry

**Files:**
- Modify: `src/mcp/tool-registry.ts`

Currently `withRetry(cb.execute(handler))` means each retry attempt increments the CB failure counter independently. Correct structure: `cb.execute(withRetry(handler))` — the CB wraps the entire retry sequence.

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

Create `test/unit/mcp/tool-registry-retry.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { CircuitBreaker } from "../../../src/adapter/circuit-breaker.js";
import { withRetry } from "../../../src/adapter/retry-engine.js";
import { MikroMCPError, ErrorCategory } from "../../../src/domain/errors/error-types.js";

// Unit test for the interaction: circuit wraps retry, not vice versa.
describe("retry/circuit-breaker ordering", () => {
  it("retry attempts do not accumulate circuit breaker failures", async () => {
    const cb = new CircuitBreaker("r1", { failureThreshold: 3, cooldownMs: 30000 });
    let calls = 0;

    const unreachable = new MikroMCPError({
      category: ErrorCategory.ROUTER_UNREACHABLE,
      code: "ECONNREFUSED",
      message: "unreachable",
      recoverability: { retryable: true, suggestedAction: "retry" },
    });

    const handler = async () => {
      calls++;
      throw unreachable;
    };

    // cb.execute wraps withRetry(handler) — should count as ONE failure
    await expect(
      cb.execute(() => withRetry(handler, { maxRetries: 2, baseDelayMs: 0, maxDelayMs: 0 }))
    ).rejects.toThrow();

    // handler was called 3 times (1 + 2 retries), but CB saw only 1 failure
    expect(calls).toBe(3);
    expect(cb.state).toBe("closed"); // threshold is 3, only 1 counted
  });
});
```

- [ ] **Step 2: Run test to verify it passes already (it tests the pattern, not the registry itself)**

```bash
npm test -- test/unit/mcp/tool-registry-retry.test.ts
```
Expected: PASS — this confirms the pattern is correct.

- [ ] **Step 3: Apply the pattern in tool-registry.ts**

Replace lines 70–78 in `src/mcp/tool-registry.ts`:

```typescript
            const runHandler = () =>
              tool.handler(args, {
                routerClient: client,
                routerId,
                correlationId,
                routerConfig,
                credentials,
              });

            const executeHandler = () =>
              cb!.execute(
                tool.annotations.readOnlyHint
                  ? () => withRetry(runHandler, config.retry)
                  : runHandler,
              );

            const result = await executeHandler();
```

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

```bash
npm test && npm run typecheck
```
Expected: All tests pass, no type errors.

- [ ] **Step 5: Commit**

```bash
git add src/mcp/tool-registry.ts test/unit/mcp/tool-registry-retry.test.ts
git commit -m "fix: circuit breaker wraps retry sequence, not each individual attempt"
```

---

### Task 4: Router registry — validate YAML config with Zod at startup

**Files:**
- Modify: `src/config/router-registry.ts`
- Test: `test/unit/config/router-registry.test.ts` (create)

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

```typescript
// test/unit/config/router-registry.test.ts
import { describe, it, expect } from "vitest";
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { RouterRegistry } from "../../../src/config/router-registry.js";

function tempYaml(content: string): string {
  const dir = mkdtempSync(join(tmpdir(), "mikromcp-"));
  const path = join(dir, "routers.yaml");
  writeFileSync(path, content);
  return path;
}

describe("RouterRegistry", () => {
  it("loads a valid config without throwing", () => {
    const path = tempYaml(`
routers:
  home:
    host: 192.168.1.1
    port: 443
    tls:
      enabled: true
      rejectUnauthorized: false
    credentials:
      source: env
      envPrefix: ROUTER_HOME
    tags: []
    rosVersion: "7"
`);
    expect(() => new RouterRegistry(path)).not.toThrow();
  });

  it("throws on missing required field (host)", () => {
    const path = tempYaml(`
routers:
  bad:
    port: 443
    tls:
      enabled: true
      rejectUnauthorized: false
    credentials:
      source: env
      envPrefix: ROUTER_BAD
    tags: []
    rosVersion: "7"
`);
    expect(() => new RouterRegistry(path)).toThrow(/host/i);
  });

  it("throws on invalid port value", () => {
    const path = tempYaml(`
routers:
  bad:
    host: 192.168.1.1
    port: 99999
    tls:
      enabled: true
      rejectUnauthorized: false
    credentials:
      source: env
    tags: []
    rosVersion: "7"
`);
    expect(() => new RouterRegistry(path)).toThrow();
  });
});
```

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

```bash
npm test -- test/unit/config/router-registry.test.ts
```
Expected: FAIL — currently malformed configs are swallowed silently.

- [ ] **Step 3: Add Zod validation to RouterRegistry**

Replace `src/config/router-registry.ts` with:

```typescript
import { readFileSync, existsSync } from "node:fs";
import { parse } from "yaml";
import { z } from "zod";
import type { RouterConfig } from "../types.js";
import { createLogger } from "../observability/logger.js";

const log = createLogger("router-registry");

const RouterConfigSchema = z.object({
  host: z.string().min(1, "host is required"),
  port: z.number().int().min(1).max(65535),
  tls: z.object({
    enabled: z.boolean(),
    rejectUnauthorized: z.boolean(),
    ca: z.string().optional(),
  }),
  credentials: z.object({
    source: z.enum(["env", "vault"]),
    envPrefix: z.string().optional(),
    vaultPath: z.string().optional(),
  }),
  tags: z.array(z.string()).default([]),
  rosVersion: z.string().min(1),
  sshPort: z.number().int().min(1).max(65535).optional(),
  cmdAllow: z.array(z.string()).optional(),
  cmdDeny: z.array(z.string()).optional(),
});

const ConfigFileSchema = z.object({
  routers: z.record(z.string(), RouterConfigSchema),
});

export class RouterRegistry {
  private routers: Map<string, RouterConfig>;

  constructor(configPath: string) {
    this.routers = new Map();

    if (!existsSync(configPath)) {
      log.warn({ configPath }, "Router config file not found; starting with empty registry");
      return;
    }

    const raw = readFileSync(configPath, "utf-8");
    const parsed = parse(raw) as unknown;

    const result = ConfigFileSchema.safeParse(parsed);
    if (!result.success) {
      const issues = result.error.errors
        .map((e) => `  ${e.path.join(".")}: ${e.message}`)
        .join("\n");
      throw new Error(`Invalid router config at ${configPath}:\n${issues}`);
    }

    for (const [id, config] of Object.entries(result.data.routers)) {
      this.routers.set(id, { ...config, id } as RouterConfig);
    }
    log.info({ count: this.routers.size }, "Loaded routers from config");
  }

  getRouter(id: string): RouterConfig {
    const router = this.routers.get(id);
    if (!router) {
      throw new Error(`Router not found: ${id}`);
    }
    return router;
  }

  listRouters(tags?: string[]): RouterConfig[] {
    const all = Array.from(this.routers.values());
    if (!tags || tags.length === 0) return all;
    return all.filter((r) => tags.some((t) => r.tags.includes(t)));
  }

  hasRouter(id: string): boolean {
    return this.routers.has(id);
  }
}
```

- [ ] **Step 4: Run tests**

```bash
npm test -- test/unit/config/router-registry.test.ts
npm run typecheck
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/config/router-registry.ts test/unit/config/router-registry.test.ts
git commit -m "fix: validate router YAML config with Zod at startup, exit fast on bad config"
```

---

### Task 5: Replace loose CIDR regex with a proper validator

**Files:**
- Create: `src/domain/tools/cidr.ts`
- Modify: `src/domain/tools/ip-tools.ts`
- Modify: `src/domain/tools/route-tools.ts`
- Test: `test/unit/domain/cidr.test.ts` (create)

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

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

describe("cidrSchema", () => {
  it("accepts valid CIDR", () => {
    expect(cidrSchema.parse("10.0.0.0/8")).toBe("10.0.0.0/8");
    expect(cidrSchema.parse("192.168.1.0/24")).toBe("192.168.1.0/24");
    expect(cidrSchema.parse("0.0.0.0/0")).toBe("0.0.0.0/0");
  });

  it("auto-appends /32 for bare IPs", () => {
    expect(cidrSchema.parse("10.0.0.1")).toBe("10.0.0.1/32");
  });

  it("rejects octets > 255", () => {
    expect(() => cidrSchema.parse("256.0.0.1/24")).toThrow();
    expect(() => cidrSchema.parse("999.999.999.999/32")).toThrow();
  });

  it("rejects prefix > 32", () => {
    expect(() => cidrSchema.parse("10.0.0.0/33")).toThrow();
    expect(() => cidrSchema.parse("10.0.0.0/99")).toThrow();
  });

  it("rejects non-numeric octets", () => {
    expect(() => cidrSchema.parse("abc.def.ghi.jkl/24")).toThrow();
  });
});
```

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

```bash
npm test -- test/unit/domain/cidr.test.ts
```
Expected: FAIL — file doesn't exist yet.

- [ ] **Step 3: Create src/domain/tools/cidr.ts**

```typescript
import { z } from "zod";

function validateCidr(value: string): string {
  const withPrefix = value.includes("/") ? value : `${value}/32`;
  const slashIdx = withPrefix.lastIndexOf("/");
  const ipPart = withPrefix.slice(0, slashIdx);
  const prefixStr = withPrefix.slice(slashIdx + 1);

  const prefix = Number(prefixStr);
  if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
    throw new Error(`Invalid prefix length "${prefixStr}": must be 0–32`);
  }

  const octets = ipPart.split(".");
  if (octets.length !== 4) {
    throw new Error(`Invalid IP address "${ipPart}": expected 4 octets`);
  }
  for (const octet of octets) {
    const n = Number(octet);
    if (!Number.isInteger(n) || n < 0 || n > 255 || String(n) !== octet) {
      throw new Error(`Invalid octet "${octet}" in IP address "${ipPart}"`);
    }
  }

  return withPrefix;
}

export const cidrSchema = z
  .string()
  .transform((v, ctx) => {
    try {
      return validateCidr(v);
    } catch (e) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, message: (e as Error).message });
      return z.NEVER;
    }
  });
```

- [ ] **Step 4: Run CIDR tests**

```bash
npm test -- test/unit/domain/cidr.test.ts
```
Expected: PASS

- [ ] **Step 5: Update ip-tools.ts to use cidrSchema**

In `src/domain/tools/ip-tools.ts`, add import:
```typescript
import { cidrSchema } from "./cidr.js";
```

Replace the `address` field in `inputSchema`:
```typescript
  address: cidrSchema
    .describe("IP address with optional prefix length in CIDR notation (e.g., 192.168.1.1/24). Defaults to /32 if prefix is omitted."),
```

Remove the old `.regex(...).transform(...)` chain.

- [ ] **Step 6: Update route-tools.ts to use cidrSchema**

In `src/domain/tools/route-tools.ts`, add import:
```typescript
import { cidrSchema } from "./cidr.js";
```

Replace the `dstAddress` field in `manageRouteInputSchema`:
```typescript
  dstAddress: cidrSchema
    .describe("Destination address in CIDR notation or plain IP (auto-converted to /32), e.g. 10.0.0.0/8 or 10.77.0.4"),
```

Remove the old `.regex(...).transform(...)` chain.

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

```bash
npm test && npm run typecheck
```
Expected: All tests pass.

- [ ] **Step 8: Commit**

```bash
git add src/domain/tools/cidr.ts src/domain/tools/ip-tools.ts src/domain/tools/route-tools.ts test/unit/domain/cidr.test.ts
git commit -m "fix: replace loose CIDR regex with proper octet/prefix validator; extract shared cidrSchema"
```

---

### Task 6: Fix boolean idempotency comparisons in interface-tools and route-tools

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

RouterOS returns boolean fields as the string `"true"` or `"false"`. Some comparisons like `rec.disabled === parsed.disabled` compare a string to a boolean and always mismatch, causing false CONFLICT reports.

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

In `test/unit/domain/interface-tools.test.ts`, add to the `create_vlan` section:

```typescript
describe("create_vlan - idempotency boolean comparison", () => {
  it("returns already_exists when disabled field is string 'false' and requested is boolean false", async () => {
    const ctx: ToolContext = {
      routerId: "test-router",
      correlationId: "corr",
      routerConfig: makeRouterConfig(),
      credentials: { username: "admin", password: "secret" },
      routerClient: {
        get: vi.fn().mockResolvedValue([{
          ".id": "*1",
          name: "vlan10",
          "vlan-id": "10",
          interface: "ether1",
          disabled: "false",   // RouterOS returns string
        }]),
      } as unknown as RouterOSRestClient,
    };
    const createVlanTool = interfaceTools[1];
    const result = await createVlanTool.handler({
      routerId: "test-router",
      name: "vlan10",
      vlanId: 10,
      parentInterface: "ether1",
      disabled: false,   // JS boolean
    }, ctx);
    const sc = result.structuredContent as Record<string, unknown>;
    expect(sc.action).toBe("already_exists");
  });
});
```

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

```bash
npm test -- test/unit/domain/interface-tools.test.ts
```
Expected: FAIL — the comparison `found.disabled === parsed.disabled` compares `"false" === false` → `false`, so it doesn't detect the match.

- [ ] **Step 3: Fix interface-tools.ts**

In the `createVlanTool` handler in `src/domain/tools/interface-tools.ts`, the idempotency check currently only compares `vlan-id` and `interface`. The `disabled` field is not compared there, so no fix needed for VLAN idempotency specifically.

However, check the running-status filter in `listInterfacesTool`. It already handles both `true` and `"true"`:
```typescript
const isUp = running === true || running === "true";
```
This is correct. No change needed in list.

For `createVlanTool`, the idempotency check compares `existingVlanId === String(parsed.vlanId)` (coerces to string). This is correct.

- [ ] **Step 4: Fix route-tools.ts boolean comparisons**

In `src/domain/tools/route-tools.ts`, the `manage_route` handler idempotency check:

Find the section comparing `existing` route fields for the `add` action. Look for any direct `===` comparison against JS booleans. The current `list_routes` filter already uses `rec.active === true || rec.active === "true"` — correct.

In `manage_route`, find any idempotency comparison for the `disabled` field:
```typescript
// Current (incorrect):
const sameDisabled = rec.disabled === parsed.disabled;
// Fix:
const sameDisabled = (rec.disabled === "true") === parsed.disabled;
```

Apply the same pattern to any other boolean fields in `manage_route` (check `dynamic` if compared).

- [ ] **Step 5: Run full tests**

```bash
npm test && npm run typecheck
```
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/domain/tools/interface-tools.ts src/domain/tools/route-tools.ts test/unit/domain/interface-tools.test.ts
git commit -m "fix: normalize RouterOS boolean string fields before idempotency comparisons"
```

---

### Task 7: Housekeeping — align version strings

**Files:**
- Modify: `package.json`
- Modify: `src/mcp/server.ts`

- [ ] **Step 1: Update package.json version to 0.4.0**

In `package.json`, change:
```json
"version": "0.1.0",
```
to:
```json
"version": "0.4.0",
```

- [ ] **Step 2: Update McpServer version string**

In `src/mcp/server.ts`, change:
```typescript
      version: "0.2.0",
```
to:
```typescript
      version: "0.4.0",
```

- [ ] **Step 3: Verify build**

```bash
npm run typecheck
```
Expected: No errors.

- [ ] **Step 4: Commit**

```bash
git add package.json src/mcp/server.ts
git commit -m "chore: align version strings to 0.4.0"
```

---

## Phase 2 — New Tools

### Task 8: Bridge tools (list_bridges, manage_bridge, manage_bridge_port)

**Files:**
- Create: `src/domain/tools/bridge-tools.ts`
- Modify: `src/domain/tools/index.ts`
- Test: `test/unit/domain/bridge-tools.test.ts` (create)

RouterOS REST paths: `interface/bridge` for bridge interfaces, `interface/bridge/port` for port members.

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

```typescript
// test/unit/domain/bridge-tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { bridgeTools } from "../../../src/domain/tools/bridge-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";

function makeRouterConfig(): RouterConfig {
  return {
    id: "test-router",
    host: "192.168.1.1",
    port: 443,
    tls: { enabled: true, rejectUnauthorized: false },
    credentials: { source: "env", envPrefix: "ROUTER_TEST" },
    tags: [],
    rosVersion: "7",
  };
}

function makeContext(
  bridgeRecords: Record<string, unknown>[],
  portRecords: Record<string, unknown>[] = [],
): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr",
    routerConfig: makeRouterConfig(),
    credentials: { username: "admin", password: "secret" },
    routerClient: {
      get: vi.fn()
        .mockResolvedValueOnce(bridgeRecords)  // first call: bridges
        .mockResolvedValueOnce(portRecords),   // second call: ports
      create: vi.fn().mockResolvedValue({ ".id": "*1" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

const [listBridgesTool, manageBridgeTool, manageBridgePortTool] = bridgeTools;

describe("bridgeTools", () => {
  describe("metadata", () => {
    it("exports 3 tools", () => {
      expect(bridgeTools).toHaveLength(3);
    });
    it("list_bridges is readOnly", () => {
      expect(listBridgesTool.annotations.readOnlyHint).toBe(true);
    });
    it("manage_bridge is not readOnly", () => {
      expect(manageBridgeTool.annotations.readOnlyHint).toBe(false);
    });
  });

  describe("list_bridges", () => {
    it("returns bridges with port members joined", async () => {
      const ctx = makeContext(
        [{ ".id": "*1", name: "bridge1", running: "true" }],
        [{ ".id": "*2", bridge: "bridge1", interface: "ether2" }],
      );
      const result = await listBridgesTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      const bridges = sc.bridges as Array<Record<string, unknown>>;
      expect(bridges).toHaveLength(1);
      const ports = bridges[0].ports as unknown[];
      expect(ports).toHaveLength(1);
    });
  });

  describe("manage_bridge", () => {
    it("dry-run returns action=dry_run without calling create", async () => {
      const ctx: ToolContext = {
        routerId: "test-router",
        correlationId: "corr",
        routerConfig: makeRouterConfig(),
        credentials: { username: "admin", password: "secret" },
        routerClient: {
          get: vi.fn().mockResolvedValue([]),
          create: vi.fn(),
        } as unknown as RouterOSRestClient,
      };
      const result = await manageBridgeTool.handler({
        routerId: "test-router",
        action: "create",
        name: "bridge2",
        dryRun: true,
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });

    it("returns already_exists when bridge exists with same name", async () => {
      const ctx: ToolContext = {
        routerId: "test-router",
        correlationId: "corr",
        routerConfig: makeRouterConfig(),
        credentials: { username: "admin", password: "secret" },
        routerClient: {
          get: vi.fn().mockResolvedValue([{ ".id": "*1", name: "bridge1" }]),
          create: vi.fn(),
        } as unknown as RouterOSRestClient,
      };
      const result = await manageBridgeTool.handler({
        routerId: "test-router",
        action: "create",
        name: "bridge1",
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("already_exists");
    });
  });

  describe("manage_bridge_port", () => {
    it("rejects extra fields", () => {
      const schema = z.object({
        routerId: z.string(),
        action: z.enum(["add", "remove"]),
        bridge: z.string(),
        interface: z.string(),
        dryRun: z.boolean().default(false),
      }).strict();
      expect(() => schema.parse({ routerId: "r", action: "add", bridge: "b", interface: "e", extra: 1 })).toThrow();
    });
  });
});
```

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

```bash
npm test -- test/unit/domain/bridge-tools.test.ts
```
Expected: FAIL — file doesn't exist.

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

// ---------------------------------------------------------------------------
// list_bridges
// ---------------------------------------------------------------------------

const listBridgesInputSchema = 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 number of bridges to return"),
  offset: z.number().int().min(0).default(0).describe("Offset for pagination"),
}).strict();

const listBridgesTool: ToolDefinition = {
  name: "list_bridges",
  title: "List Bridges",
  description: "List bridge interfaces and their port members on a MikroTik router.",
  inputSchema: listBridgesInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listBridgesInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing bridges");
    try {
      const [bridges, ports] = await Promise.all([
        context.routerClient.get<RouterOSRecord>("interface/bridge"),
        context.routerClient.get<RouterOSRecord>("interface/bridge/port"),
      ]);

      const portsByBridge: Record<string, RouterOSRecord[]> = {};
      for (const port of ports) {
        const rec = port as Record<string, string>;
        const bridgeName = rec.bridge ?? "";
        if (!portsByBridge[bridgeName]) portsByBridge[bridgeName] = [];
        portsByBridge[bridgeName].push(port);
      }

      const enriched = bridges.map((b) => {
        const rec = b as Record<string, string>;
        return { ...rec, ports: portsByBridge[rec.name] ?? [] };
      });

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

      const lines = [`Bridges on ${context.routerId}: ${total} total`];
      for (const b of paginated) {
        const portList = (b.ports as RouterOSRecord[]).map((p) => (p as Record<string, string>).interface).join(", ");
        lines.push(`  ${b.name} [${(b.ports as unknown[]).length} ports${portList ? ": " + portList : ""}]`);
      }

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

// ---------------------------------------------------------------------------
// manage_bridge
// ---------------------------------------------------------------------------

const manageBridgeInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["create", "remove"]).describe("Action to perform"),
  name: z.string().regex(/^[a-zA-Z0-9_-]+$/).max(15).describe("Bridge interface name"),
  comment: z.string().max(255).optional().describe("Optional comment"),
  disabled: z.boolean().default(false).describe("Whether the bridge should be disabled"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const manageBridgeTool: ToolDefinition = {
  name: "manage_bridge",
  title: "Manage Bridge Interface",
  description: "Create or remove a bridge interface on a MikroTik router. Idempotent: create returns already_exists if bridge with same name exists.",
  inputSchema: manageBridgeInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageBridgeInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name }, "Managing bridge");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("interface/bridge", {
        filter: { name: parsed.name },
      });

      if (parsed.action === "create") {
        if (existing.length > 0) {
          return {
            content: `Bridge "${parsed.name}" already exists. No changes made.`,
            structuredContent: { action: "already_exists", bridge: existing[0] },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would create bridge "${parsed.name}".`,
            structuredContent: { action: "dry_run", diff: [
              { property: "name", before: null, after: parsed.name },
              { property: "disabled", before: null, after: parsed.disabled ? "true" : "false" },
            ]},
          };
        }
        const body: Record<string, string> = {
          name: parsed.name,
          disabled: parsed.disabled ? "true" : "false",
        };
        if (parsed.comment) body.comment = parsed.comment.replace(/[\x00-\x1f\x7f]/g, "");
        const created = await context.routerClient.create("interface/bridge", body);
        log.info({ name: parsed.name, id: created[".id"] }, "Bridge created");
        return {
          content: `Created bridge "${parsed.name}".`,
          structuredContent: { action: "created", bridge: created },
        };
      }

      // remove
      if (existing.length === 0) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "BRIDGE_NOT_FOUND",
          message: `Bridge "${parsed.name}" not found.`,
          details: { name: parsed.name },
          recoverability: { retryable: false, suggestedAction: "Verify the bridge name exists on the router." },
        });
      }
      const rec = existing[0] as Record<string, string>;
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove bridge "${parsed.name}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] },
        };
      }
      await context.routerClient.remove("interface/bridge", rec[".id"]);
      log.info({ name: parsed.name }, "Bridge removed");
      return {
        content: `Removed bridge "${parsed.name}".`,
        structuredContent: { action: "removed", name: parsed.name, id: rec[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_bridge" });
    }
  },
};

// ---------------------------------------------------------------------------
// manage_bridge_port
// ---------------------------------------------------------------------------

const manageBridgePortInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove"]).describe("Action to perform"),
  bridge: z.string().describe("Bridge interface name"),
  interface: z.string().describe("Interface to add or remove as a bridge port"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const manageBridgePortTool: ToolDefinition = {
  name: "manage_bridge_port",
  title: "Manage Bridge Port",
  description: "Add or remove an interface from a bridge on a MikroTik router. Idempotent: add returns already_exists if the port assignment already exists.",
  inputSchema: manageBridgePortInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageBridgePortInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, bridge: parsed.bridge, interface: parsed.interface }, "Managing bridge port");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("interface/bridge/port", {
        filter: { bridge: parsed.bridge, interface: parsed.interface },
      });

      if (parsed.action === "add") {
        if (existing.length > 0) {
          return {
            content: `Interface "${parsed.interface}" is already a port on bridge "${parsed.bridge}". No changes made.`,
            structuredContent: { action: "already_exists", port: existing[0] },
          };
        }
        if (parsed.dryRun) {
          return {
            content: `Dry run: Would add "${parsed.interface}" to bridge "${parsed.bridge}".`,
            structuredContent: { action: "dry_run", diff: [
              { property: "bridge", before: null, after: parsed.bridge },
              { property: "interface", before: null, after: parsed.interface },
            ]},
          };
        }
        const created = await context.routerClient.create("interface/bridge/port", {
          bridge: parsed.bridge,
          interface: parsed.interface,
        });
        log.info({ bridge: parsed.bridge, interface: parsed.interface }, "Bridge port added");
        return {
          content: `Added "${parsed.interface}" to bridge "${parsed.bridge}".`,
          structuredContent: { action: "created", port: created },
        };
      }

      // remove
      if (existing.length === 0) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "BRIDGE_PORT_NOT_FOUND",
          message: `Interface "${parsed.interface}" is not a port on bridge "${parsed.bridge}".`,
          details: { bridge: parsed.bridge, interface: parsed.interface },
          recoverability: { retryable: false, suggestedAction: "Verify the interface is a member of the bridge." },
        });
      }
      const rec = existing[0] as Record<string, string>;
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove "${parsed.interface}" from bridge "${parsed.bridge}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "interface", before: parsed.interface, after: null }] },
        };
      }
      await context.routerClient.remove("interface/bridge/port", rec[".id"]);
      log.info({ bridge: parsed.bridge, interface: parsed.interface }, "Bridge port removed");
      return {
        content: `Removed "${parsed.interface}" from bridge "${parsed.bridge}".`,
        structuredContent: { action: "removed", bridge: parsed.bridge, interface: parsed.interface, id: rec[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_bridge_port" });
    }
  },
};

export const bridgeTools: ToolDefinition[] = [listBridgesTool, manageBridgeTool, manageBridgePortTool];
```

- [ ] **Step 4: Register bridge tools in index.ts**

In `src/domain/tools/index.ts`, add:
```typescript
import { bridgeTools } from "./bridge-tools.js";
```
And add `...bridgeTools,` to `allTools`.

- [ ] **Step 5: Run tests**

```bash
npm test -- test/unit/domain/bridge-tools.test.ts
npm run typecheck
```
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/domain/tools/bridge-tools.ts src/domain/tools/index.ts test/unit/domain/bridge-tools.test.ts
git commit -m "feat: add bridge tools (list_bridges, manage_bridge, manage_bridge_port)"
```

---

### Task 9: WiFi tools (list_wifi_interfaces, list_wifi_clients, manage_wifi_interface)

**Files:**
- Create: `src/domain/tools/wifi-tools.ts`
- Modify: `src/domain/tools/index.ts`
- Test: `test/unit/domain/wifi-tools.test.ts` (create)

RouterOS path selection: `routerConfig.rosVersion` starting with `"7"` uses `/interface/wifi` (ROS 7.13+); others use `/interface/wireless`. For clients: `/interface/wifi/registration-table` vs `/interface/wireless/registration-table`.

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

```typescript
// test/unit/domain/wifi-tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { wifiTools } from "../../../src/domain/tools/wifi-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";

function makeRouterConfig(rosVersion = "7"): RouterConfig {
  return {
    id: "test-router",
    host: "192.168.1.1",
    port: 443,
    tls: { enabled: true, rejectUnauthorized: false },
    credentials: { source: "env", envPrefix: "ROUTER_TEST" },
    tags: [],
    rosVersion,
  };
}

function makeContext(records: Record<string, unknown>[], rosVersion = "7"): ToolContext {
  return {
    routerId: "test-router",
    correlationId: "corr",
    routerConfig: makeRouterConfig(rosVersion),
    credentials: { username: "admin", password: "secret" },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      update: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

const [listWifiTool, listWifiClientsTool, manageWifiTool] = wifiTools;

describe("wifiTools", () => {
  describe("metadata", () => {
    it("exports 3 tools", () => expect(wifiTools).toHaveLength(3));
    it("list_wifi_interfaces is readOnly", () => expect(listWifiTool.annotations.readOnlyHint).toBe(true));
    it("manage_wifi_interface is not readOnly", () => expect(manageWifiTool.annotations.readOnlyHint).toBe(false));
  });

  describe("list_wifi_interfaces", () => {
    it("uses /interface/wifi for ROS 7", async () => {
      const ctx = makeContext([{ ".id": "*1", name: "wifi1", ssid: "MyNet", disabled: "false" }], "7");
      await listWifiTool.handler({ routerId: "test-router" }, ctx);
      expect(ctx.routerClient.get).toHaveBeenCalledWith("interface/wifi", expect.anything());
    });

    it("uses /interface/wireless for ROS 6", async () => {
      const ctx = makeContext([{ ".id": "*1", name: "wlan1", ssid: "MyNet", disabled: "false" }], "6");
      await listWifiTool.handler({ routerId: "test-router" }, ctx);
      expect(ctx.routerClient.get).toHaveBeenCalledWith("interface/wireless", expect.anything());
    });

    it("returns interfaces in structuredContent", async () => {
      const ctx = makeContext([{ ".id": "*1", name: "wifi1", ssid: "MyNet", disabled: "false" }]);
      const result = await listWifiTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.interfaces as unknown[]).length).toBe(1);
    });
  });

  describe("manage_wifi_interface - dry run", () => {
    it("returns dry_run without calling update", async () => {
      const ctx = makeContext([{ ".id": "*1", name: "wifi1", disabled: "false", ssid: "OldNet" }]);
      const result = await manageWifiTool.handler({
        routerId: "test-router",
        name: "wifi1",
        ssid: "NewNet",
        dryRun: true,
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("dry_run");
      expect(ctx.routerClient.update).not.toHaveBeenCalled();
    });
  });
});
```

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

```bash
npm test -- test/unit/domain/wifi-tools.test.ts
```
Expected: FAIL

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

function wifiPath(rosVersion: string): string {
  return rosVersion.startsWith("7") ? "interface/wifi" : "interface/wireless";
}

function wifiRegistrationPath(rosVersion: string): string {
  return rosVersion.startsWith("7")
    ? "interface/wifi/registration-table"
    : "interface/wireless/registration-table";
}

// ---------------------------------------------------------------------------
// list_wifi_interfaces
// ---------------------------------------------------------------------------

const listWifiInputSchema = 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 number of interfaces to return"),
  offset: z.number().int().min(0).default(0).describe("Offset for pagination"),
}).strict();

const listWifiTool: ToolDefinition = {
  name: "list_wifi_interfaces",
  title: "List WiFi Interfaces",
  description:
    "List WiFi/wireless interfaces on a MikroTik router. Uses /interface/wifi on ROS 7.x, /interface/wireless on older versions.",
  inputSchema: listWifiInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listWifiInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing WiFi interfaces");
    try {
      const path = wifiPath(context.routerConfig.rosVersion);
      const interfaces = await context.routerClient.get<RouterOSRecord>(path, {
        limit: undefined,
        offset: undefined,
      });
      const total = interfaces.length;
      const paginated = interfaces.slice(parsed.offset, parsed.offset + parsed.limit);
      const hasMore = parsed.offset + parsed.limit < total;

      const lines = [`WiFi interfaces on ${context.routerId}: ${total} total`];
      for (const iface of paginated) {
        const rec = iface as Record<string, string>;
        const status = (rec.disabled === "true") ? "DISABLED" : "ENABLED";
        lines.push(`  ${rec.name} [${rec.ssid ?? "no-ssid"}] ${status}`);
      }

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

// ---------------------------------------------------------------------------
// list_wifi_clients
// ---------------------------------------------------------------------------

const listWifiClientsInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  interface: z.string().optional().describe("Filter by WiFi interface name"),
  limit: z.number().int().min(1).max(500).default(100).describe("Maximum number of clients to return"),
  offset: z.number().int().min(0).default(0).describe("Offset for pagination"),
}).strict();

const listWifiClientsTool: ToolDefinition = {
  name: "list_wifi_clients",
  title: "List WiFi Clients",
  description:
    "List currently connected WiFi clients (stations) with signal strength and transfer rates.",
  inputSchema: listWifiClientsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listWifiClientsInputSchema.parse(params);
    log.info({ routerId: context.routerId, interface: parsed.interface }, "Listing WiFi clients");
    try {
      const path = wifiRegistrationPath(context.routerConfig.rosVersion);
      const filter = parsed.interface ? { interface: parsed.interface } : undefined;
      let clients = await context.routerClient.get<RouterOSRecord>(path, { filter });

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

      const lines = [`WiFi clients on ${context.routerId}: ${total} total`];
      for (const c of clients) {
        const rec = c as Record<string, string>;
        lines.push(`  ${rec["mac-address"] ?? rec[".id"]} on ${rec.interface ?? "unknown"} signal=${rec["signal-strength"] ?? "?"}`);
      }

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

// ---------------------------------------------------------------------------
// manage_wifi_interface
// ---------------------------------------------------------------------------

const manageWifiInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  name: z.string().describe("WiFi interface name (e.g. wifi1, wlan1)"),
  disabled: z.boolean().optional().describe("Set to true to disable, false to enable"),
  ssid: z.string().max(32).optional().describe("New SSID to set"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const manageWifiTool: ToolDefinition = {
  name: "manage_wifi_interface",
  title: "Manage WiFi Interface",
  description:
    "Enable, disable, or update SSID settings on a WiFi interface. At least one of disabled or ssid must be provided.",
  inputSchema: manageWifiInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageWifiInputSchema.parse(params);

    if (parsed.disabled === undefined && parsed.ssid === undefined) {
      throw new MikroMCPError({
        category: ErrorCategory.VALIDATION,
        code: "NO_CHANGES_SPECIFIED",
        message: "At least one of 'disabled' or 'ssid' must be provided.",
        recoverability: { retryable: false, suggestedAction: "Provide disabled and/or ssid to change." },
      });
    }

    log.info({ routerId: context.routerId, name: parsed.name }, "Managing WiFi interface");
    try {
      const path = wifiPath(context.routerConfig.rosVersion);
      const existing = await context.routerClient.get<RouterOSRecord>(path, {
        filter: { name: parsed.name },
      });

      if (existing.length === 0) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "WIFI_INTERFACE_NOT_FOUND",
          message: `WiFi interface "${parsed.name}" not found.`,
          details: { name: parsed.name },
          recoverability: { retryable: false, suggestedAction: "Verify the interface name with list_wifi_interfaces." },
        });
      }

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

      if (parsed.disabled !== undefined) {
        const desired = parsed.disabled ? "true" : "false";
        if (rec.disabled !== desired) {
          changes.disabled = desired;
          diff.push({ property: "disabled", before: rec.disabled ?? null, after: desired });
        }
      }
      if (parsed.ssid !== undefined && rec.ssid !== parsed.ssid) {
        changes.ssid = parsed.ssid;
        diff.push({ property: "ssid", before: rec.ssid ?? null, after: parsed.ssid });
      }

      if (diff.length === 0) {
        return {
          content: `WiFi interface "${parsed.name}" already has the requested configuration. No changes made.`,
          structuredContent: { action: "no_change", interface: existing[0] },
        };
      }

      if (parsed.dryRun) {
        return {
          content: `Dry run: Would update WiFi interface "${parsed.name}": ${diff.map((d) => `${d.property}: ${d.before} → ${d.after}`).join(", ")}.`,
          structuredContent: { action: "dry_run", diff },
        };
      }

      await context.routerClient.update(path, rec[".id"], changes);
      log.info({ name: parsed.name, changes }, "WiFi interface updated");
      return {
        content: `Updated WiFi interface "${parsed.name}": ${diff.map((d) => `${d.property}: ${d.before} → ${d.after}`).join(", ")}.`,
        structuredContent: { action: "updated", name: parsed.name, changes: diff },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_wifi_interface" });
    }
  },
};

export const wifiTools: ToolDefinition[] = [listWifiTool, listWifiClientsTool, manageWifiTool];
```

- [ ] **Step 4: Register wifi tools in index.ts**

```typescript
import { wifiTools } from "./wifi-tools.js";
// add ...wifiTools to allTools
```

- [ ] **Step 5: Run tests**

```bash
npm test -- test/unit/domain/wifi-tools.test.ts
npm run typecheck
```
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/domain/tools/wifi-tools.ts src/domain/tools/index.ts test/unit/domain/wifi-tools.test.ts
git commit -m "feat: add WiFi tools (list_wifi_interfaces, list_wifi_clients, manage_wifi_interface)"
```

---

### Task 10: WireGuard tools (list_wireguard_interfaces, list_wireguard_peers, manage_wireguard_peer)

**Files:**
- Create: `src/domain/tools/wireguard-tools.ts`
- Modify: `src/domain/tools/index.ts`
- Test: `test/unit/domain/wireguard-tools.test.ts` (create)

RouterOS paths: `interface/wireguard` for interfaces, `interface/wireguard/peers` for peers. Peer idempotency key: `public-key` field.

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

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

function makeRouterConfig(): RouterConfig {
  return {
    id: "test-router", host: "192.168.1.1", port: 443,
    tls: { enabled: true, rejectUnauthorized: false },
    credentials: { source: "env", envPrefix: "ROUTER_TEST" },
    tags: [], rosVersion: "7",
  };
}

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

const [listWgTool, listPeersTool, managePeerTool] = wireguardTools;

describe("wireguardTools", () => {
  describe("metadata", () => {
    it("exports 3 tools", () => expect(wireguardTools).toHaveLength(3));
    it("list_wireguard_interfaces is readOnly", () => expect(listWgTool.annotations.readOnlyHint).toBe(true));
    it("manage_wireguard_peer is not readOnly", () => expect(managePeerTool.annotations.readOnlyHint).toBe(false));
  });

  describe("list_wireguard_interfaces", () => {
    it("returns interfaces in structuredContent", async () => {
      const ctx = makeContext([{ ".id": "*1", name: "wg0", "listen-port": "51820", "running": "true" }]);
      const result = await listWgTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.interfaces as unknown[]).length).toBe(1);
    });
  });

  describe("manage_wireguard_peer", () => {
    const testPublicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";

    it("returns already_exists when peer with same public key exists", async () => {
      const ctx = makeContext([{ ".id": "*1", interface: "wg0", "public-key": testPublicKey }]);
      const result = await managePeerTool.handler({
        routerId: "test-router", action: "add",
        interface: "wg0", publicKey: testPublicKey,
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("already_exists");
    });

    it("throws NOT_FOUND when removing non-existent peer", async () => {
      const ctx = makeContext([]);
      await expect(managePeerTool.handler({
        routerId: "test-router", action: "remove",
        interface: "wg0", publicKey: testPublicKey,
      }, ctx)).rejects.toMatchObject({ category: ErrorCategory.NOT_FOUND });
    });

    it("dry-run add returns dry_run without calling create", async () => {
      const ctx = makeContext([]);
      const result = await managePeerTool.handler({
        routerId: "test-router", action: "add",
        interface: "wg0", publicKey: testPublicKey, dryRun: true,
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });
  });
});
```

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

```bash
npm test -- test/unit/domain/wireguard-tools.test.ts
```
Expected: FAIL

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

// ---------------------------------------------------------------------------
// list_wireguard_interfaces
// ---------------------------------------------------------------------------

const listWgInputSchema = 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 number of interfaces to return"),
  offset: z.number().int().min(0).default(0).describe("Offset for pagination"),
}).strict();

const listWgTool: ToolDefinition = {
  name: "list_wireguard_interfaces",
  title: "List WireGuard Interfaces",
  description: "List WireGuard interfaces and their status on a MikroTik router.",
  inputSchema: listWgInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listWgInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing WireGuard interfaces");
    try {
      const interfaces = await context.routerClient.get<RouterOSRecord>("interface/wireguard", {
        limit: undefined, offset: undefined,
      });
      const total = interfaces.length;
      const paginated = interfaces.slice(parsed.offset, parsed.offset + parsed.limit);
      const hasMore = parsed.offset + parsed.limit < total;

      const lines = [`WireGuard interfaces on ${context.routerId}: ${total} total`];
      for (const iface of paginated) {
        const rec = iface as Record<string, string>;
        const running = rec.running === "true" ? "UP" : "DOWN";
        lines.push(`  ${rec.name} [port=${rec["listen-port"] ?? "?"}] ${running}`);
      }

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

// ---------------------------------------------------------------------------
// list_wireguard_peers
// ---------------------------------------------------------------------------

const listPeersInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  interface: z.string().optional().describe("Filter by WireGuard interface name"),
  limit: z.number().int().min(1).max(500).default(100).describe("Maximum number of peers to return"),
  offset: z.number().int().min(0).default(0).describe("Offset for pagination"),
}).strict();

const listPeersTool: ToolDefinition = {
  name: "list_wireguard_peers",
  title: "List WireGuard Peers",
  description: "List WireGuard peers with last handshake time and transfer statistics.",
  inputSchema: listPeersInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listPeersInputSchema.parse(params);
    log.info({ routerId: context.routerId, interface: parsed.interface }, "Listing WireGuard peers");
    try {
      const filter = parsed.interface ? { interface: parsed.interface } : undefined;
      let peers = await context.routerClient.get<RouterOSRecord>("interface/wireguard/peers", { filter });

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

      const lines = [`WireGuard peers on ${context.routerId}: ${total} total`];
      for (const peer of peers) {
        const rec = peer as Record<string, string>;
        const key = rec["public-key"] ? rec["public-key"].slice(0, 8) + "…" : "unknown";
        lines.push(`  [${rec.interface ?? "?"}] ${key} last-handshake=${rec["last-handshake"] ?? "never"}`);
      }

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

// ---------------------------------------------------------------------------
// manage_wireguard_peer
// ---------------------------------------------------------------------------

const managePeerInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  action: z.enum(["add", "remove"]).describe("Action to perform"),
  interface: z.string().describe("WireGuard interface name (e.g. wg0)"),
  publicKey: z.string().min(1).describe("Peer public key in base64 format (44 characters)"),
  allowedAddress: z.string().optional().describe("Allowed IP address/CIDR for this peer (e.g. 10.0.0.2/32)"),
  endpoint: z.string().optional().describe("Peer endpoint as IP:port (e.g. 1.2.3.4:51820)"),
  comment: z.string().max(255).optional().describe("Optional comment"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const managePeerTool: ToolDefinition = {
  name: "manage_wireguard_peer",
  title: "Manage WireGuard Peer",
  description:
    "Add or remove a WireGuard peer. Idempotent by public key: add returns already_exists if a peer with the same public key already exists on the interface.",
  inputSchema: managePeerInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = managePeerInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, interface: parsed.interface }, "Managing WireGuard peer");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("interface/wireguard/peers", {
        filter: { interface: parsed.interface, "public-key": parsed.publicKey },
      });

      if (parsed.action === "add") {
        if (existing.length > 0) {
          return {
            content: `WireGuard peer with this public key already exists on "${parsed.interface}". No changes made.`,
            structuredContent: { action: "already_exists", peer: existing[0] },
          };
        }
        if (parsed.dryRun) {
          const diff = [
            { property: "interface", before: null, after: parsed.interface },
            { property: "public-key", before: null, after: parsed.publicKey },
            ...(parsed.allowedAddress ? [{ property: "allowed-address", before: null, after: parsed.allowedAddress }] : []),
            ...(parsed.endpoint ? [{ property: "endpoint-address", before: null, after: parsed.endpoint }] : []),
          ];
          return {
            content: `Dry run: Would add WireGuard peer on "${parsed.interface}".`,
            structuredContent: { action: "dry_run", diff },
          };
        }
        const body: Record<string, string> = {
          interface: parsed.interface,
          "public-key": parsed.publicKey,
        };
        if (parsed.allowedAddress) body["allowed-address"] = parsed.allowedAddress;
        if (parsed.endpoint) body["endpoint-address"] = parsed.endpoint;
        if (parsed.comment) body.comment = parsed.comment.replace(/[\x00-\x1f\x7f]/g, "");

        const created = await context.routerClient.create("interface/wireguard/peers", body);
        log.info({ interface: parsed.interface, id: created[".id"] }, "WireGuard peer added");
        return {
          content: `Added WireGuard peer on "${parsed.interface}".`,
          structuredContent: { action: "created", peer: created },
        };
      }

      // remove
      if (existing.length === 0) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "WIREGUARD_PEER_NOT_FOUND",
          message: `WireGuard peer with this public key not found on interface "${parsed.interface}".`,
          details: { interface: parsed.interface, publicKey: parsed.publicKey },
          recoverability: { retryable: false, suggestedAction: "Verify the public key and interface with list_wireguard_peers." },
        });
      }
      const rec = existing[0] as Record<string, string>;
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove WireGuard peer from "${parsed.interface}".`,
          structuredContent: { action: "dry_run", diff: [{ property: "public-key", before: parsed.publicKey, after: null }] },
        };
      }
      await context.routerClient.remove("interface/wireguard/peers", rec[".id"]);
      log.info({ interface: parsed.interface }, "WireGuard peer removed");
      return {
        content: `Removed WireGuard peer from "${parsed.interface}".`,
        structuredContent: { action: "removed", interface: parsed.interface, id: rec[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_wireguard_peer" });
    }
  },
};

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

- [ ] **Step 4: Register wireguard tools in index.ts**

```typescript
import { wireguardTools } from "./wireguard-tools.js";
// add ...wireguardTools to allTools
```

- [ ] **Step 5: Run tests**

```bash
npm test -- test/unit/domain/wireguard-tools.test.ts
npm run typecheck
```
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/domain/tools/wireguard-tools.ts src/domain/tools/index.ts test/unit/domain/wireguard-tools.test.ts
git commit -m "feat: add WireGuard tools (list_wireguard_interfaces, list_wireguard_peers, manage_wireguard_peer)"
```

---

### Task 11: DNS tools (list_dns_entries, manage_dns_entry, get_dns_settings)

**Files:**
- Create: `src/domain/tools/dns-tools.ts`
- Modify: `src/domain/tools/index.ts`
- Test: `test/unit/domain/dns-tools.test.ts` (create)

RouterOS paths: `/ip/dns/static` for static entries, `/ip/dns` for resolver settings. Idempotency key for entries: `name` + `type`.

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

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

function makeRouterConfig(): RouterConfig {
  return {
    id: "test-router", host: "192.168.1.1", port: 443,
    tls: { enabled: true, rejectUnauthorized: false },
    credentials: { source: "env", envPrefix: "ROUTER_TEST" },
    tags: [], rosVersion: "7",
  };
}

function makeGetContext(records: Record<string, unknown>[]): ToolContext {
  return {
    routerId: "test-router", correlationId: "corr",
    routerConfig: makeRouterConfig(),
    credentials: { username: "admin", password: "secret" },
    routerClient: {
      get: vi.fn().mockResolvedValue(records),
      create: vi.fn().mockResolvedValue({ ".id": "*1" }),
      remove: vi.fn().mockResolvedValue(undefined),
    } as unknown as RouterOSRestClient,
  };
}

const [listDnsTool, manageDnsTool, getDnsSettingsTool] = dnsTools;

describe("dnsTools", () => {
  describe("metadata", () => {
    it("exports 3 tools", () => expect(dnsTools).toHaveLength(3));
    it("list_dns_entries is readOnly", () => expect(listDnsTool.annotations.readOnlyHint).toBe(true));
    it("get_dns_settings is readOnly", () => expect(getDnsSettingsTool.annotations.readOnlyHint).toBe(true));
    it("manage_dns_entry is not readOnly", () => expect(manageDnsTool.annotations.readOnlyHint).toBe(false));
  });

  describe("list_dns_entries", () => {
    it("returns entries in structuredContent", async () => {
      const ctx = makeGetContext([
        { ".id": "*1", name: "host.lan", type: "A", address: "10.0.0.5" },
      ]);
      const result = await listDnsTool.handler({ routerId: "test-router" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.entries as unknown[]).length).toBe(1);
    });

    it("filters by type", async () => {
      const ctx = makeGetContext([
        { ".id": "*1", name: "host.lan", type: "A", address: "10.0.0.5" },
        { ".id": "*2", name: "alias.lan", type: "CNAME", cname: "host.lan" },
      ]);
      const result = await listDnsTool.handler({ routerId: "test-router", type: "A" }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect((sc.entries as unknown[]).length).toBe(1);
    });
  });

  describe("manage_dns_entry", () => {
    it("returns already_exists when same name+type exists", async () => {
      const ctx = makeGetContext([{ ".id": "*1", name: "host.lan", type: "A", address: "10.0.0.5" }]);
      const result = await manageDnsTool.handler({
        routerId: "test-router", action: "add",
        name: "host.lan", type: "A", address: "10.0.0.5",
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("already_exists");
    });

    it("throws NOT_FOUND when removing non-existent entry", async () => {
      const ctx = makeGetContext([]);
      await expect(manageDnsTool.handler({
        routerId: "test-router", action: "remove",
        name: "missing.lan", type: "A",
      }, ctx)).rejects.toMatchObject({ category: ErrorCategory.NOT_FOUND });
    });

    it("dry-run returns dry_run without calling create", async () => {
      const ctx = makeGetContext([]);
      const result = await manageDnsTool.handler({
        routerId: "test-router", action: "add",
        name: "new.lan", type: "A", address: "10.0.0.9", dryRun: true,
      }, ctx);
      const sc = result.structuredContent as Record<string, unknown>;
      expect(sc.action).toBe("dry_run");
      expect(ctx.routerClient.create).not.toHaveBeenCalled();
    });
  });
});
```

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

```bash
npm test -- test/unit/domain/dns-tools.test.ts
```
Expected: FAIL

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

// ---------------------------------------------------------------------------
// list_dns_entries
// ---------------------------------------------------------------------------

const listDnsInputSchema = z.object({
  routerId: z.string().describe("Target router identifier from the router registry"),
  name: z.string().optional().describe("Filter by hostname (partial match)"),
  type: z.enum(["A", "CNAME", "TXT", "all"]).default("all").describe("Filter by record type"),
  limit: z.number().int().min(1).max(500).default(100).describe("Maximum number of entries to return"),
  offset: z.number().int().min(0).default(0).describe("Offset for pagination"),
}).strict();

const listDnsTool: ToolDefinition = {
  name: "list_dns_entries",
  title: "List DNS Entries",
  description: "List static DNS entries on a MikroTik router with optional filtering by name and type.",
  inputSchema: listDnsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = listDnsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Listing DNS entries");
    try {
      let entries = await context.routerClient.get<RouterOSRecord>("ip/dns/static", {
        limit: undefined, offset: undefined,
      });

      if (parsed.type !== "all") {
        entries = entries.filter((e) => (e as Record<string, string>).type === parsed.type);
      }
      if (parsed.name) {
        const needle = parsed.name.toLowerCase();
        entries = entries.filter((e) => ((e as Record<string, string>).name ?? "").toLowerCase().includes(needle));
      }

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

      const lines = [`DNS entries on ${context.routerId}: ${total} total`];
      for (const entry of paginated) {
        const rec = entry as Record<string, string>;
        const value = rec.address ?? rec.cname ?? rec.text ?? "?";
        lines.push(`  ${rec.name} ${rec.type ?? "A"} → ${value}`);
      }

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

// ---------------------------------------------------------------------------
// manage_dns_entry
// ---------------------------------------------------------------------------

const manageDnsInputSchema = 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().min(1).describe("Hostname for the DNS record (e.g. server.example.com)"),
  type: z.enum(["A", "CNAME", "TXT"]).default("A").describe("DNS record type"),
  address: z.string().optional().describe("IP address (required for A records)"),
  cname: z.string().optional().describe("Target hostname (required for CNAME records)"),
  text: z.string().optional().describe("Text value (required for TXT records)"),
  ttl: z.string().optional().describe("TTL value (e.g. 1d, 00:05:00)"),
  comment: z.string().max(255).optional().describe("Optional comment"),
  disabled: z.boolean().default(false).describe("Whether the entry should be disabled"),
  dryRun: z.boolean().default(false).describe("Preview changes without applying"),
}).strict();

const manageDnsTool: ToolDefinition = {
  name: "manage_dns_entry",
  title: "Manage DNS Entry",
  description:
    "Add or remove a static DNS entry. Idempotent by name+type: add returns already_exists if the same record already exists.",
  inputSchema: manageDnsInputSchema,
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    const parsed = manageDnsInputSchema.parse(params);
    log.info({ routerId: context.routerId, action: parsed.action, name: parsed.name, type: parsed.type }, "Managing DNS entry");
    try {
      const existing = await context.routerClient.get<RouterOSRecord>("ip/dns/static", {
        filter: { name: parsed.name, type: parsed.type },
      });

      if (parsed.action === "add") {
        if (existing.length > 0) {
          return {
            content: `DNS entry "${parsed.name}" (${parsed.type}) already exists. No changes made.`,
            structuredContent: { action: "already_exists", entry: existing[0] },
          };
        }

        if (parsed.type === "A" && !parsed.address) {
          throw new MikroMCPError({
            category: ErrorCategory.VALIDATION,
            code: "DNS_MISSING_ADDRESS",
            message: "address is required for A records.",
            recoverability: { retryable: false, suggestedAction: "Provide the address parameter." },
          });
        }
        if (parsed.type === "CNAME" && !parsed.cname) {
          throw new MikroMCPError({
            category: ErrorCategory.VALIDATION,
            code: "DNS_MISSING_CNAME",
            message: "cname is required for CNAME records.",
            recoverability: { retryable: false, suggestedAction: "Provide the cname parameter." },
          });
        }
        if (parsed.type === "TXT" && !parsed.text) {
          throw new MikroMCPError({
            category: ErrorCategory.VALIDATION,
            code: "DNS_MISSING_TEXT",
            message: "text is required for TXT records.",
            recoverability: { retryable: false, suggestedAction: "Provide the text parameter." },
          });
        }

        if (parsed.dryRun) {
          const value = parsed.address ?? parsed.cname ?? parsed.text ?? "";
          return {
            content: `Dry run: Would add DNS entry "${parsed.name}" ${parsed.type} → ${value}.`,
            structuredContent: { action: "dry_run", diff: [
              { property: "name", before: null, after: parsed.name },
              { property: "type", before: null, after: parsed.type },
            ]},
          };
        }

        const body: Record<string, string> = {
          name: parsed.name,
          type: parsed.type,
          disabled: parsed.disabled ? "true" : "false",
        };
        if (parsed.address) body.address = parsed.address;
        if (parsed.cname) body.cname = parsed.cname;
        if (parsed.text) body.text = parsed.text;
        if (parsed.ttl) body.ttl = parsed.ttl;
        if (parsed.comment) body.comment = parsed.comment.replace(/[\x00-\x1f\x7f]/g, "");

        const created = await context.routerClient.create("ip/dns/static", body);
        log.info({ name: parsed.name, id: created[".id"] }, "DNS entry created");
        return {
          content: `Added DNS entry "${parsed.name}" (${parsed.type}).`,
          structuredContent: { action: "created", entry: created },
        };
      }

      // remove
      if (existing.length === 0) {
        throw new MikroMCPError({
          category: ErrorCategory.NOT_FOUND,
          code: "DNS_ENTRY_NOT_FOUND",
          message: `DNS entry "${parsed.name}" (${parsed.type}) not found.`,
          details: { name: parsed.name, type: parsed.type },
          recoverability: { retryable: false, suggestedAction: "Verify the entry with list_dns_entries." },
        });
      }
      const rec = existing[0] as Record<string, string>;
      if (parsed.dryRun) {
        return {
          content: `Dry run: Would remove DNS entry "${parsed.name}" (${parsed.type}).`,
          structuredContent: { action: "dry_run", diff: [{ property: "name", before: parsed.name, after: null }] },
        };
      }
      await context.routerClient.remove("ip/dns/static", rec[".id"]);
      log.info({ name: parsed.name, type: parsed.type }, "DNS entry removed");
      return {
        content: `Removed DNS entry "${parsed.name}" (${parsed.type}).`,
        structuredContent: { action: "removed", name: parsed.name, type: parsed.type, id: rec[".id"] },
      };
    } catch (err) {
      if (err instanceof MikroMCPError) throw err;
      throw enrichError(err, { routerId: context.routerId, tool: "manage_dns_entry" });
    }
  },
};

// ---------------------------------------------------------------------------
// get_dns_settings
// ---------------------------------------------------------------------------

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

const getDnsSettingsTool: ToolDefinition = {
  name: "get_dns_settings",
  title: "Get DNS Settings",
  description:
    "Read DNS resolver configuration: upstream servers, cache size, cache TTL, and whether remote DNS requests are allowed.",
  inputSchema: getDnsSettingsInputSchema,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
    getDnsSettingsInputSchema.parse(params);
    log.info({ routerId: context.routerId }, "Getting DNS settings");
    try {
      const results = await context.routerClient.get<RouterOSRecord>("ip/dns");
      const settings = (Array.isArray(results) && results.length > 0 ? results[0] : results) as Record<string, string>;

      const servers = settings.servers ?? settings.server ?? "none";
      const cacheSize = settings["cache-size"] ?? "?";
      const cacheTtl = settings["cache-max-ttl"] ?? "?";
      const allowRemote = settings["allow-remote-requests"] ?? "false";

      return {
        content: [
          `DNS settings on ${context.routerId}:`,
          `  servers: ${servers}`,
          `  cache-size: ${cacheSize}`,
          `  cache-max-ttl: ${cacheTtl}`,
          `  allow-remote-requests: ${allowRemote}`,
        ].join("\n"),
        structuredContent: { routerId: context.routerId, settings },
      };
    } catch (err) {
      throw enrichError(err, { routerId: context.routerId, tool: "get_dns_settings" });
    }
  },
};

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

- [ ] **Step 4: Register DNS tools in index.ts**

```typescript
import { dnsTools } from "./dns-tools.js";
// add ...dnsTools to allTools
```

- [ ] **Step 5: Run tests**

```bash
npm test -- test/unit/domain/dns-tools.test.ts
npm run typecheck
```
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/domain/tools/dns-tools.ts src/domain/tools/index.ts test/unit/domain/dns-tools.test.ts
git commit -m "feat: add DNS tools (list_dns_entries, manage_dns_entry, get_dns_settings)"
```

---

### Task 12: Run full suite and update documentation

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

- [ ] **Step 1: Run full test suite and type check**

```bash
npm test && npm run typecheck
```
Expected: All tests pass, no type errors.

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

Change `## 🔜 v0.4 — Network Services` to `## ✅ v0.4 — Network Services`.

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

Find the tool count in the mermaid diagram and Available Tools table and update to reflect the new tools:
- `list_bridges`, `manage_bridge`, `manage_bridge_port`
- `list_wifi_interfaces`, `list_wifi_clients`, `manage_wifi_interface`
- `list_wireguard_interfaces`, `list_wireguard_peers`, `manage_wireguard_peer`
- `list_dns_entries`, `manage_dns_entry`, `get_dns_settings`

- [ ] **Step 4: Commit**

```bash
git add ROADMAP.md README.md
git commit -m "docs: mark v0.4 complete, update tool count and available tools table"
```
