UNPKG

@bitpeakdev/suporta-mcp-server

Version:

MCP server for Suporta Helpdesk — manage tickets from Claude Code

2,052 lines • 60.8 kB
#!/usr/bin/env node

import { readFileSync } from "node:fs";

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

import {
  mergeBranch,
  mergePullRequest,
  pullRequestUrlRefusal,
  removeBranch,
  removePullRequest,
  resolveRepository,
} from "./lib/ticket-links.mjs";

/**
 * Read from package.json rather than hardcoded, so the version a client sees
 * in `serverInfo` is always the version it is actually running. It had drifted
 * to a literal "3.0.0" against a 1.2.0 package, which is exactly the kind of
 * thing that misleads whoever is trying to work out which build is live.
 *
 * Resolved against this module's own location, not the cwd, because npx runs
 * the bin from wherever the caller happens to be. npm always includes
 * package.json in the tarball, so this is present in a published install.
 */
const PACKAGE_VERSION = (() => {
  try {
    return JSON.parse(
      readFileSync(new URL("./package.json", import.meta.url), "utf8")
    ).version;
  } catch {
    return "0.0.0-unknown";
  }
})();

const SUPORTA_URL = process.env.SUPORTA_URL;
const SUPORTA_TOKEN = process.env.SUPORTA_TOKEN;

function getBaseUrl() {
  if (!SUPORTA_URL || !SUPORTA_TOKEN) {
    return null;
  }
  return `${SUPORTA_URL.replace(/\/+$/, "")}/api/v1`;
}

/**
 * What went wrong, in terms the reader can act on.
 *
 * A bare `API 401: {"message":"Unauthenticated."}` sends people to regenerate
 * a token that is usually fine — the two ordinary causes are a token from a
 * DIFFERENT workspace (tokens live in their tenant's own database, so one from
 * another Suporta is simply unknown here) and a token that was deleted in
 * Settings. Both are worth naming before anyone reaches for a new one.
 */
async function describeFailure(response) {
  const body = await response.text().catch(() => "");

  if (response.status === 401) {
    return [
      "API 401: the workspace rejected this token.",
      `SUPORTA_URL is ${SUPORTA_URL} — check the token was generated in THAT workspace,`,
      "under Settings → API Tokens, and that it has not been deleted there.",
      body ? `Server said: ${body}` : "",
    ]
      .filter(Boolean)
      .join(" ");
  }

  if (response.status === 403) {
    return `API 403: this token may not do that. Its project scoping is set where it was generated (Settings → API Tokens). ${body}`;
  }

  if (response.status === 422) {
    /*
     * A 422 here is a refusal with a reason attached — a lane that requires a
     * branch or a pull request, or a repository the project has not mapped.
     * The reason is in the JSON body, so a caller shown only "API 422:" is
     * left guessing at the one failure the server was explicit about.
     */
    try {
      const parsed = JSON.parse(body);
      const message = parsed.message ?? parsed.error ?? "";
      const errors = Object.values(parsed.errors ?? {}).flat().join(" ");
      const detail = [message, errors].filter(Boolean).join(" ");

      if (detail) {
        return `API 422: ${detail}${parsed.error_code ? ` (${parsed.error_code})` : ""}`;
      }
    } catch {
      // Not JSON — fall through to the raw body below.
    }
  }

  return `API ${response.status}: ${body}`;
}

