ISSUE-23Add parentTicketID input to MCP create_ticket and update_ticket
Description
The MCP create_ticket and update_ticket tools currently don't accept a parentTicketID field. The database has had the column since apps/api/src/db/schema.ts:134 (parentTicketID: uuid("parent_ticket_id").references(...)), and the service layer supports both setting it on create (services/ticketService.ts:89, 143) and updating it with cycle detection on patch (services/ticketService.ts:485-508). The MCP route and stdio tool definitions just don't pass it through.
Concrete impact: AI/bot clients using the MCP can't create epic → subticket hierarchies, can't reparent tickets, and can't clear a parent. Any project-shaped work driven through the MCP has to be flat, and parents have to be wired manually in the web UI afterwards.
(Same pattern as ISSUE-20 for visibility — the API supports it, the MCP doesn't surface it.)
Fix
1. Schemas
// apps/api/src/routes/mcp.ts
const createBodySchema = z.object({
project: projectKeySchema,
title: z.string().trim().min(1).max(200),
description: z.string().max(50_000).optional(),
statusSlug: z.string().trim().min(1).max(80).optional(),
priority: z.enum(PRIORITIES).optional(),
labels: z.array(z.string().trim().min(1).max(80)).optional(),
assignee: z.string().trim().min(1).max(100).nullable().optional(),
parentTicketRef: z.string().regex(/^[A-Z]{2,6}-[1-9][0-9]*$/).nullable().optional(), // new
}).strict();
const patchBodySchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
description: z.string().max(50_000).optional(),
statusSlug: z.string().trim().min(1).max(80).optional(),
priority: z.enum(PRIORITIES).optional(),
labels: z.array(z.string().trim().min(1).max(80)).optional(),
addLabels: z.array(z.string().trim().min(1).max(80)).optional(),
removeLabels: z.array(z.string().trim().min(1).max(80)).optional(),
assignee: z.string().trim().min(1).max(100).nullable().optional(),
parentTicketRef: z.string().regex(/^[A-Z]{2,6}-[1-9][0-9]*$/).nullable().optional(), // new
}).strict();
Accept the ticket as a ref (GIFT-12), not a raw UUID — the MCP surface is ref-shaped everywhere else. Resolve ref → UUID in the service layer.
2. Service input types + resolution + passthrough
// apps/api/src/services/mcpService.ts
export type McpCreateInput = {
projectKey: string;
title: string;
description?: string;
statusSlug?: string;
priority?: Priority;
labels?: string[];
assignee?: string | null;
parentTicketRef?: string | null; // new
};
export type McpPatchInput = {
// ... existing ...
parentTicketRef?: string | null; // new
};
async function resolveParentTicketID(
tx: Transaction,
projectID: string,
ref: string | null | undefined,
): Promise<string | null | undefined> {
if (ref === undefined) return undefined; // not provided → don't touch
if (ref === null) return null; // explicit clear
const [projectKey, numberStr] = ref.split("-");
const number = Number(numberStr);
const row = await tx
.select({ id: tickets.id, projectID: tickets.projectID })
.from(tickets)
.innerJoin(projects, eq(tickets.projectID, projects.id))
.where(and(eq(projects.key, projectKey), eq(tickets.number, number), isNull(tickets.deletedAt)))
.limit(1)
.then((rows) => rows[0]);
if (!row) throw new HTTPException(404, { message: `Parent ticket ${ref} not found` });
if (row.projectID !== projectID) {
throw new HTTPException(400, { message: `Parent ticket ${ref} is in a different project` });
}
return row.id;
}
// inside createTicket - pass through
const parentTicketID = await resolveParentTicketID(tx, project.id, input.parentTicketRef);
const row = await TicketService.createTicket({
projectID: project.id,
reporterID: userID,
title: input.title,
// ... existing ...
parentTicketID, // new
});
// inside patchTicket - pass through (undefined = don't touch, null = clear)
const parentTicketID = await resolveParentTicketID(tx, context.project.id, patch.parentTicketRef);
await TicketService.patchTicket(context.ticketID, context.project.id, userID, {
// ... existing ...
...(parentTicketID !== undefined && { parentTicketID }), // new
});
The existing assertNoParentCycle in services/ticketService.ts:508 already protects against cycles on patch; createTicket sets a brand-new ticket so no cycle is possible there.
3. MCP tool definitions
// apps/mcp/src/tools/tickets.ts
// Add parentTicketRef to the create_ticket and update_ticket tool input schemas
// so clients see it in the tool description. Forward through to the REST API call.
The MCP-side schema should reuse the same ref pattern (/^[A-Z]{2,6}-[1-9][0-9]*$/).
4. Cross-project parent
This implementation rejects cross-project parents (consistent with how kanban / linking treats project boundaries). If cross-project epics are wanted later, that's a separate ticket — the relates_to link type already covers the loose case.
Files
apps/api/src/routes/mcp.tsapps/api/src/services/mcpService.tsapps/mcp/src/tools/tickets.tspackages/shared/src/mcp.ts(if MCP types are shared)
Sub-tickets
0No sub-tickets yet.
Links
No links.
The MCP
create_ticketandupdate_tickettools now acceptparentTicketRef(e.g."DASH-12"), so clients can build epic/subticket hierarchies, reparent, or clear parents without dropping into the web UI.createBodySchemaandpatchBodySchemainroutes/mcp.tsacceptparentTicketRef: refSchema.nullable().optional(). A new private helperresolveParentTicketRef(projectID, ref)inmcpService.tsresolves the ref to a UUID against the active project: 400 on malformed, 404 on unknown, 400 on cross-project.nullclears on patch and does nothing on create.Cycle detection on patch is already covered by
TicketService.patchTicket.assertNoParentCycle, so the MCP layer just passes the UUID through.Stdio side:
parentTicketRefadded tocreateSchema/updateSchemainapps/mcp/src/tools/tickets.tsand toCreateTicketArgs/PatchTicketArgsinclient.ts. Tool descriptions updated so clients see the new field.Cross-project parents are rejected, consistent with kanban and linking project boundaries. The
relates_tolink type still covers the loose cross-project case.Tests cover set, null clear, unknown ref, malformed ref, cycle rejection, and cross-project rejection in
mcp.test.ts. Stdio tool tests verify forwarding including the null-clear case intools.test.ts.Code: https://github.com/jmmd2000/issues/commit/3b3b122925007bb57e031ac91a5f901bd6185656