@bitpeakdev/suporta-mcp-server
Version:
MCP server for Suporta Helpdesk — manage tickets from Claude Code
2,052 lines • 60.8 kB
JavaScript
#!/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);