#!/usr/bin/env node
/**
 * Livada SEO — MCP server
 *
 * Exposes the Livada SEO WordPress plugin's REST API as MCP tools so Claude
 * Code can read settings, edit post SEO meta, manage redirects, run the
 * analyzer, flush the sitemap, ping IndexNow, and pull the dashboard stats
 * directly from a chat conversation.
 *
 * Auth: WordPress Application Passwords (Basic auth over HTTPS).
 *
 * Required environment variables:
 *   WP_URL           — base URL of the site (e.g. https://camping-la-ferme.com)
 *   WP_USER          — admin user with manage_options capability
 *   WP_APP_PASSWORD  — Application Password (Users → Profile → Application Passwords)
 */

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

// ── Environment ─────────────────────────────────────────────────────────────

const WP_URL = (process.env.WP_URL || "").replace(/\/+$/, "");
const WP_USER = process.env.WP_USER || "";
const WP_APP_PASSWORD = (process.env.WP_APP_PASSWORD || "").replace(/\s+/g, "");

if (!WP_URL || !WP_USER || !WP_APP_PASSWORD) {
  console.error(
    "[livada-seo-mcp] Missing env vars. Required: WP_URL, WP_USER, WP_APP_PASSWORD."
  );
  process.exit(1);
}

const AUTH_HEADER =
  "Basic " + Buffer.from(`${WP_USER}:${WP_APP_PASSWORD}`).toString("base64");

const REST_BASE = `${WP_URL}/wp-json/livada-seo/v1`;
const WP_REST_BASE = `${WP_URL}/wp-json/wp/v2`;

// ── HTTP helper ─────────────────────────────────────────────────────────────

async function wpFetch(
  path: string,
  init: RequestInit = {},
  base: string = REST_BASE
): Promise<unknown> {
  const url = base + path;
  const res = await fetch(url, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      Authorization: AUTH_HEADER,
      ...(init.headers || {}),
    },
  });

  const text = await res.text();
  let body: unknown = text;
  try {
    body = text ? JSON.parse(text) : null;
  } catch {
    /* keep raw text on non-JSON */
  }

  if (!res.ok) {
    const detail =
      typeof body === "object" && body !== null && "message" in body
        ? (body as { message: string }).message
        : text;
    throw new Error(`HTTP ${res.status} ${res.statusText} on ${url}: ${detail}`);
  }

  return body;
}

// ── Tool input schemas ──────────────────────────────────────────────────────

const PostIdSchema = z.object({
  post_id: z.number().int().positive(),
});

const UpdateSettingsSchema = z.object({
  settings: z
    .record(z.union([z.string(), z.number(), z.boolean(), z.array(z.any())]))
    .describe("Partial settings object: only the keys provided are updated."),
});

const UpdatePostMetaSchema = z.object({
  post_id: z.number().int().positive(),
  title: z.string().optional(),
  description: z.string().optional(),
  focus_keyword: z.string().optional(),
  noindex: z.boolean().optional(),
  nofollow: z.boolean().optional(),
  canonical: z.string().url().optional(),
  og_image: z.string().url().optional(),
});

const BulkUpdateMetaSchema = z.object({
  items: z
    .array(
      z.object({
        post_id: z.number().int().positive(),
        meta: z.record(z.union([z.string(), z.boolean()])),
      })
    )
    .min(1),
});

const ListRedirectsSchema = z.object({
  page: z.number().int().positive().default(1),
  per_page: z.number().int().min(1).max(100).default(25),
  search: z.string().optional(),
});

const AddRedirectSchema = z.object({
  source_url: z.string().describe("Source path, e.g. /old-page/"),
  target_url: z.string().describe("Destination URL (absolute or relative)"),
  type: z.union([z.literal(301), z.literal(302)]).default(301),
});

const UpdateRedirectSchema = z.object({
  id: z.number().int().positive(),
  source_url: z.string().optional(),
  target_url: z.string().optional(),
  type: z.union([z.literal(301), z.literal(302)]).optional(),
});

const DeleteRedirectSchema = z.object({
  id: z.number().int().positive(),
});

const SearchPostsSchema = z.object({
  search: z.string().min(1),
  per_page: z.number().int().min(1).max(50).default(10),
  post_type: z.string().default("post"),
});