async function apiRequest(method, path, params = {}) {
  const baseUrl = getBaseUrl();
  if (!baseUrl) {
    return {
      error:
        "Not connected. Set SUPORTA_URL and SUPORTA_TOKEN in your .mcp.json env block. Get your token from Suporta Settings → API Tokens.",
    };
  }

  const url = new URL(`${baseUrl}/${path}`);

  const options = {
    method: method.toUpperCase(),
    headers: {
      Authorization: `Bearer ${SUPORTA_TOKEN}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
  };

  if (method === "GET" && Object.keys(params).length > 0) {
    Object.entries(params).forEach(([key, value]) => {
      if (value !== undefined && value !== null && value !== "") {
        url.searchParams.set(key, String(value));
      }
    });
  } else if (method !== "GET") {
    options.body = JSON.stringify(params);
  }

  const response = await fetch(url.toString(), options);

  if (!response.ok) {
    return { error: await describeFailure(response) };
  }

  const json = await response.json();
  return json.data ?? json;
}

// A ticket has no priority column — priority is expressed as a tag, and every
// ticket payload returns `tags` instead. These are the seeded priority tags,
// matched by name in either language and falling back to their seeded colours
// for a tag that has since been renamed. Kept in step with the mobile app's
// TicketPriorityRule so both report the same level for the same ticket.
const PRIORITY_TAG_NAMES = {
  high: ["high priority", "hoge prioriteit"],
  medium: ["medium priority", "gemiddelde prioriteit"],
  low: ["low priority", "lage prioriteit"],
};

const PRIORITY_TAG_COLORS = {
  high: "dc2626",
  medium: "f59e0b",
  low: "10b981",
};

const PRIORITY_RANK = { high: 3, medium: 2, low: 1 };

/** The priority level one tag row represents, or null for an ordinary tag. */
function priorityFromTag(tag) {
  const en = String(tag?.name_en ?? "")
    .trim()
    .toLowerCase();
  const nl = String(tag?.name_nl ?? "")
    .trim()
    .toLowerCase();

  for (const [level, names] of Object.entries(PRIORITY_TAG_NAMES)) {
    if (names.includes(en) || names.includes(nl)) {
      return level;
    }
  }

  const color = String(tag?.color ?? "")
    .trim()
    .toLowerCase()
    .replace(/^#/, "");
  for (const [level, hex] of Object.entries(PRIORITY_TAG_COLORS)) {
    if (hex === color) {
      return level;
    }
  }

  return null;
}

/** The highest priority across a ticket's tags; null when none is set. */
function priorityFromTags(tags) {
  let best = null;
  for (const tag of tags ?? []) {
    const level = priorityFromTag(tag);
    if (level && (!best || PRIORITY_RANK[level] > PRIORITY_RANK[best])) {
      best = level;
    }
  }
  return best;
}

/** Tag names that are not priority markers — the ticket's real labels. */
function labelTags(tags) {
  return (tags ?? [])
    .filter((tag) => priorityFromTag(tag) === null)
    .map((tag) => tag.name_en ?? tag.name_nl)
    .filter(Boolean);
}

/**
 * Auto-translated titles and descriptions come back wrapped in a literal pair
 * of double quotes, because the translation prompt quotes the source text and
 * the model echoes that quoting. The server-side fix strips it at the source,
 * but values translated before that fix are already cached, so strip here too
 * rather than hand the caller a quoted title.
 */
function cleanText(value) {
  if (typeof value !== "string") {
    return value ?? null;
  }
  const trimmed = value.trim();
  if (
    trimmed.length >= 2 &&
    trimmed.startsWith('"') &&
    trimmed.endsWith('"') &&
    // Only unwrap when the quotes are the outermost pair, so a title that
    // legitimately contains quotes ("A" vs "B") is left alone.
    !trimmed.slice(1, -1).includes('"')
  ) {
    return trimmed.slice(1, -1);
  }
  return value;
}

/**
 * Fetches a ticket by id or slug.
 *
 * The API resolves `tickets/{id}` on the slug column, so a bare numeric id or
 * a truncated slug 404s — and callers only ever have the id, because ticket
 * URLs are /ticket/:id. The leading id uniquely identifies the ticket, so an
 * exact-slug miss is retried by looking the id up and using its real slug.
 */
async function resolveTicket(idOrSlug) {
  const raw = `${idOrSlug}`.trim();
  const direct = await apiRequest("GET", `tickets/${raw}`);

  if (!direct.error) {
    return direct;
  }

  const id = raw.match(/^(\d+)/)?.[1];
  if (!id) {
    return direct;
  }

  // The id is indexed by search; take the row whose id matches exactly.
  const found = await apiRequest("GET", "tickets", {
    search: id,
    paginate: 1,
    per_page: 50,
  });

  if (found.error) {
    return direct;
  }

  const rows = Array.isArray(found) ? found : found.data ?? [];
  const hit = rows.find((t) => `${t.id}` === id);

  if (!hit?.slug || hit.slug === raw) {
    return {
      error: `No ticket ${raw}. Searched by id ${id} and found no exact match.`,
    };
  }

  return apiRequest("GET", `tickets/${hit.slug}`);
}

/** A project's board columns as [{id, name, order}]; {error} on failure. */
async function fetchProjectStatuses(project) {
  const slug = await resolveProjectSlug(project);
  const data = await apiRequest("GET", `projects/${slug}/statuses`);

  if (data.error) {
    return { error: data.error };
  }

  const rows = Array.isArray(data) ? data : data.data ?? [];
  return rows
    .map((s) => ({
      id: s.id ?? null,
      name: s.name ?? null,
      order: s.sort_order ?? null,
    }))
    .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}

/**
 * The tickets endpoint's `project_id` filter matches on the project SLUG — a
 * bare numeric id silently returns zero rows — so anything numeric is mapped
 * to its slug first.
 */
async function resolveProjectSlug(project) {
  const raw = `${project}`.trim();
  if (!/^\d+$/.test(raw)) {
    return raw;
  }

  const data = await apiRequest("GET", "projects");
  const rows = Array.isArray(data) ? data : data.data ?? [];
  const hit = rows.find((p) => `${p.id}` === raw);
  return hit?.slug ?? raw;
}

/**
 * Resolves a status given as a name OR an id against one project's own
 * columns. Returns {id, name}, or {error} naming every valid status — the old
 * 422 said only "The selected status id is invalid".
 */
async function resolveStatus(project, status) {
  const statuses = await fetchProjectStatuses(project);
  if (statuses.error) {
    return { error: statuses.error };
  }

  const raw = `${status}`.trim();
  const wanted = raw.toLowerCase();
  const hit = statuses.find(
    (s) => `${s.id}` === raw || (s.name ?? "").trim().toLowerCase() === wanted
  );

  if (hit) {
    return { id: hit.id, name: hit.name };
  }

  const valid = statuses.map((s) => `${s.name} (id ${s.id})`).join(", ");
  return {
    error:
      `No status "${status}" on project ${project}. ` +
      `Valid statuses: ${valid || "none found"}`,
  };
}

// Create server
const server = new McpServer({
  name: "Suporta",
  version: PACKAGE_VERSION,
});

// who-am-i
server.tool(
  "who-am-i",
  "Check who you are authenticated as — name, role, and tenant.",
  {},
  async () => {
    const data = await apiRequest("GET", "auth/refresh");

    if (data.error) {
      return { content: [{ type: "text", text: data.error }] };
    }

    const user = data.user ?? data;
    const name = `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim();

    const result = {
      user_id: user.id ?? null,
      name: name || null,
      email: user.email ?? null,
      roles: data.role ?? [],
      tenant: getBaseUrl(),
    };

    return {
      content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
    };
  }
);

// get-ticket
server.tool(
  "get-ticket",
  'Fetch full ticket details, including replies, attachments, branches and pull requests. Accepts the numeric id alone ("1445"), the full slug, or an id-prefixed partial slug.',
  {
    slug: z
      .string()
      .describe(
        'Ticket id or slug. "1445", "1445-partial-title" and the full slug all work — the leading id is what identifies it.'
      ),
  },
  async ({ slug }) => {
    const data = await resolveTicket(slug);

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    // Replies are not part of the ticket payload — they have their own
    // endpoint, filtered by the numeric ticket id rather than the slug.
    // A failure here must not lose the ticket, so it is reported alongside it.
    let replies = [];
    let repliesError = null;

    if (data.id != null) {
      const replyData = await apiRequest("GET", "ticket-replies", {
        ticket_id: data.id,
      });

      if (replyData.error) {
        repliesError = replyData.error;
      } else {
        const rows = Array.isArray(replyData)
          ? replyData
          : replyData.data ?? [];
        replies = rows.map((r) => ({
          id: r.id ?? null,
          sender: r.sender || null,
          role: r.role || null,
          content: cleanText(r.content),
          is_internal: r.is_internal ?? false,
          created_at: r.created_at ?? null,
          edited_at: r.edited_at ?? null,
        }));
      }
    }

    const ticket = {
      id: data.id ?? null,
      formatted_id: data.formatted_id ?? null,
      slug: data.slug ?? null,
      title: cleanText(data.title),
      description: cleanText(data.description),
      status: data.status?.name ?? data.status ?? null,
      client_status: data.client_status ?? null,
      // Derived: the API has no priority field, only priority tags.
      priority: priorityFromTags(data.tags),
      tags: labelTags(data.tags),
      due_date: data.due_date ?? null,
      story_points: data.story_points ?? null,
      is_internal: data.is_internal ?? null,
      // The detail payload carries the project's id and slug but not its
      // name — use list-projects to resolve a name from the slug.
      project_slug: data.project_slug ?? null,
      project_id: data.project_id ?? null,
      sprint_id: data.sprint_id ?? null,
      sprint_status: data.sprint_status ?? null,
      assignees: (data.assignees ?? []).map((a) => a.name).filter(Boolean),
      reporter: data.reporter?.name ?? null,
      // Staff-only: the API omits both for a client token.
      repository_branches: (data.repository_branches ?? []).map((b) => ({
        workspace: b.workspace_slug ?? null,
        repository: b.repository_slug ?? null,
        branch: b.branch_name ?? null,
      })),
      pull_requests: (data.pull_requests ?? []).map((p) => ({
        url: p.url ?? null,
        description: p.description ?? null,
      })),
      // Only present once someone has actually marked the ticket.
      no_code_changes: data.no_code_changes?.reason
        ? {
            reason: data.no_code_changes.reason,
            marked_at: data.no_code_changes.marked_at ?? null,
          }
        : null,
      attachments: (data.attachments ?? []).map((a) => ({
        name: a.name ?? null,
        size: a.size ?? null,
        // Signed, time-limited URL from the storage disk. Without this a
        // ticket whose description says "see screenshot" cannot be acted on.
        url: a.url || null,
      })),
      replies,
      ...(repliesError ? { replies_error: repliesError } : {}),
    };

    return {
      content: [{ type: "text", text: JSON.stringify(ticket, null, 2) }],
    };
  }
);

// list-my-tickets
server.tool(
  "list-my-tickets",
  "List tickets assigned to the authenticated user. Optionally filter by status or due date.",
  {
    status: z
      .string()
      .optional()
      .describe('Filter by status: "open" or "closed". Leave empty for all.'),
    due_filter: z
      .string()
      .optional()
      .describe(
        'Filter by due date: "overdue", "today", or "this_week". Leave empty for all.'
      ),
  },
  async ({ status, due_filter }) => {
    const params = {
      paginate: 1,
      per_page: 50,
      is_show_completed: 0,
      is_show_archived: 0,
    };

    if (status) {
      params.client_status = status.charAt(0).toUpperCase() + status.slice(1);
    }
    if (due_filter) {
      params.due_date_filter = due_filter;
    }

    const data = await apiRequest("GET", "tickets", params);

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    const tickets = Array.isArray(data) ? data : data.data ?? [];

    if (tickets.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: "No tickets found matching the criteria.",
          },
        ],
      };
    }

    const compact = tickets.map((t) => ({
      id: t.id ?? null,
      slug: t.slug ?? null,
      title: cleanText(t.title),
      status: t.status?.name ?? t.status ?? null,
      client_status: t.client_status ?? null,
      // Derived: the API has no priority field, only priority tags.
      priority: priorityFromTags(t.tags),
      tags: labelTags(t.tags),
      due_date: t.due_date ?? null,
      project: t.project?.name ?? t.project ?? null,
      sprint: t.sprint?.name ?? t.sprint ?? null,
      assignees: (t.assignees ?? [])
        .map((a) => `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim())
        .filter(Boolean),
      story_points: t.story_points ?? null,
    }));

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            { count: compact.length, tickets: compact },
            null,
            2
          ),
        },
      ],
    };
  }
);

// search-tickets
server.tool(
  "search-tickets",
  "Search tickets by keyword. Searches title, ticket ID, and creator name.",
  {
    query: z
      .string()
      .describe(
        "Search keyword — matches ticket title, ID, or creator name."
      ),
  },
  async ({ query }) => {
    const data = await apiRequest("GET", "tickets", {
      search: query,
      paginate: 1,
      per_page: 20,
    });

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    const tickets = Array.isArray(data) ? data : data.data ?? [];

    if (tickets.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: `No tickets found matching '${query}'.`,
          },
        ],
      };
    }

    const compact = tickets.map((t) => ({
      id: t.id ?? null,
      slug: t.slug ?? null,
      title: cleanText(t.title),
      status: t.status?.name ?? t.status ?? null,
      client_status: t.client_status ?? null,
      project: t.project?.name ?? t.project ?? null,
    }));

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            { count: compact.length, tickets: compact },
            null,
            2
          ),
        },
      ],
    };
  }
);

// post-ticket-reply
server.tool(
  "post-ticket-reply",
  "Post a reply/comment on a ticket. Use is_internal=true for internal notes (not visible to clients).",
  {
    slug: z
      .string()
      .describe('The ticket slug, or its numeric id (e.g. "1445")'),
    message: z.string().describe("The reply message content"),
    is_internal: z
      .boolean()
      .default(true)
      .describe(
        "If true (default), reply is internal. Set false for client-visible replies."
      ),
  },
  async ({ slug, message, is_internal }) => {
    // Resolve to the numeric id first. Posting the slug as `ticket_id` only
    // ever worked because MySQL truncates "1445-some-title" to 1445 when
    // comparing against an integer column — so a slug whose leading digits
    // belonged to a different ticket would silently reply to the wrong one.
    const ticket = await resolveTicket(slug);

    if (ticket.error) {
      return { content: [{ type: "text", text: `Error: ${ticket.error}` }] };
    }
    if (ticket.id == null) {
      return {
        content: [
          { type: "text", text: `Error: could not resolve ticket ${slug}.` },
        ],
      };
    }

    const data = await apiRequest("POST", "ticket-replies", {
      ticket_id: ticket.id,
      message,
      is_internal,
    });

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket_id: ticket.id,
              ticket_slug: ticket.slug ?? slug,
              is_internal,
              message: "Reply posted successfully.",
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// list-projects
server.tool(
  "list-projects",
  "List all projects accessible with the current API token. Pass include_statuses to get each project's board columns in the same call.",
  {
    include_statuses: z
      .boolean()
      .optional()
      .describe(
        "Include each project's statuses as [{id, name, order}]. Costs one extra request per project, so it is off by default; list-project-statuses fetches just one project's."
      ),
  },
  async ({ include_statuses }) => {
    const data = await apiRequest("GET", "projects");

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    const projects = Array.isArray(data) ? data : data.data ?? [];

    if (projects.length === 0) {
      return {
        content: [{ type: "text", text: "No projects found." }],
      };
    }

    const compact = await Promise.all(
      projects.map(async (p) => {
        const row = {
          id: p.id ?? null,
          slug: p.slug ?? null,
          name: p.name ?? null,
          description: p.description ?? null,
          tickets_count: p.tickets_count ?? null,
        };

        // A project's board columns are what `update-ticket` needs to move a
        // ticket, and they used to be undiscoverable through the MCP at all.
        // Opt-in because it costs one request per project.
        if (include_statuses) {
          row.statuses = await fetchProjectStatuses(p.slug ?? p.id);
        }
        return row;
      })
    );

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            { count: compact.length, projects: compact },
            null,
            2
          ),
        },
      ],
    };
  }
);

// list-project-statuses
server.tool(
  "list-project-statuses",
  "List a project's board columns (statuses) with their ids, so a ticket can be moved. Use the name with update-ticket's `status`, or the id with `status_id`.",
  {
    project: z
      .string()
      .describe("Project slug or numeric id, from list-projects"),
  },
  async ({ project }) => {
    const statuses = await fetchProjectStatuses(project);

    if (statuses.error) {
      return { content: [{ type: "text", text: `Error: ${statuses.error}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            { project, count: statuses.length, statuses },
            null,
            2
          ),
        },
      ],
    };
  }
);

