From 7b955d31ac357e689a3d37259ddaf16f7bd5ac8c Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 28 Apr 2026 01:00:59 +0200 Subject: [PATCH] feat(ST-1102): add 4 question-channel MCP tools (M11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier nieuwe tools voor het Claude vraag-antwoord-kanaal: - ask_user_question (write): post een gestructureerde vraag aan de actieve Scrum4Me-gebruiker over een story; default async (returnt direct met question_id + status='open'); optionele wait_seconds (max 600) polt elke 2s tot het antwoord er is of timeout — daarna status='pending' zodat Claude met get_question_answer later kan ophalen - get_question_answer (read): huidige status + antwoord van een eerder gestelde vraag - list_open_questions (read): eigen vragen met status open/answered, max 50, meest recente eerst - cancel_question (write, asker-only): atomic UPDATE WHERE asked_by + status= 'open' zodat alleen eigen open vragen geannuleerd worden Allemaal achter access-check via userCanAccessStory/Product en demo-blok via requireWriteAccess (volgt patroon van create-todo en bestaande log-tools). Submodule vendor/scrum4me bumpt naar Scrum4Me commit 79367dd (M11 ST-1101) — bevat het ClaudeQuestion-model en notify_question_change-trigger waar deze tools tegen werken. scripts/smoke-test.ts: 13 tools verwacht (was 9); list_open_questions toegevoegd als read-tool-coverage. Build + tools/list groen — verdere e2e via MCP Inspector na PR-merge omdat de seed een nieuwe API-token heeft gegenereerd en .env een nieuwe waarde nodig heeft. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 + prisma/schema.prisma | 31 ++++++++ scripts/smoke-test.ts | 10 ++- src/index.ts | 8 ++ src/tools/ask-user-question.ts | 124 +++++++++++++++++++++++++++++++ src/tools/cancel-question.ts | 52 +++++++++++++ src/tools/get-question-answer.ts | 65 ++++++++++++++++ src/tools/list-open-questions.ts | 67 +++++++++++++++++ vendor/scrum4me | 2 +- 9 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/tools/ask-user-question.ts create mode 100644 src/tools/cancel-question.ts create mode 100644 src/tools/get-question-answer.ts create mode 100644 src/tools/list-open-questions.ts diff --git a/README.md b/README.md index 8aa83c6..3004738 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ activity and create todos via native tool calls instead of curl. | `log_test_result` | Append TEST_RESULT (PASSED/FAILED) | no | | `log_commit` | Append COMMIT with hash and message | no | | `create_todo` | Add a todo, optionally scoped to a product | no | +| `ask_user_question` | Post a question to the active user about a story; optional `wait_seconds` (max 600) polls for the answer | no | +| `get_question_answer` | Fetch the current status + answer of a previously-asked question | n/a | +| `list_open_questions` | List own open/answered questions, most recent first (max 50) | n/a | +| `cancel_question` | Cancel an own open question (asker-only) | no | Demo accounts may read but writes return `PERMISSION_DENIED`. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b18f633..368ee67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,8 @@ model User { product_members ProductMember[] assigned_stories Story[] @relation("StoryAssignee") login_pairings LoginPairing[] + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") @@index([active_product_id]) @@map("users") @@ -108,6 +110,7 @@ model Product { todos Todo[] members ProductMember[] active_for_users User[] @relation("UserActiveProduct") + claude_questions ClaudeQuestion[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -154,6 +157,7 @@ model Story { updated_at DateTime @updatedAt logs StoryLog[] tasks Task[] + claude_questions ClaudeQuestion[] @@unique([product_id, code]) @@index([pbi_id, priority, sort_order]) @@ -208,6 +212,7 @@ model Task { status TaskStatus @default(TO_DO) created_at DateTime @default(now()) updated_at DateTime @updatedAt + claude_questions ClaudeQuestion[] @@index([story_id, priority, sort_order]) @@index([sprint_id, status]) @@ -263,3 +268,29 @@ model LoginPairing { @@index([status, expires_at]) @@map("login_pairings") } + +model ClaudeQuestion { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) + task_id String? + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String // gedenormaliseerd uit story.product_id voor SSE-filter + asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) + asked_by String // user_id van token-houder (= Claude-token) + question String @db.Text + options Json? // string[] voor multi-choice; null voor free-text + status String // 'open' | 'answered' | 'cancelled' | 'expired' + answer String? @db.Text + answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) + answered_by String? + answered_at DateTime? + created_at DateTime @default(now()) + expires_at DateTime // ingesteld door MCP-tool, default now() + 24h + + @@index([story_id, status]) + @@index([product_id, status]) + @@index([status, expires_at]) + @@map("claude_questions") +} diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 8e66810..a354791 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -53,7 +53,7 @@ async function main() { const tools = await client.listTools() log( 'tools/list', - tools.tools.length === 9, + tools.tools.length === 13, `${tools.tools.length} tools: ${tools.tools.map((t) => t.name).join(', ')}`, ) @@ -75,6 +75,14 @@ async function main() { log('get_claude_context', !ctx.isError, ctx.text) } + // list_open_questions (M11 — read-only, geen write nodig voor smoke-test) + const openQs = await callTool(client, 'list_open_questions') + log('list_open_questions', !openQs.isError, openQs.text) + if (!openQs.isError) { + const parsed = JSON.parse(openQs.text) as { count: number } + log('list_open_questions.shape', typeof parsed.count === 'number', `count=${parsed.count}`) + } + // prompts/list const prompts = await client.listPrompts() log( diff --git a/src/index.ts b/src/index.ts index 4bdb489..8c7350c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ import { registerLogImplementationTool } from './tools/log-implementation.js' import { registerLogTestResultTool } from './tools/log-test-result.js' import { registerLogCommitTool } from './tools/log-commit.js' import { registerCreateTodoTool } from './tools/create-todo.js' +import { registerAskUserQuestionTool } from './tools/ask-user-question.js' +import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js' +import { registerListOpenQuestionsTool } from './tools/list-open-questions.js' +import { registerCancelQuestionTool } from './tools/cancel-question.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' const VERSION = '0.1.0' @@ -33,6 +37,10 @@ async function main() { registerLogTestResultTool(server) registerLogCommitTool(server) registerCreateTodoTool(server) + registerAskUserQuestionTool(server) + registerGetQuestionAnswerTool(server) + registerListOpenQuestionsTool(server) + registerCancelQuestionTool(server) registerImplementNextStoryPrompt(server) const transport = new StdioServerTransport() diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts new file mode 100644 index 0000000..dd9201f --- /dev/null +++ b/src/tools/ask-user-question.ts @@ -0,0 +1,124 @@ +// 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 } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const PENDING_TTL_HOURS = 24 +const POLL_INTERVAL_MS = 2_000 +const MAX_WAIT_SECONDS = 600 + +const inputSchema = z.object({ + story_id: z.string().min(1), + 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(), +}) + +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, question, options, task_id, wait_seconds }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + 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`) + } + + 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}`) + } + } + + const created = await prisma.claudeQuestion.create({ + data: { + story_id, + task_id: task_id ?? null, + product_id: story.product_id, + 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), + }, + }) + + // 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)) + }), + ) +} diff --git a/src/tools/cancel-question.ts b/src/tools/cancel-question.ts new file mode 100644 index 0000000..154d35f --- /dev/null +++ b/src/tools/cancel-question.ts @@ -0,0 +1,52 @@ +// ST-1102: cancel_question — alleen de asker (Claude) annuleert een eigen open +// vraag, bv. wanneer hij in de loop van het werk de oplossing zelf vindt en +// het antwoord niet meer relevant is. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const inputSchema = z.object({ + question_id: z.string().min(1), +}) + +export function registerCancelQuestionTool(server: McpServer) { + server.registerTool( + 'cancel_question', + { + title: 'Cancel question', + description: + 'Cancel an own open Claude question (only the asker may cancel). ' + + 'Forbidden for demo accounts.', + inputSchema, + }, + async ({ question_id }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + + // Atomic: alleen open vragen van deze asker mogen cancelled worden. + const result = await prisma.claudeQuestion.updateMany({ + where: { + id: question_id, + asked_by: auth.userId, + status: 'open', + }, + data: { status: 'cancelled' }, + }) + + if (result.count !== 1) { + // Disambigueer: bestaat de vraag voor deze asker? + const exists = await prisma.claudeQuestion.findFirst({ + where: { id: question_id, asked_by: auth.userId }, + select: { status: true }, + }) + if (!exists) return toolError(`Question ${question_id} not found or not yours`) + return toolError(`Question ${question_id} is already ${exists.status}`) + } + + return toolJson({ question_id, status: 'cancelled' }) + }), + ) +} diff --git a/src/tools/get-question-answer.ts b/src/tools/get-question-answer.ts new file mode 100644 index 0000000..33d9971 --- /dev/null +++ b/src/tools/get-question-answer.ts @@ -0,0 +1,65 @@ +// ST-1102: get_question_answer — vraag de huidige status + antwoord op van een +// eerder gestelde vraag. Bedoeld voor Claude om in een latere sessie het +// resultaat op te pikken (voor wanneer ask_user_question async werd gebruikt). + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { getAuth } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const inputSchema = z.object({ + question_id: z.string().min(1), +}) + +export function registerGetQuestionAnswerTool(server: McpServer) { + server.registerTool( + 'get_question_answer', + { + title: 'Get question answer', + description: + 'Fetch the current status and (if available) answer for a previously-asked Claude question. ' + + 'Use this when ask_user_question was called with wait_seconds=0 (or timed out) and you want ' + + 'to check whether the user has answered.', + inputSchema, + }, + async ({ question_id }) => + withToolErrors(async () => { + const auth = await getAuth() + const q = await prisma.claudeQuestion.findUnique({ + where: { id: question_id }, + select: { + id: true, + product_id: true, + story_id: true, + task_id: true, + status: true, + question: true, + options: true, + answer: true, + answered_by: true, + answered_at: true, + expires_at: true, + }, + }) + if (!q) return toolError(`Question ${question_id} not found`) + if (!(await userCanAccessProduct(q.product_id, auth.userId))) { + return toolError(`Question ${question_id} not accessible`) + } + + return toolJson({ + question_id: q.id, + status: q.status, + story_id: q.story_id, + task_id: q.task_id, + 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(), + }) + }), + ) +} diff --git a/src/tools/list-open-questions.ts b/src/tools/list-open-questions.ts new file mode 100644 index 0000000..38483ef --- /dev/null +++ b/src/tools/list-open-questions.ts @@ -0,0 +1,67 @@ +// ST-1102: list_open_questions — toont vragen die de tokengebruiker (Claude) +// zelf heeft gesteld, status open of answered. Bedoeld als check-up bij begin +// van een nieuwe sessie: zijn er antwoorden binnengekomen op eerdere vragen? + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { getAuth } from '../auth.js' +import { toolJson, withToolErrors } from '../errors.js' + +const MAX_RESULTS = 50 + +const inputSchema = z.object({ + story_id: z.string().min(1).optional(), +}) + +export function registerListOpenQuestionsTool(server: McpServer) { + server.registerTool( + 'list_open_questions', + { + title: 'List open questions', + description: + 'List Claude questions asked by the current token, status open or answered, ' + + `most recent first (max ${MAX_RESULTS}). Optionally filter by story_id.`, + inputSchema, + }, + async ({ story_id }) => + withToolErrors(async () => { + const auth = await getAuth() + const rows = await prisma.claudeQuestion.findMany({ + where: { + asked_by: auth.userId, + status: { in: ['open', 'answered'] }, + ...(story_id ? { story_id } : {}), + }, + orderBy: { created_at: 'desc' }, + take: MAX_RESULTS, + select: { + id: true, + story_id: true, + task_id: true, + status: true, + question: true, + answer: true, + created_at: true, + answered_at: true, + expires_at: true, + }, + }) + + return toolJson({ + count: rows.length, + questions: rows.map((q) => ({ + question_id: q.id, + story_id: q.story_id, + task_id: q.task_id, + status: q.status, + question: q.question, + answer: q.answer, + created_at: q.created_at.toISOString(), + answered_at: q.answered_at?.toISOString() ?? null, + expires_at: q.expires_at.toISOString(), + })), + }) + }), + ) +} diff --git a/vendor/scrum4me b/vendor/scrum4me index 7461643..79367dd 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 74616432d25dfbee706de1eb80b338e3d7433764 +Subproject commit 79367dda7bf10e1a0c95786d861bcb08c6674a99