const IndexNowSchema = z.object({
  url: z.string().url().optional().describe("URL to ping. Omit to ping the home page."),
});

const ImportActionSchema = z.object({
  action_type: z
    .enum(["start", "batch", "redirections"])
    .default("start")
    .describe(
      "Step in the batched migration. Call `start` first to seed counters, then `batch` repeatedly until done is true, then `redirections` once."
    ),
});

const List404Schema = z.object({
  per_page: z.number().int().min(1).max(500).default(100),
  min_hits: z.number().int().min(1).default(1),
});

const Convert404Schema = z.object({
  id: z.number().int().positive(),
  target: z.string().describe("Destination URL (absolute or relative)."),
  type: z.union([z.literal(301), z.literal(302)]).default(301),
});

const Delete404Schema = z.object({
  id: z.number().int().positive(),
});

const AuditGetSchema = z.object({
  force: z
    .boolean()
    .optional()
    .describe("Set to true to bypass the 6-hour cache and re-run."),
});

// Multi-location: a flat object with optional fields that mirror the schema
// builder. `name` is the only required field.
const LocationSchema = z
  .object({
    name: z.string().min(1),
    type: z.string().optional(),
    address: z.string().optional(),
    city: z.string().optional(),
    postal: z.string().optional(),
    country: z.string().optional(),
    phone: z.string().optional(),
    email: z.string().optional(),
    url: z.string().optional(),
    logo: z.string().optional(),
    geo_lat: z.string().optional(),
    geo_lng: z.string().optional(),
    price_range: z.string().optional(),
    star_rating: z.string().optional(),
    number_of_rooms: z.string().optional(),
    pets_allowed: z.union([z.string(), z.boolean()]).optional(),
    checkin_time: z.string().optional(),
    checkout_time: z.string().optional(),
    rating_value: z.string().optional(),
    review_count: z.string().optional(),
    amenities: z.array(z.any()).optional(),
    same_as: z.array(z.string()).optional(),
    services: z.array(z.any()).optional(),
    opening_hours: z.array(z.any()).optional(),
  })
  .passthrough();

const LocationIndexSchema = z.object({
  index: z.number().int().min(0),
});

const UpdateLocationSchema = z
  .object({
    index: z.number().int().min(0),
  })
  .merge(LocationSchema.partial());

const ReplaceLocationsSchema = z.object({
  locations: z.array(LocationSchema),
});

const ListImagesMissingAltSchema = z.object({
  page: z.number().int().positive().default(1),
  per_page: z.number().int().min(1).max(200).default(50),
  search: z.string().optional(),
});

const SetImageAltSchema = z.object({
  id: z.number().int().positive(),
  alt: z.string(),
});

const AiSuggestSchema = z.object({
  post_id: z.number().int().positive(),
  type: z
    .enum(["title", "description", "keyword", "rewrite"])
    .describe("What to generate: SEO title (≤60 chars), meta description (≤160 chars), focus keyword (1–4 words), or rewrite arbitrary text."),
  extra: z
    .string()
    .optional()
    .describe("For type='rewrite': the source text to rewrite. Empty falls back to the post content excerpt."),
  save: z
    .boolean()
    .default(false)
    .describe("For title/description/keyword: persist into the matching _livada_seo_* post meta. Ignored for rewrite."),
});

const AiAltSchema = z.object({
  id: z.number().int().positive(),
  context: z
    .string()
    .optional()
    .describe(
      "Free-form sentence describing the page or location to bias the model (e.g. 'Camping La Ferme de Castellane, summer 2026 promo page')."
    ),
  save: z
    .boolean()
    .default(false)
    .describe("Set to true to also persist the generated alt as the attachment's _wp_attachment_image_alt meta."),
});

// ── Tool definitions ────────────────────────────────────────────────────────

type ToolDef = {
  name: string;
  description: string;
  inputSchema: z.ZodTypeAny;
  handler: (args: unknown) => Promise<unknown>;
};