// list-project-tickets
server.tool(
  "list-project-tickets",
  "List tickets in a project, optionally filtered by status and assignee, REGARDLESS of who they are assigned to. Use this to read a board column; list-my-tickets only ever returns your own tickets.",
  {
    project: z
      .string()
      .describe("Project slug or numeric id, from list-projects"),
    status: z
      .string()
      .optional()
      .describe(
        'Status name (e.g. "TEST COMPLETED BY JOHN") or numeric id. Names are matched case-insensitively against this project\'s own statuses.'
      ),
    assignee_id: z
      .number()
      .optional()
      .describe("Only tickets assigned to this user id"),
    limit: z
      .number()
      .optional()
      .describe("Tickets per page, 1-200 (default 100)"),
    cursor: z
      .string()
      .optional()
      .describe("next_cursor from a previous call, to page through results"),
    include_completed: z
      .boolean()
      .optional()
      .describe(
        "Include completed/archived tickets (default true — a board column may itself be a completed status)"
      ),
  },
  async ({ project, status, assignee_id, limit, cursor, include_completed }) => {
    const params = {
      project_id: await resolveProjectSlug(project),
      paginate: 1,
      per_page: Math.min(Math.max(limit ?? 100, 1), 200),
    };

    if (cursor) params.cursor = cursor;
    if (assignee_id != null) params.assignee_id = assignee_id;
    if (include_completed === false) {
      params.is_show_completed = 0;
      params.is_show_archived = 0;
    }

    // Resolve a status NAME against this project's columns, so the caller
    // never has to know a numeric id.
    let statusLabel = null;
    if (status != null && `${status}`.trim() !== "") {
      const resolved = await resolveStatus(project, status);
      if (resolved.error) {
        return { content: [{ type: "text", text: `Error: ${resolved.error}` }] };
      }
      params["status_id[]"] = resolved.id;
      statusLabel = resolved.name;
    }

    const data = await apiRequest("GET", "tickets", params);

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    const payload = data.data !== undefined ? data : { data };
    const rows = Array.isArray(payload.data) ? payload.data : [];

    const tickets = rows.map((t) => ({
      id: t.id ?? null,
      slug: t.slug ?? null,
      title: cleanText(t.title),
      status: t.status?.name ?? t.status ?? null,
      client_status: t.client_status ?? null,
      priority: priorityFromTags(t.tags),
      due_date: t.due_date ?? null,
      story_points: t.story_points ?? null,
      assignees: (t.assignees ?? [])
        .map((a) => `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim())
        .filter(Boolean),
    }));

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              project,
              status: statusLabel,
              count: tickets.length,
              next_cursor: payload.next_cursor ?? null,
              tickets,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// update-ticket
server.tool(
  "update-ticket",
  "Update a ticket's fields — status, title, description, priority, assignees, story points, due date, etc.",
  {
    slug: z
      .string()
      .describe("The ticket slug, or its numeric id (e.g. \"1445\")"),
    status: z
      .string()
      .optional()
      .describe(
        'Status NAME to move the ticket to, e.g. "TEST COMPLETED BY JOHN". Resolved against the ticket\'s own project, so no numeric id is needed. Use list-project-statuses to see the options.'
      ),
    status_id: z
      .number()
      .optional()
      .describe(
        "Project status id, if you already know it. Prefer `status` with a name — ids differ per project. Get ids from list-project-statuses."
      ),
    client_status: z
      .string()
      .optional()
      .describe('Set to "Open" or "Closed"'),
    title: z.string().optional().describe("New ticket title"),
    description: z.string().optional().describe("New ticket description"),
    priority: z
      .string()
      .optional()
      .describe('Priority: "P1", "P2", or "P3"'),
    story_points: z
      .number()
      .optional()
      .describe("Story points: 1, 2, 3, 5, 8, 13, or 21"),
    due_date: z
      .string()
      .optional()
      .describe("Due date in YYYY-MM-DD format, or null to clear"),
    assignees: z
      .array(z.number())
      .optional()
      .describe("Array of user IDs to assign"),
    sprint_id: z
      .number()
      .optional()
      .describe("Sprint ID to move the ticket to"),
  },
  async ({ slug, status, ...fields }) => {
    // Filter out undefined fields
    const updateData = {};
    for (const [key, value] of Object.entries(fields)) {
      if (value !== undefined) {
        updateData[key] = value;
      }
    }

    // A status NAME is resolved against the ticket's own project, because
    // status ids are per-project and were previously undiscoverable.
    let statusLabel = null;
    let target = slug;
    if (status != null && `${status}`.trim() !== "") {
      // resolveTicket, not a bare GET: the caller may have passed a numeric
      // id, which the ticket endpoint does not resolve on its own.
      const ticket = await resolveTicket(slug);
      if (ticket.error) {
        return { content: [{ type: "text", text: `Error: ${ticket.error}` }] };
      }
      // PUT against the slug the API actually knows.
      target = ticket.slug ?? slug;

      const project = ticket.project_slug ?? ticket.project_id;
      if (project == null) {
        return {
          content: [
            {
              type: "text",
              text:
                `Error: ticket ${slug} has no project, so a status name cannot ` +
                `be resolved. Pass status_id instead.`,
            },
          ],
        };
      }

      const resolved = await resolveStatus(project, status);
      if (resolved.error) {
        return { content: [{ type: "text", text: `Error: ${resolved.error}` }] };
      }
      updateData.status_id = resolved.id;
      statusLabel = resolved.name;
    }

    if (Object.keys(updateData).length === 0) {
      return {
        content: [
          {
            type: "text",
            text: "Error: provide at least one field to update.",
          },
        ],
      };
    }

    const data = await apiRequest("PUT", `tickets/${target}`, updateData);

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket_slug: target,
              updated_fields: Object.keys(updateData),
              ...(statusLabel ? { status: statusLabel } : {}),
              message: "Ticket updated successfully.",
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// create-ticket
server.tool(
  "create-ticket",
  "Create a new ticket in a project.",
  {
    title: z.string().describe("Ticket title"),
    description: z
      .string()
      .optional()
      .describe("Ticket description (supports HTML)"),
    project_id: z
      .string()
      .describe("Project slug or ID to create the ticket in"),
    priority: z
      .string()
      .optional()
      .describe('Priority: "P1", "P2", or "P3"'),
    story_points: z
      .number()
      .optional()
      .describe("Story points: 1, 2, 3, 5, 8, 13, or 21"),
    assignees: z
      .array(z.number())
      .optional()
      .describe("Array of user IDs to assign"),
    sprint_id: z
      .number()
      .optional()
      .describe("Sprint ID to add the ticket to"),
    client_status: z
      .string()
      .optional()
      .describe('Status: "Open" (default) or "Closed"'),
  },
  async ({ title, description, project_id, ...fields }) => {
    const ticketData = { title, project_id };

    if (description) {
      ticketData.description = description;
    }

    for (const [key, value] of Object.entries(fields)) {
      if (value !== undefined) {
        ticketData[key] = value;
      }
    }

    const data = await apiRequest("POST", "tickets", ticketData);

    if (data.error) {
      return { content: [{ type: "text", text: `Error: ${data.error}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket: {
                id: data.id ?? null,
                slug: data.slug ?? null,
                title: data.title ?? title,
                project: data.project?.name ?? project_id,
              },
              message: "Ticket created successfully.",
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

/* ── Ticket links: pull requests and repository branches ─────────────────── */

/**
 * Both link lists are written with a PUT that takes the COMPLETE desired set —
 * omission is deletion. Every tool below therefore reads the current list,
 * merges the caller's one change into it (`lib/ticket-links.mjs`) and writes
 * the result, so "link this PR" cannot quietly unlink the others.
 *
 * The routes are `role:admin|support`. A client-scoped token gets a 403 that
 * reads like a project-scoping problem, so these say what it actually is.
 */
const STAFF_ONLY_HINT =
  "Linking branches and pull requests is staff-only (role admin or support) — a client account cannot, by design.";

function linkFailure(result) {
  const message = `${result.error ?? ""}`;

  return message.startsWith("API 403:")
    ? `${message} ${STAFF_ONLY_HINT}`
    : message;
}

/** The slug the API knows for a ticket, given a slug or a bare id. */
async function resolveTicketTarget(slug) {
  const ticket = await resolveTicket(slug);

  if (ticket.error) {
    return { error: ticket.error };
  }

  return { target: ticket.slug ?? `${slug}`.trim(), title: cleanText(ticket.title) };
}

const asText = (value) => `${value ?? ""}`.trim();

// link-pull-request
server.tool(
  "link-pull-request",
  "Attach a pull request to a ticket. Existing pull requests are kept — this adds one, or edits its description if the same URL is already linked.",
  {
    slug: z.string().describe('The ticket slug, or its numeric id (e.g. "1484")'),
    url: z
      .string()
      .describe(
        "Full pull request URL, e.g. https://bitbucket.org/workspace/repo/pull-requests/42"
      ),
    description: z
      .string()
      .optional()
      .describe("Optional note about this pull request"),
    replace_all: z
      .boolean()
      .optional()
      .describe(
        "Unlink every other pull request, leaving only this one (default false — normally you want to keep the others)"
      ),
    no_code_changes_reason: z
      .string()
      .optional()
      .describe(
        'Set the ticket\'s "no code changes needed" reason, which satisfies a lane that requires a pull request. Pass "" to clear it. Omit to leave it untouched.'
      ),
  },
  async ({ slug, url, description, replace_all, no_code_changes_reason }) => {
    const refusal = pullRequestUrlRefusal(url);
    if (refusal) {
      return { content: [{ type: "text", text: `Error: ${refusal}` }] };
    }

    const ticket = await resolveTicketTarget(slug);
    if (ticket.error) {
      return { content: [{ type: "text", text: `Error: ${ticket.error}` }] };
    }

    const current = await apiRequest(
      "GET",
      `tickets/${ticket.target}/pull-requests`
    );
    if (current.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(current)}` }] };
    }

    const merged = mergePullRequest(
      current.pull_requests,
      { url, description },
      { replaceAll: replace_all === true }
    );

    const payload = { pull_requests: merged.desired };
    // Three-way on the server: absent leaves the mark alone, "" clears it,
    // text sets it. Sending the key unconditionally would wipe a mark this
    // call never mentioned.
    if (no_code_changes_reason !== undefined) {
      payload.no_code_changes_reason = no_code_changes_reason;
    }

    const saved = await apiRequest(
      "PUT",
      `tickets/${ticket.target}/pull-requests`,
      payload
    );
    if (saved.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(saved)}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket_slug: ticket.target,
              ticket_title: ticket.title,
              action: merged.wasPresent ? "updated" : "linked",
              url: asText(url),
              ...(merged.removedCount > 0
                ? { unlinked_by_replace_all: merged.removedCount }
                : {}),
              pull_requests: (saved.pull_requests ?? []).map((p) => ({
                url: p.url,
                description: p.description || null,
                state: p.state ?? null,
              })),
              no_code_changes: saved.no_code_changes?.reason ?? null,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// unlink-pull-request
server.tool(
  "unlink-pull-request",
  "Remove a pull request from a ticket, leaving the others linked. Pass all=true to remove every one.",
  {
    slug: z.string().describe('The ticket slug, or its numeric id (e.g. "1484")'),
    url: z
      .string()
      .optional()
      .describe("The pull request URL to unlink. Required unless all=true."),
    all: z
      .boolean()
      .optional()
      .describe("Unlink every pull request on the ticket"),
  },
  async ({ slug, url, all }) => {
    if (all !== true && asText(url) === "") {
      return {
        content: [
          { type: "text", text: "Error: pass the url to unlink, or all=true." },
        ],
      };
    }

    const ticket = await resolveTicketTarget(slug);
    if (ticket.error) {
      return { content: [{ type: "text", text: `Error: ${ticket.error}` }] };
    }

    const current = await apiRequest(
      "GET",
      `tickets/${ticket.target}/pull-requests`
    );
    if (current.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(current)}` }] };
    }

    const plan = removePullRequest(current.pull_requests, {
      url,
      all: all === true,
    });

    // Nothing matched: say so rather than issuing a write that changes nothing
    // and reporting it as a removal.
    if (plan.removed.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                success: true,
                ticket_slug: ticket.target,
                action: "no_change",
                message: `No pull request matching ${asText(url)} is linked to this ticket.`,
                pull_requests: plan.desired.map((p) => p.url),
              },
              null,
              2
            ),
          },
        ],
      };
    }

    const saved = await apiRequest(
      "PUT",
      `tickets/${ticket.target}/pull-requests`,
      { pull_requests: plan.desired }
    );
    if (saved.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(saved)}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket_slug: ticket.target,
              unlinked: plan.removed,
              remaining: (saved.pull_requests ?? []).map((p) => p.url),
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// link-branch
server.tool(
  "link-branch",
  "Attach a git branch to a ticket. Branches on other repositories are kept. A repository holds one branch, so linking a second one for the same repository replaces it.",
  {
    slug: z.string().describe('The ticket slug, or its numeric id (e.g. "1484")'),
    branch_name: z
      .string()
      .describe('The branch name, e.g. "fix/icon-font-flash"'),
    repository_slug: z
      .string()
      .optional()
      .describe(
        "Repository the branch lives in. Optional when the project maps exactly one; the error lists the options otherwise."
      ),
    workspace_slug: z
      .string()
      .optional()
      .describe(
        "Bitbucket workspace, only needed when the same repository slug is mapped in more than one"
      ),
    replace_all: z
      .boolean()
      .optional()
      .describe(
        "Unlink every other repository's branch, leaving only this one (default false)"
      ),
  },
  async ({ slug, branch_name, repository_slug, workspace_slug, replace_all }) => {
    if (asText(branch_name) === "") {
      return { content: [{ type: "text", text: "Error: branch_name is required." }] };
    }

    const ticket = await resolveTicketTarget(slug);
    if (ticket.error) {
      return { content: [{ type: "text", text: `Error: ${ticket.error}` }] };
    }

    const current = await apiRequest(
      "GET",
      `tickets/${ticket.target}/repository-branches`
    );
    if (current.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(current)}` }] };
    }

    const repository = resolveRepository(
      current.repositories,
      current.branches,
      { repository_slug, workspace_slug }
    );
    if (repository.error) {
      return { content: [{ type: "text", text: `Error: ${repository.error}` }] };
    }

    const merged = mergeBranch(
      current.branches,
      { ...repository, branch_name },
      { replaceAll: replace_all === true }
    );

    const saved = await apiRequest(
      "PUT",
      `tickets/${ticket.target}/repository-branches`,
      { branches: merged.desired }
    );
    if (saved.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(saved)}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket_slug: ticket.target,
              ticket_title: ticket.title,
              repository: `${repository.workspace_slug}/${repository.repository_slug}`,
              branch: asText(branch_name),
              // A repository holds one branch, so this is how the caller finds
              // out it displaced something rather than adding alongside it.
              ...(merged.replaced ? { replaced_branch: merged.replaced } : {}),
              ...(merged.removedCount > 0
                ? { unlinked_by_replace_all: merged.removedCount }
                : {}),
              branches: (saved.branches ?? []).map(
                (b) => `${b.workspace_slug}/${b.repository_slug}: ${b.branch_name}`
              ),
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// unlink-branch
server.tool(
  "unlink-branch",
  "Remove a repository's branch from a ticket, leaving the others linked. Pass all=true to remove every one.",
  {
    slug: z.string().describe('The ticket slug, or its numeric id (e.g. "1484")'),
    repository_slug: z
      .string()
      .optional()
      .describe("Repository whose branch to unlink. Required unless all=true."),
    workspace_slug: z
      .string()
      .optional()
      .describe("Bitbucket workspace, when the repository slug is ambiguous"),
    all: z.boolean().optional().describe("Unlink every branch on the ticket"),
  },
  async ({ slug, repository_slug, workspace_slug, all }) => {
    if (all !== true && asText(repository_slug) === "") {
      return {
        content: [
          {
            type: "text",
            text: "Error: pass the repository_slug to unlink, or all=true.",
          },
        ],
      };
    }

    const ticket = await resolveTicketTarget(slug);
    if (ticket.error) {
      return { content: [{ type: "text", text: `Error: ${ticket.error}` }] };
    }

    const current = await apiRequest(
      "GET",
      `tickets/${ticket.target}/repository-branches`
    );
    if (current.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(current)}` }] };
    }

    let target = { workspace_slug, repository_slug };
    if (all !== true) {
      const repository = resolveRepository(
        current.repositories,
        current.branches,
        { repository_slug, workspace_slug }
      );
      if (repository.error) {
        return { content: [{ type: "text", text: `Error: ${repository.error}` }] };
      }
      target = repository;
    }

    const plan = removeBranch(current.branches, { ...target, all: all === true });

    if (plan.removed.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                success: true,
                ticket_slug: ticket.target,
                action: "no_change",
                message: `No branch is linked for ${asText(target.repository_slug) || "that repository"}.`,
              },
              null,
              2
            ),
          },
        ],
      };
    }

    const saved = await apiRequest(
      "PUT",
      `tickets/${ticket.target}/repository-branches`,
      { branches: plan.desired }
    );
    if (saved.error) {
      return { content: [{ type: "text", text: `Error: ${linkFailure(saved)}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              success: true,
              ticket_slug: ticket.target,
              unlinked: plan.removed.map(
                (b) => `${b.workspace_slug}/${b.repository_slug}: ${b.branch_name}`
              ),
              remaining: (saved.branches ?? []).map(
                (b) => `${b.workspace_slug}/${b.repository_slug}: ${b.branch_name}`
              ),
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// Helpers for the tools below.
const text = (value) => ({
  content: [
    {
      type: "text",
      text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
    },
  ],
});

const defined = (fields) =>
  Object.fromEntries(
    Object.entries(fields).filter(([, value]) => value !== undefined)
  );

const asList = (data) => (Array.isArray(data) ? data : data.data ?? []);

const today = () => new Date().toLocaleDateString("sv-SE"); // YYYY-MM-DD, local time

// Current = open sprint whose date range covers today; else the newest open sprint.
function pickCurrentSprint(sprints, date = today()) {
  const open = sprints.filter((s) => !s.closed_at);
  const day = (d) => (d ? String(d).slice(0, 10) : null);
  return (
    open.find(
      (s) =>
        day(s.start_date) &&
        day(s.end_date) &&
        day(s.start_date) <= date &&
        date <= day(s.end_date)
    ) ??
    [...open].sort((a, b) =>
      String(b.start_date ?? "").localeCompare(String(a.start_date ?? ""))
    )[0] ??
    null
  );
}

const compactSprint = (s) => ({
  id: s.id ?? null,
  name: s.name ?? null,
  start_date: s.start_date ?? null,
  end_date: s.end_date ?? null,
  closed_at: s.closed_at ?? null,
  description: s.description ?? null,
});

// list-sprints
server.tool(
  "list-sprints",
  "List all sprints (newest first) with their IDs and dates.",
  {
    include_closed: z
      .boolean()
      .default(false)
      .describe("Include closed sprints. Default false."),
  },
  async ({ include_closed }) => {
    const data = await apiRequest("GET", "sprints");
    if (data.error) return text(`Error: ${data.error}`);

    const sprints = asList(data)
      .filter((s) => include_closed || !s.closed_at)
      .map(compactSprint);

    return text({ count: sprints.length, sprints });
  }
);

// get-current-sprint
server.tool(
  "get-current-sprint",
  "Get the current sprint: the open sprint whose dates cover today, or else the newest open sprint. Optionally include its tickets.",
  {
    include_board: z
      .boolean()
      .default(false)
      .describe("Also fetch the sprint's tickets, grouped by sprint status."),
  },
  async ({ include_board }) => {
    const data = await apiRequest("GET", "sprints");
    if (data.error) return text(`Error: ${data.error}`);

    const sprint = pickCurrentSprint(asList(data));
    if (!sprint) return text("No open sprint found.");

    const result = { sprint: compactSprint(sprint) };

    if (include_board) {
      const board = await apiRequest("GET", `sprints/${sprint.id}/board`);
      if (board.error) return text(`Error: ${board.error}`);

      result.columns = (board.columns ?? []).map((c) => ({
        status: c.status ?? null,
        label: c.label ?? null,
        tickets: (c.tickets ?? []).map((t) => ({
          id: t.id ?? null,
          slug: t.slug ?? null,
          title: t.title ?? null,
          project: t.project?.name ?? null,
          assignees: (t.assignees ?? []).map(
            (a) => a.name ?? `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim()
          ),
          story_points: t.story_points ?? null,
        })),
      }));
    }

    return text(result);
  }
);

