From e3f9476568147283150cc766907be9b12eb7559b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 26 Apr 2026 23:05:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(ST-706):=20task=20write=20tools=20?= =?UTF-8?q?=E2=80=94=20update=5Ftask=5Fstatus=20and=20update=5Ftask=5Fplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/access.ts: shared product/story/task access checks via product ownership or membership - update_task_status accepts lowercase API values, converts to DB enum, rejects unknown values - update_task_plan replaces implementation_plan on a task - Both call requireWriteAccess() so demo accounts get PERMISSION_DENIED before any DB write Co-Authored-By: Claude Opus 4.7 (1M context) --- src/access.ts | 30 +++++++++++++++++++++ src/index.ts | 6 ++++- src/tools/update-task-plan.ts | 42 ++++++++++++++++++++++++++++++ src/tools/update-task-status.ts | 46 +++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/access.ts create mode 100644 src/tools/update-task-plan.ts create mode 100644 src/tools/update-task-status.ts diff --git a/src/access.ts b/src/access.ts new file mode 100644 index 0000000..37bb38e --- /dev/null +++ b/src/access.ts @@ -0,0 +1,30 @@ +import { prisma } from './prisma.js' + +export async function userCanAccessProduct(productId: string, userId: string): Promise { + const hit = await prisma.product.findFirst({ + where: { + id: productId, + OR: [{ user_id: userId }, { members: { some: { user_id: userId } } }], + }, + select: { id: true }, + }) + return Boolean(hit) +} + +export async function userCanAccessTask(taskId: string, userId: string): Promise { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { story: { select: { product_id: true } } }, + }) + if (!task) return false + return userCanAccessProduct(task.story.product_id, userId) +} + +export async function userCanAccessStory(storyId: string, userId: string): Promise { + const story = await prisma.story.findUnique({ + where: { id: storyId }, + select: { product_id: true }, + }) + if (!story) return false + return userCanAccessProduct(story.product_id, userId) +} diff --git a/src/index.ts b/src/index.ts index 16bd1f4..ed72131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { registerHealthTool } from './tools/health.js' import { registerListProductsTool } from './tools/list-products.js' import { registerGetClaudeContextTool } from './tools/get-claude-context.js' +import { registerUpdateTaskStatusTool } from './tools/update-task-status.js' +import { registerUpdateTaskPlanTool } from './tools/update-task-plan.js' const VERSION = '0.1.0' @@ -20,7 +22,9 @@ async function main() { registerHealthTool(server) registerListProductsTool(server) registerGetClaudeContextTool(server) - // Write tools and prompts in ST-706..ST-709. + registerUpdateTaskStatusTool(server) + registerUpdateTaskPlanTool(server) + // Log tools, create_todo and prompts in ST-707..ST-709. const transport = new StdioServerTransport() await server.connect(transport) diff --git a/src/tools/update-task-plan.ts b/src/tools/update-task-plan.ts new file mode 100644 index 0000000..441be02 --- /dev/null +++ b/src/tools/update-task-plan.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessTask } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' +import { taskStatusToApi } from '../status.js' + +const inputSchema = z.object({ + task_id: z.string().min(1), + implementation_plan: z.string(), +}) + +export function registerUpdateTaskPlanTool(server: McpServer) { + server.registerTool( + 'update_task_plan', + { + title: 'Update task implementation plan', + description: + 'Save or replace the implementation_plan on a task. ' + + 'Forbidden for demo accounts.', + inputSchema, + }, + async ({ task_id, implementation_plan }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userCanAccessTask(task_id, auth.userId))) { + return toolError(`Task ${task_id} not found or not accessible`) + } + const task = await prisma.task.update({ + where: { id: task_id }, + data: { implementation_plan }, + select: { id: true, status: true, implementation_plan: true }, + }) + return toolJson({ + id: task.id, + status: taskStatusToApi(task.status), + implementation_plan: task.implementation_plan, + }) + }), + ) +} diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts new file mode 100644 index 0000000..68f482c --- /dev/null +++ b/src/tools/update-task-status.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessTask } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' +import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '../status.js' + +const inputSchema = z.object({ + task_id: z.string().min(1), + status: z.enum(TASK_STATUS_API_VALUES as [string, ...string[]]), +}) + +export function registerUpdateTaskStatusTool(server: McpServer) { + server.registerTool( + 'update_task_status', + { + title: 'Update task status', + description: + 'Set the status of a task. Allowed values: todo, in_progress, review, done. ' + + 'Forbidden for demo accounts.', + inputSchema, + }, + async ({ task_id, status }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + const dbStatus = taskStatusFromApi(status) + if (!dbStatus) { + return toolError(`Unknown status: ${status}`) + } + if (!(await userCanAccessTask(task_id, auth.userId))) { + return toolError(`Task ${task_id} not found or not accessible`) + } + const task = await prisma.task.update({ + where: { id: task_id }, + data: { status: dbStatus }, + select: { id: true, status: true, implementation_plan: true }, + }) + return toolJson({ + id: task.id, + status: taskStatusToApi(task.status), + implementation_plan: task.implementation_plan, + }) + }), + ) +}