const tools: ToolDef[] = [
  // ── Settings ────────────────────────────────────────────────────────────
  {
    name: "livada_get_settings",
    description:
      "Read the full Livada SEO settings array (titles, schema, social, sitemap, robots, indexnow, lodging fields, etc.).",
    inputSchema: z.object({}),
    handler: async () => wpFetch("/settings"),
  },
  {
    name: "livada_update_settings",
    description:
      "Partially update Livada SEO settings. Pass any subset of keys; missing keys keep their current value. Useful for setting business name, address, opening hours, amenities, star rating, etc.",
    inputSchema: UpdateSettingsSchema,
    handler: async (args) => {
      const { settings } = UpdateSettingsSchema.parse(args);
      return wpFetch("/settings", {
        method: "PUT",
        body: JSON.stringify(settings),
      });
    },
  },

  // ── Per-post SEO meta ───────────────────────────────────────────────────
  {
    name: "livada_get_post_seo",
    description:
      "Fetch the SEO meta for a single post (title, description, focus_keyword, noindex, canonical, og_image, score).",
    inputSchema: PostIdSchema,
    handler: async (args) => {
      const { post_id } = PostIdSchema.parse(args);
      return wpFetch(`/meta/${post_id}`);
    },
  },
  {
    name: "livada_update_post_seo",
    description:
      "Update the SEO meta for a single post. Only the fields provided are written; others are preserved.",
    inputSchema: UpdatePostMetaSchema,
    handler: async (args) => {
      const parsed = UpdatePostMetaSchema.parse(args);
      const { post_id, ...meta } = parsed;
      return wpFetch(`/meta/${post_id}`, {
        method: "PUT",
        body: JSON.stringify(meta),
      });
    },
  },
  {
    name: "livada_bulk_update_post_seo",
    description:
      "Update SEO meta for many posts in a single call. Each item is { post_id, meta: { ...fields } }.",
    inputSchema: BulkUpdateMetaSchema,
    handler: async (args) => {
      const { items } = BulkUpdateMetaSchema.parse(args);
      return wpFetch("/meta/bulk", {
        method: "PUT",
        body: JSON.stringify({ items }),
      });
    },
  },

  // ── Analysis ────────────────────────────────────────────────────────────
  {
    name: "livada_analyze_post",
    description:
      "Re-run the on-save SEO analysis for a post. Returns the resulting score and per-check details.",
    inputSchema: PostIdSchema,
    handler: async (args) => {
      const { post_id } = PostIdSchema.parse(args);
      return wpFetch(`/analysis/${post_id}`, { method: "POST" });
    },
  },

  // ── Redirects ───────────────────────────────────────────────────────────
  {
    name: "livada_list_redirects",
    description: "List redirects with pagination and optional search.",
    inputSchema: ListRedirectsSchema,
    handler: async (args) => {
      const { page, per_page, search } = ListRedirectsSchema.parse(args);
      const qs = new URLSearchParams({
        page: String(page),
        per_page: String(per_page),
      });
      if (search) qs.set("search", search);
      return wpFetch(`/redirects?${qs.toString()}`);
    },
  },
  {
    name: "livada_add_redirect",
    description:
      "Create a 301 or 302 redirect. The source is a path (/old-page/), the target can be a relative path or a full URL.",
    inputSchema: AddRedirectSchema,
    handler: async (args) => {
      const body = AddRedirectSchema.parse(args);
      return wpFetch("/redirects", {
        method: "POST",
        body: JSON.stringify(body),
      });
    },
  },
  {
    name: "livada_update_redirect",
    description: "Update an existing redirect by ID.",
    inputSchema: UpdateRedirectSchema,
    handler: async (args) => {
      const { id, ...patch } = UpdateRedirectSchema.parse(args);
      return wpFetch(`/redirects/${id}`, {
        method: "PUT",
        body: JSON.stringify(patch),
      });
    },
  },
  {
    name: "livada_delete_redirect",
    description: "Delete a redirect by ID.",
    inputSchema: DeleteRedirectSchema,
    handler: async (args) => {
      const { id } = DeleteRedirectSchema.parse(args);
      return wpFetch(`/redirects/${id}`, { method: "DELETE" });
    },
  },

  // ── Dashboard / maintenance ─────────────────────────────────────────────
  {
    name: "livada_get_dashboard",
    description:
      "Pull aggregate stats: average SEO score, count of posts missing description, noindex count, redirect count.",
    inputSchema: z.object({}),
    handler: async () => wpFetch("/dashboard"),
  },
  {
    name: "livada_flush_sitemap",
    description:
      "Delete all sitemap transients so the next request regenerates them. Useful after bulk-editing many posts.",
    inputSchema: z.object({}),
    handler: async () => wpFetch("/sitemap/flush", { method: "POST" }),
  },
  {
    name: "livada_indexnow_ping",
    description:
      "Trigger an IndexNow ping for a specific URL or the home page. Use after publishing or updating important content to push it to Bing/Yandex.",
    inputSchema: IndexNowSchema,
    handler: async (args) => {
      const { url } = IndexNowSchema.parse(args);
      return wpFetch("/indexnow/ping", {
        method: "POST",
        body: JSON.stringify(url ? { url } : {}),
      });
    },
  },

  // ── WP REST helpers (search posts to find IDs by title) ─────────────────
  {
    name: "livada_search_posts",
    description:
      "Search WordPress posts/pages by title or content to find the right post_id before editing SEO meta. Wraps /wp/v2/<post_type>?search=...",
    inputSchema: SearchPostsSchema,
    handler: async (args) => {
      const { search, per_page, post_type } = SearchPostsSchema.parse(args);
      const qs = new URLSearchParams({
        search,
        per_page: String(per_page),
        _fields: "id,title,link,type,status",
      });
      return wpFetch(`/${encodeURIComponent(post_type)}?${qs.toString()}`, {}, WP_REST_BASE);
    },
  },

  // ── Migration tools (Yoast → Livada) ────────────────────────────────────
  {
    name: "livada_import_seopress",
    description:
      "Drive the batched SEOPress import. Call with action_type='start' first, then loop on 'batch' until the response has done=true, then call 'redirections' once.",
    inputSchema: ImportActionSchema,
    handler: async (args) => {
      const body = ImportActionSchema.parse(args);
      return wpFetch("/import/seopress", {
        method: "POST",
        body: JSON.stringify(body),
      });
    },
  },
  {
    name: "livada_import_yoast",
    description:
      "Drive the batched Yoast SEO import. Same protocol as import_seopress: 'start', then 'batch' loop until done, then 'redirections' (which also pulls Yoast Premium redirects when available).",
    inputSchema: ImportActionSchema,
    handler: async (args) => {
      const body = ImportActionSchema.parse(args);
      return wpFetch("/import/yoast", {
        method: "POST",
        body: JSON.stringify(body),
      });
    },
  },

  // ── Sitemap diagnose ────────────────────────────────────────────────────
  {
    name: "livada_diagnose_sitemap",
    description:
      "Return a structured snapshot of the sitemap configuration: configured post types, deny-list of template CPTs, list actually emitted, every public post type with its status (in_sitemap / blocked_by_deny_list / template_excluded / not_in_sitemap), and a heuristic list of suspicious slugs (jkit-*, *-template, *-library, etc.).",
    inputSchema: z.object({}),
    handler: async () => wpFetch("/sitemap/diagnose"),
  },

  // ── Multi-location ──────────────────────────────────────────────────────
  {
    name: "livada_list_locations",
    description:
      "List every business location configured for the site. Each location is emitted as its own LocalBusiness JSON-LD node.",
    inputSchema: z.object({}),
    handler: async () => wpFetch("/locations"),
  },
  {
    name: "livada_add_location",
    description:
      "Append a new business location. `name` is required; everything else is optional. Use schema_type values like Campground, LodgingBusiness, Hotel, BedAndBreakfast, Resort, Hostel, Motel, Restaurant.",
    inputSchema: LocationSchema,
    handler: async (args) => {
      const body = LocationSchema.parse(args);
      return wpFetch("/locations", {
        method: "POST",
        body: JSON.stringify(body),
      });
    },
  },
  {
    name: "livada_update_location",
    description:
      "Partially update an existing business location by index. Only the keys you provide are overwritten.",
    inputSchema: UpdateLocationSchema,
    handler: async (args) => {
      const { index, ...patch } = UpdateLocationSchema.parse(args);
      return wpFetch(`/locations/${index}`, {
        method: "PUT",
        body: JSON.stringify(patch),
      });
    },
  },
  {
    name: "livada_delete_location",
    description:
      "Delete a business location by index. Subsequent locations shift down — `_livada_seo_location_id` post meta still pointing at higher indexes will need re-mapping.",
    inputSchema: LocationIndexSchema,
    handler: async (args) => {
      const { index } = LocationIndexSchema.parse(args);
      return wpFetch(`/locations/${index}`, { method: "DELETE" });
    },
  },
  {
    name: "livada_replace_locations",
    description:
      "Replace the full list of business locations in one shot. Useful when bulk-importing from a structured source.",
    inputSchema: ReplaceLocationsSchema,
    handler: async (args) => {
      const body = ReplaceLocationsSchema.parse(args);
      return wpFetch("/locations", {
        method: "PUT",
        body: JSON.stringify(body),
      });
    },
  },

  // ── WooCommerce ─────────────────────────────────────────────────────────
  {
    name: "livada_preview_product_schema",
    description:
      "Preview the JSON-LD Product node Livada SEO would emit for a WooCommerce product, without rendering the frontend. Useful to validate Schema markup before publishing.",
    inputSchema: z.object({
      product_id: z.number().int().positive(),
    }),
    handler: async (args) => {
      const { product_id } = z
        .object({ product_id: z.number().int().positive() })
        .parse(args);
      return wpFetch(`/woo/product/${product_id}/schema`);
    },
  },

  // ── AI suggestions ──────────────────────────────────────────────────────
  {
    name: "livada_ai_suggest",
    description:
      "Ask Claude for an SEO title, meta description, or focus keyword suggestion for an existing post. Pass save:true to also persist it. Requires the Anthropic API key under General settings.",
    inputSchema: AiSuggestSchema,
    handler: async (args) => {
      const { post_id, type, extra, save } = AiSuggestSchema.parse(args);
      return wpFetch(`/ai/suggest/${post_id}`, {
        method: "POST",
        body: JSON.stringify({ type, extra: extra ?? "", save }),
      });
    },
  },

  // ── Image SEO ───────────────────────────────────────────────────────────
  {
    name: "livada_list_images_missing_alt",
    description:
      "List image attachments that are missing alt-text, with thumbnail URLs and edit links. Useful for triaging accessibility/SEO debt.",
    inputSchema: ListImagesMissingAltSchema,
    handler: async (args) => {
      const { page, per_page, search } = ListImagesMissingAltSchema.parse(args);
      const qs = new URLSearchParams({
        page: String(page),
        per_page: String(per_page),
      });
      if (search) qs.set("search", search);
      return wpFetch(`/images/missing-alt?${qs.toString()}`);
    },
  },
  {
    name: "livada_set_image_alt",
    description:
      "Update the alt-text of a single attachment. Pass an empty string to clear it.",
    inputSchema: SetImageAltSchema,
    handler: async (args) => {
      const { id, alt } = SetImageAltSchema.parse(args);
      return wpFetch(`/images/${id}/alt`, {
        method: "PUT",
        body: JSON.stringify({ alt }),
      });
    },
  },
  {
    name: "livada_ai_generate_alt",
    description:
      "Generate an alt-text suggestion for an attachment using a vision-capable LLM (Claude by default). Pass save:true to also persist the result. Requires the Anthropic API key to be configured under General settings.",
    inputSchema: AiAltSchema,
    handler: async (args) => {
      const { id, context, save } = AiAltSchema.parse(args);
      return wpFetch(`/images/${id}/ai-alt`, {
        method: "POST",
        body: JSON.stringify({ context: context ?? "", save }),
      });
    },
  },

  // ── Site Audit ──────────────────────────────────────────────────────────
  {
    name: "livada_get_audit",
    description:
      "Read the cached Site Audit report (summary, site-wide checks, per-post results). Auto-runs if the cache is empty. Pass force:true to ignore the 6h cache and re-run.",
    inputSchema: AuditGetSchema,
    handler: async (args) => {
      const { force } = AuditGetSchema.parse(args);
      const qs = force ? "?force=1" : "";
      return wpFetch(`/audit${qs}`);
    },
  },
  {
    name: "livada_run_audit",
    description:
      "Force a fresh Site Audit run, refresh the cache, return the new payload. Slower than livada_get_audit but always current.",
    inputSchema: z.object({}),
    handler: async () => wpFetch("/audit", { method: "POST" }),
  },

  // ── 404 monitor ─────────────────────────────────────────────────────────
  {
    name: "livada_list_404s",
    description:
      "List logged 404 hits ordered by hit count. Useful for triaging broken URLs after a migration and turning the top offenders into 301 redirects.",
    inputSchema: List404Schema,
    handler: async (args) => {
      const { per_page, min_hits } = List404Schema.parse(args);
      const qs = new URLSearchParams({
        per_page: String(per_page),
        min_hits: String(min_hits),
      });
      return wpFetch(`/404?${qs.toString()}`);
    },
  },
  {
    name: "livada_404_to_redirect",
    description:
      "Convert a 404 row into a 301/302 redirect to a target URL and remove the row from the 404 log in one operation.",
    inputSchema: Convert404Schema,
    handler: async (args) => {
      const { id, target, type } = Convert404Schema.parse(args);
      return wpFetch(`/404/${id}/redirect`, {
        method: "POST",
        body: JSON.stringify({ target, type }),
      });
    },
  },
  {
    name: "livada_delete_404",
    description: "Delete a 404 row without creating a redirect.",
    inputSchema: Delete404Schema,
    handler: async (args) => {
      const { id } = Delete404Schema.parse(args);
      return wpFetch(`/404/${id}`, { method: "DELETE" });
    },
  },
];