// create-sprint
server.tool(
  "create-sprint",
  "Create a new sprint.",
  {
    name: z.string().describe("Sprint name"),
    start_date: z.string().optional().describe("Start date, YYYY-MM-DD"),
    end_date: z.string().optional().describe("End date, YYYY-MM-DD"),
    description: z.string().optional().describe("Sprint goal / description"),
  },
  async (fields) => {
    const data = await apiRequest("POST", "sprints", defined(fields));
    if (data.error) return text(`Error: ${data.error}`);

    return text({ success: true, sprint: compactSprint(data) });
  }
);

// log-time
server.tool(
  "log-time",
  "Log time on a project (optionally on a specific ticket). Use list-projects to find the project ID.",
  {
    duration_minutes: z.number().int().min(1).describe("Minutes worked"),
    project_id: z.number().int().optional().describe("Project ID"),
    ticket_id: z
      .number()
      .int()
      .optional()
      .describe("Ticket ID (numeric id, not the slug). Optional."),
    company_id: z.number().int().optional().describe("Company ID. Optional."),
    date: z
      .string()
      .optional()
      .describe("Date worked, YYYY-MM-DD. Defaults to today."),
    start_time: z.string().optional().describe("Start time, HH:MM. Optional."),
    end_time: z.string().optional().describe("End time, HH:MM. Optional."),
    note: z.string().optional().describe("What you worked on"),
  },
  async (fields) => {
    if (!fields.project_id && !fields.ticket_id) {
      return text("Error: provide a project_id or a ticket_id.");
    }

    const payload = defined({ date: today(), ...fields });
    const data = await apiRequest("POST", "timelog", payload);
    if (data.error) return text(`Error: ${data.error}`);

    return text({ success: true, timelog_id: data.id ?? null, logged: payload });
  }
);

