scrum4me-mcp/src/tools/create-task.ts
Madhura68 f600237c8c feat(create_task): optionele repo_url voor cross-repo tasks
Schema heeft Task.repo_url al (override van product.repo_url voor
worktree/branch/push), maar de create_task MCP-tool exposeerde 'm
niet — gevolg: cross-repo tasks (bv. T-519 in scrum4me-mcp onder een
Scrum4Me-PBI) eindigden met repo_url=null en worker draaide ze in
het verkeerde repo.

PBI-34 introduceerde IdeaProduct (idea aan meerdere producten) als
multi-product-pattern. Voor PBI/Story is geen extensie nodig; per-task
override is genoeg om cross-repo werk correct te routeren.

Validatie: zod.string().url() — full https://github.com/owner/repo URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:16:31 +02:00

129 lines
4.9 KiB
TypeScript

// MCP authoring tool: create een Task onder een bestaande Story.
//
// sprint_id wordt afgeleid uit de Story (denormalized FK). Als de story in
// een sprint zit, erft de task die sprint_id; anders null. Status='TO_DO'.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const TASK_AUTO_RE = /^T-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextTaskCode(productId: string): Promise<string> {
const tasks = await prisma.task.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const t of tasks) {
const m = t.code?.match(TASK_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `T-${max + 1}`
}
function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
if (!target) return false
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({
story_id: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().max(4000).optional(),
implementation_plan: z.string().max(8000).optional(),
priority: z.number().int().min(1).max(4),
sort_order: z.number().optional(),
// Cross-repo override: zet expliciet de repo waarop de worker deze task
// moet uitvoeren (overrides product.repo_url). Gebruik dit voor PBI's die
// werk in meerdere repos coördineren — bv. PBI op Scrum4Me-product met
// tasks die in scrum4me-mcp of scrum4me-docker landen.
// Format: full git URL (https://github.com/owner/repo). Null/omit = erf
// van product.repo_url.
repo_url: z.string().url().optional(),
})
export function registerCreateTaskTool(server: McpServer) {
server.registerTool(
'create_task',
{
title: 'Create task',
description:
'Add a task under an existing story. Inherits sprint_id from the story (denormalized). Status defaults to TO_DO. Sort_order auto-set to last+1 within the story/priority group if not provided. Optional repo_url overrides the product.repo_url for cross-repo work (e.g. tasks targeting scrum4me-mcp under a Scrum4Me PBI). Forbidden for demo accounts.',
inputSchema,
},
async ({ story_id, title, description, implementation_plan, priority, sort_order, repo_url }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const story = await prisma.story.findUnique({
where: { id: story_id },
select: { product_id: true, sprint_id: true },
})
if (!story) return toolError(`Story ${story_id} not found`)
if (!(await userCanAccessProduct(story.product_id, auth.userId))) {
return toolError(`Story ${story_id} not accessible`)
}
let resolvedSortOrder = sort_order
if (resolvedSortOrder === undefined) {
const last = await prisma.task.findFirst({
where: { story_id, priority },
orderBy: { sort_order: 'desc' },
select: { sort_order: true },
})
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextTaskCode(story.product_id)
try {
const task = await prisma.task.create({
data: {
story_id,
product_id: story.product_id, // denormalized — erf van story
sprint_id: story.sprint_id, // denormalized — erf van story
code,
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'TO_DO',
repo_url: repo_url ?? null,
},
select: {
id: true,
code: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
sort_order: true,
status: true,
repo_url: true,
created_at: true,
},
})
return toolJson(task)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke Task-code genereren')
}),
)
}