// ── Server bootstrap ────────────────────────────────────────────────────────

const server = new Server(
  {
    name: "livada-seo-mcp",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: tools.map((t) => ({
    name: t.name,
    description: t.description,
    inputSchema: zodToJsonSchema(t.inputSchema),
  })),
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const tool = tools.find((t) => t.name === request.params.name);
  if (!tool) {
    return {
      isError: true,
      content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
    };
  }

  try {
    const result = await tool.handler(request.params.arguments ?? {});
    return {
      content: [
        { type: "text", text: JSON.stringify(result, null, 2) },
      ],
    };
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err);
    return {
      isError: true,
      content: [{ type: "text", text: msg }],
    };
  }
});

// ── Minimal Zod → JSON Schema converter ─────────────────────────────────────
// We keep this in-tree to avoid pulling zod-to-json-schema for a few small
// schemas. Covers the subset of types used in our tool definitions.

function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
  if (schema instanceof z.ZodObject) {
    const shape = schema.shape as Record<string, z.ZodTypeAny>;
    const properties: Record<string, unknown> = {};
    const required: string[] = [];
    for (const [key, child] of Object.entries(shape)) {
      properties[key] = zodToJsonSchema(child);
      if (!child.isOptional()) {
        required.push(key);
      }
    }
    const out: Record<string, unknown> = {
      type: "object",
      properties,
      additionalProperties: false,
    };
    if (required.length) out.required = required;
    return out;
  }

  if (schema instanceof z.ZodString) {
    const out: Record<string, unknown> = { type: "string" };
    const desc = (schema as unknown as { _def: { description?: string } })._def
      .description;
    if (desc) out.description = desc;
    return out;
  }

  if (schema instanceof z.ZodNumber) {
    return { type: "number" };
  }

  if (schema instanceof z.ZodBoolean) {
    return { type: "boolean" };
  }

  if (schema instanceof z.ZodArray) {
    return { type: "array", items: zodToJsonSchema(schema.element) };
  }

  if (schema instanceof z.ZodOptional) {
    return zodToJsonSchema(schema.unwrap());
  }

  if (schema instanceof z.ZodDefault) {
    return zodToJsonSchema(schema.removeDefault());
  }

  if (schema instanceof z.ZodLiteral) {
    return { const: schema.value };
  }

  if (schema instanceof z.ZodUnion) {
    return {
      oneOf: schema.options.map((o: z.ZodTypeAny) => zodToJsonSchema(o)),
    };
  }

  if (schema instanceof z.ZodRecord) {
    return { type: "object", additionalProperties: true };
  }

  // Fallback — accept anything.
  return {};
}

// ── Run ─────────────────────────────────────────────────────────────────────

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error(
    `[livada-seo-mcp] connected — REST base ${REST_BASE}, ${tools.length} tools`
  );
}

main().catch((err) => {
  console.error("[livada-seo-mcp] fatal:", err);
  process.exit(1);
});