// list-users
server.tool(
  "list-users",
  "List users with their IDs, names, emails and roles. Use the IDs for assignees.",
  {
    search: z.string().optional().describe("Filter by name or email"),
    status: z
      .enum(["active", "inactive"])
      .optional()
      .describe("Filter by status"),
    role: z
      .string()
      .optional()
      .describe('Filter by role name, e.g. "admin", "support" or "client"'),
  },
  async (params) => {
    const data = await apiRequest("GET", "users", params);
    if (data.error) return text(`Error: ${data.error}`);

    const users = asList(data).map((u) => ({
      id: u.id ?? null,
      name:
        u.name ?? (`${u.first_name ?? ""} ${u.last_name ?? ""}`.trim() || null),
      email: u.email ?? null,
      roles: (u.roles ?? []).map((r) => r.name ?? r),
      status: u.status ?? null,
    }));

    return text({ count: users.length, users });
  }
);

// Project CRUD (list-projects is above)
const projectFields = {
  name: z.string().describe("Project name"),
  description: z.string().optional().describe("Project description"),
  company_id: z.number().int().optional().describe("Company ID"),
  is_public: z.boolean().optional().describe("Visible to all users"),
};

server.tool(
  "get-project",
  "Fetch one project by ID.",
  { id: z.number().int().describe("Project ID") },
  async ({ id }) => {
    const data = await apiRequest("GET", `projects/${id}`);
    return text(data.error ? `Error: ${data.error}` : data);
  }
);

