162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
// ST-1102: ask_user_question — Claude vraagt aan de actieve gebruiker via een
|
|
// gestructureerde vraag in claude_questions. De Postgres-trigger emit op
|
|
// scrum4me_changes; de Scrum4Me-app toont een notificatie-badge en de gebruiker
|
|
// antwoordt in de UI. Met optionele `wait_seconds` polt deze tool intern op
|
|
// het antwoord; default async (returnt direct met question_id).
|
|
|
|
import { z } from 'zod'
|
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import { prisma } from '../prisma.js'
|
|
import { requireWriteAccess } from '../auth.js'
|
|
import { userCanAccessStory, userOwnsIdea } from '../access.js'
|
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
import { triggerPush } from '../lib/push-trigger.js'
|
|
|
|
const PENDING_TTL_HOURS = 24
|
|
const POLL_INTERVAL_MS = 2_000
|
|
const MAX_WAIT_SECONDS = 600
|
|
|
|
// M12: schema accepteert exact één van story_id of idea_id (xor refine).
|
|
const inputSchema = z
|
|
.object({
|
|
story_id: z.string().min(1).optional(),
|
|
idea_id: z.string().min(1).optional(),
|
|
question: z.string().min(1).max(4_000),
|
|
options: z.array(z.string().min(1)).max(8).optional(),
|
|
task_id: z.string().min(1).optional(),
|
|
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
|
|
})
|
|
.refine((d) => Boolean(d.story_id) !== Boolean(d.idea_id), {
|
|
message: 'Provide exactly one of story_id or idea_id',
|
|
})
|
|
|
|
function summarize(q: {
|
|
id: string
|
|
status: string
|
|
question: string
|
|
options: unknown
|
|
answer: string | null
|
|
answered_by: string | null
|
|
answered_at: Date | null
|
|
expires_at: Date
|
|
}) {
|
|
return {
|
|
question_id: q.id,
|
|
status: q.status,
|
|
question: q.question,
|
|
options: q.options,
|
|
answer: q.answer,
|
|
answered_by: q.answered_by,
|
|
answered_at: q.answered_at?.toISOString() ?? null,
|
|
expires_at: q.expires_at.toISOString(),
|
|
}
|
|
}
|
|
|
|
export function registerAskUserQuestionTool(server: McpServer) {
|
|
server.registerTool(
|
|
'ask_user_question',
|
|
{
|
|
title: 'Ask user question',
|
|
description:
|
|
'Post a question to the active Scrum4Me user about a story Claude is implementing. ' +
|
|
'Returns immediately with status="open" by default; pass `wait_seconds` (max 600) to ' +
|
|
'poll internally and return the answer as soon as the user submits it. Forbidden for ' +
|
|
'demo accounts.',
|
|
inputSchema,
|
|
},
|
|
async ({ story_id, idea_id, question, options, task_id, wait_seconds }) =>
|
|
withToolErrors(async () => {
|
|
const auth = await requireWriteAccess()
|
|
|
|
// M12: branch on which scope was provided. story_id en idea_id sluiten
|
|
// elkaar uit (zod-refine in inputSchema).
|
|
let productId: string
|
|
if (idea_id) {
|
|
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
|
return toolError(`Idea ${idea_id} not found`)
|
|
}
|
|
const idea = await prisma.idea.findUnique({
|
|
where: { id: idea_id },
|
|
select: { product_id: true },
|
|
})
|
|
if (!idea?.product_id) {
|
|
// Idee zonder product mag pas Q&A starten als product gekoppeld is
|
|
// (M12 grill-keuze 3: product met repo verplicht voor grill).
|
|
return toolError(`Idea ${idea_id} has no linked product`)
|
|
}
|
|
productId = idea.product_id
|
|
} else if (story_id) {
|
|
if (!(await userCanAccessStory(story_id, auth.userId))) {
|
|
return toolError(`Story ${story_id} not found or not accessible`)
|
|
}
|
|
const story = await prisma.story.findUnique({
|
|
where: { id: story_id },
|
|
select: { product_id: true },
|
|
})
|
|
if (!story) {
|
|
return toolError(`Story ${story_id} not found`)
|
|
}
|
|
productId = story.product_id
|
|
|
|
if (task_id) {
|
|
const task = await prisma.task.findFirst({
|
|
where: { id: task_id, story_id },
|
|
select: { id: true },
|
|
})
|
|
if (!task) {
|
|
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
|
|
}
|
|
}
|
|
} else {
|
|
// Mag niet voorkomen door de zod-refine, maar TS-narrow.
|
|
return toolError('Provide exactly one of story_id or idea_id')
|
|
}
|
|
|
|
const created = await prisma.claudeQuestion.create({
|
|
data: {
|
|
story_id: story_id ?? null,
|
|
idea_id: idea_id ?? null,
|
|
task_id: task_id ?? null,
|
|
product_id: productId,
|
|
asked_by: auth.userId,
|
|
question,
|
|
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;
|
|
// door undefined fields uit te sluiten laten we DB-default kicken.
|
|
...(options !== undefined ? { options } : {}),
|
|
status: 'open',
|
|
expires_at: new Date(Date.now() + PENDING_TTL_HOURS * 60 * 60 * 1000),
|
|
},
|
|
})
|
|
|
|
void triggerPush(auth.userId, {
|
|
title: 'Claude heeft een vraag',
|
|
body: question.slice(0, 120),
|
|
url: '/notifications',
|
|
tag: `claude-q-${created.id}`,
|
|
})
|
|
|
|
// Async-mode (default): return direct.
|
|
if (!wait_seconds || wait_seconds === 0) {
|
|
return toolJson(summarize(created))
|
|
}
|
|
|
|
// Sync-mode: poll tot status verandert of timeout.
|
|
const deadline = Date.now() + wait_seconds * 1000
|
|
let current = created
|
|
while (current.status === 'open' && Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
|
|
const refreshed = await prisma.claudeQuestion.findUnique({
|
|
where: { id: created.id },
|
|
})
|
|
if (!refreshed) break
|
|
current = refreshed
|
|
}
|
|
|
|
// Eindstatus: answered/cancelled/expired → return met antwoord; nog open → 'pending'
|
|
if (current.status === 'open') {
|
|
return toolJson({ ...summarize(current), status: 'pending' })
|
|
}
|
|
return toolJson(summarize(current))
|
|
}),
|
|
)
|
|
}
|