diff --git a/README.md b/README.md index 3004738..e7b1506 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ 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 | +| `create_pbi` | Add a Product Backlog Item to a product (auto sort_order) | no | +| `create_story` | Add a story under a PBI (status=OPEN, lands in product backlog) | no | +| `create_task` | Add a task under a story (status=TO_DO, inherits sprint_id) | 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 | diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index a354791..4764b15 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 === 13, + tools.tools.length === 16, `${tools.tools.length} tools: ${tools.tools.map((t) => t.name).join(', ')}`, ) diff --git a/src/index.ts b/src/index.ts index 8c7350c..5605b52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,9 @@ 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 { registerCreatePbiTool } from './tools/create-pbi.js' +import { registerCreateStoryTool } from './tools/create-story.js' +import { registerCreateTaskTool } from './tools/create-task.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' @@ -37,6 +40,9 @@ async function main() { registerLogTestResultTool(server) registerLogCommitTool(server) registerCreateTodoTool(server) + registerCreatePbiTool(server) + registerCreateStoryTool(server) + registerCreateTaskTool(server) registerAskUserQuestionTool(server) registerGetQuestionAnswerTool(server) registerListOpenQuestionsTool(server) diff --git a/src/tools/create-pbi.ts b/src/tools/create-pbi.ts new file mode 100644 index 0000000..7090114 --- /dev/null +++ b/src/tools/create-pbi.ts @@ -0,0 +1,68 @@ +// MCP authoring tool: create een Product Backlog Item. +// +// Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als +// niet meegegeven. Code-veld blijft null — auto-codes (PBI-1, PBI-2, …) worden +// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const inputSchema = z.object({ + product_id: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().max(4000).optional(), + priority: z.number().int().min(1).max(4), + sort_order: z.number().optional(), +}) + +export function registerCreatePbiTool(server: McpServer) { + server.registerTool( + 'create_pbi', + { + title: 'Create PBI', + description: + 'Add a Product Backlog Item to a product. Sort_order auto-set to last+1 within the priority group if not provided. Forbidden for demo accounts.', + inputSchema, + }, + async ({ product_id, title, description, priority, sort_order }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userCanAccessProduct(product_id, auth.userId))) { + return toolError(`Product ${product_id} not found or not accessible`) + } + + let resolvedSortOrder = sort_order + if (resolvedSortOrder === undefined) { + const last = await prisma.pbi.findFirst({ + where: { product_id, priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 + } + + const pbi = await prisma.pbi.create({ + data: { + product_id, + title, + description: description ?? null, + priority, + sort_order: resolvedSortOrder, + }, + select: { + id: true, + title: true, + description: true, + priority: true, + sort_order: true, + created_at: true, + }, + }) + return toolJson(pbi) + }), + ) +} diff --git a/src/tools/create-story.ts b/src/tools/create-story.ts new file mode 100644 index 0000000..5f9877a --- /dev/null +++ b/src/tools/create-story.ts @@ -0,0 +1,80 @@ +// MCP authoring tool: create een Story onder een bestaande PBI. +// +// product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md +// convention — nooit vertrouwen op client-input). status='OPEN' default; +// landt in de Product Backlog, niet auto in een sprint. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const inputSchema = z.object({ + pbi_id: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().max(4000).optional(), + acceptance_criteria: z.string().max(4000).optional(), + priority: z.number().int().min(1).max(4), + sort_order: z.number().optional(), +}) + +export function registerCreateStoryTool(server: McpServer) { + server.registerTool( + 'create_story', + { + title: 'Create story', + description: + 'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', + inputSchema, + }, + async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + + const pbi = await prisma.pbi.findUnique({ + where: { id: pbi_id }, + select: { product_id: true }, + }) + if (!pbi) return toolError(`PBI ${pbi_id} not found`) + if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { + return toolError(`PBI ${pbi_id} not accessible`) + } + + let resolvedSortOrder = sort_order + if (resolvedSortOrder === undefined) { + const last = await prisma.story.findFirst({ + where: { pbi_id, priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 + } + + const story = await prisma.story.create({ + data: { + pbi_id, + product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input + title, + description: description ?? null, + acceptance_criteria: acceptance_criteria ?? null, + priority, + sort_order: resolvedSortOrder, + status: 'OPEN', + }, + select: { + id: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + created_at: true, + }, + }) + return toolJson(story) + }), + ) +} diff --git a/src/tools/create-task.ts b/src/tools/create-task.ts new file mode 100644 index 0000000..70fdd91 --- /dev/null +++ b/src/tools/create-task.ts @@ -0,0 +1,79 @@ +// 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.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +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(), +}) + +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. Forbidden for demo accounts.', + inputSchema, + }, + async ({ story_id, title, description, implementation_plan, priority, sort_order }) => + 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 + } + + const task = await prisma.task.create({ + data: { + story_id, + sprint_id: story.sprint_id, // denormalized — erf van story + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + sort_order: resolvedSortOrder, + status: 'TO_DO', + }, + select: { + id: true, + title: true, + description: true, + implementation_plan: true, + priority: true, + sort_order: true, + status: true, + created_at: true, + }, + }) + return toolJson(task) + }), + ) +}