server.tool("create-project", "Create a project.", projectFields, async (fields) => {
  const data = await apiRequest("POST", "projects", defined(fields));
  if (data.error) return text(`Error: ${data.error}`);

  return text({
    success: true,
    project: { id: data.id ?? null, slug: data.slug ?? null, name: data.name ?? fields.name },
  });
});

server.tool(
  "update-project",
  "Update a project's name, description, company or visibility.",
  {
    id: z.number().int().describe("Project ID"),
    ...projectFields,
    name: projectFields.name.optional(),
  },
  async ({ id, ...fields }) => {
    const updateData = defined(fields);
    if (Object.keys(updateData).length === 0) {
      return text("Error: provide at least one field to update.");
    }

    const data = await apiRequest("PUT", `projects/${id}`, updateData);
    if (data.error) return text(`Error: ${data.error}`);

    return text({ success: true, project_id: id, updated_fields: Object.keys(updateData) });
  }
);

server.tool(
  "delete-project",
  "Archive (soft-delete) a project. It can be restored in Suporta.",
  { id: z.number().int().describe("Project ID") },
  async ({ id }) => {
    const data = await apiRequest("DELETE", `projects/${id}`);
    return text(data.error ? `Error: ${data.error}` : { success: true, deleted_project_id: id });
  }
);

// Company CRUD
const companyFields = {
  company_name: z.string().describe("Company name"),
  kvk_number: z.string().max(8).optional().describe("KvK (Chamber of Commerce) number"),
  street_address: z.string().optional(),
  postal_code: z.string().optional(),
  city: z.string().optional(),
  country: z.string().optional(),
  legal_form: z.string().optional().describe('e.g. "B.V."'),
  sla_enabled: z.boolean().optional().describe("Enable SLA; needs sla_user_ids"),
  sla_p1_enabled: z.boolean().optional(),
  sla_p1_minutes: z.number().int().min(1).optional().describe("P1 response time in minutes"),
  sla_p2_enabled: z.boolean().optional(),
  sla_p2_minutes: z.number().int().min(1).optional().describe("P2 response time in minutes"),
  sla_p3_enabled: z.boolean().optional(),
  sla_p3_minutes: z.number().int().min(1).optional().describe("P3 response time in minutes"),
  sla_user_ids: z.array(z.number().int()).optional().describe("User IDs responsible for the SLA"),
};

const compactCompany = (c) => ({
  id: c.id ?? null,
  company_name: c.company_name ?? null,
  kvk_number: c.kvk_number ?? null,
  city: c.city ?? null,
  country: c.country ?? null,
});

server.tool(
  "list-companies",
  "List companies (clients) with their IDs.",
  { search: z.string().optional().describe("Filter by name") },
  async (params) => {
    const data = await apiRequest("GET", "companies", params);
    if (data.error) return text(`Error: ${data.error}`);

    const companies = asList(data).map(compactCompany);
    return text({ count: companies.length, companies });
  }
);

server.tool(
  "get-company",
  "Fetch one company by ID, including address and SLA settings.",
  { id: z.number().int().describe("Company ID") },
  async ({ id }) => {
    const data = await apiRequest("GET", `companies/${id}`);
    return text(data.error ? `Error: ${data.error}` : data);
  }
);

server.tool("create-company", "Create a company (client).", companyFields, async (fields) => {
  const data = await apiRequest("POST", "companies", defined(fields));
  if (data.error) return text(`Error: ${data.error}`);

  return text({ success: true, company: compactCompany(data) });
});

server.tool(
  "update-company",
  "Update a company's details or SLA settings.",
  {
    id: z.number().int().describe("Company ID"),
    ...companyFields,
    company_name: companyFields.company_name.optional(),
  },
  async ({ id, ...fields }) => {
    const updateData = defined(fields);
    if (Object.keys(updateData).length === 0) {
      return text("Error: provide at least one field to update.");
    }

    const data = await apiRequest("PUT", `companies/${id}`, updateData);
    if (data.error) return text(`Error: ${data.error}`);

    return text({ success: true, company_id: id, updated_fields: Object.keys(updateData) });
  }
);

server.tool(
  "delete-company",
  "PERMANENTLY delete a company. This cannot be undone — confirm with the user first.",
  { id: z.number().int().describe("Company ID") },
  async ({ id }) => {
    const data = await apiRequest("DELETE", `companies/${id}`);
    return text(data.error ? `Error: ${data.error}` : { success: true, deleted_company_id: id });
  }
);

// Execution mode — extra tools for headless agent sessions.
// Active only when the runner sets SUPORTA_EXECUTION_ID in the environment.
const SUPORTA_EXECUTION_ID = process.env.SUPORTA_EXECUTION_ID;

async function executionRequest(method, path, params = {}) {
  const baseUrl = getBaseUrl();
  if (!baseUrl) {
    return {
      error:
        "Not connected. SUPORTA_URL and SUPORTA_TOKEN must be set by the agent runner.",
    };
  }

  const headers = {
    Authorization: `Bearer ${SUPORTA_TOKEN}`,
    Accept: "application/json",
    "Content-Type": "application/json",
  };
  if (process.env.SUPORTA_WORKER_ID) {
    headers["X-Suporta-Worker"] = process.env.SUPORTA_WORKER_ID;
  }

  const options = { method: method.toUpperCase(), headers };
  if (method !== "GET") {
    options.body = JSON.stringify(params);
  }

  const response = await fetch(`${baseUrl}/${path}`, options);

  if (!response.ok) {
    return {
      error: `API ${response.status}: ${await response.text()}`,
      status: response.status,
    };
  }

  const json = await response.json().catch(() => ({}));
  return { data: json.data ?? json };
}

if (SUPORTA_EXECUTION_ID) {
  const executionPath = (suffix) =>
    `local-agent/executions/${SUPORTA_EXECUTION_ID}/${suffix}`;

  const LIFECYCLE_STEPS = new Set([
    "analyzing_ticket",
    "analyzing_repository",
    "planning",
    "coding",
    "running_tests",
    "fixing_issues",
    "writing_documentation",
  ]);

  // get-execution-context
  server.tool(
    "get-execution-context",
    "Fetch the full context of the current ticket execution — ticket, project, repositories, locale, plan, and feedback history.",
    {},
    async () => {
      const result = await executionRequest("GET", executionPath("context"));

      if (result.error) {
        return { content: [{ type: "text", text: `Error: ${result.error}` }] };
      }

      return {
        content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
      };
    }
  );

  // report-progress
  server.tool(
    "report-progress",
    "Report progress on the current execution. Call this when you move to a new phase of the work so the team can follow along.",
    {
      step: z
        .enum([
          "analyzing_ticket",
          "analyzing_repository",
          "planning",
          "coding",
          "running_tests",
          "fixing_issues",
          "writing_documentation",
        ])
        .describe("The phase of work you are currently in"),
      message: z.string().describe("Short human-readable progress message"),
    },
    async ({ step, message }) => {
      const progress = await executionRequest(
        "POST",
        executionPath("progress"),
        { events: [{ message, step, level: "info" }] }
      );

      if (progress.error) {
        return {
          content: [{ type: "text", text: `Error: ${progress.error}` }],
        };
      }

      if (LIFECYCLE_STEPS.has(step)) {
        const status = await executionRequest("POST", executionPath("status"), {
          status: step,
          step: message.length > 120 ? `${message.slice(0, 117)}...` : message,
        });
        // 409 = illegal transition; the progress event already landed, so stay silent.
        if (status.error && status.status !== 409) {
          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(
                  { success: true, step, status_warning: status.error },
                  null,
                  2
                ),
              },
            ],
          };
        }
      }

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              { success: true, step, message: "Progress reported." },
              null,
              2
            ),
          },
        ],
      };
    }
  );

  // request-input
  server.tool(
    "request-input",
    "Ask the ticket team a question when you are blocked and cannot proceed without human input. The execution pauses until someone answers.",
    {
      question: z
        .string()
        .describe("The question you need answered before you can continue"),
    },
    async ({ question }) => {
      const result = await executionRequest(
        "POST",
        executionPath("input-required"),
        { question }
      );

      if (result.error) {
        return { content: [{ type: "text", text: `Error: ${result.error}` }] };
      }

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                success: true,
                message:
                  "Question sent. The execution will pause; stop working and finish your turn now.",
              },
              null,
              2
            ),
          },
        ],
      };
    }
  );
}

// Start server
const transport = new StdioServerTransport();
await server.connect(transport);