From e9d87dd8ff09aba9f980dfb3b4496c56c066f3b8 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 26 Apr 2026 23:04:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(ST-705):=20read=20tools=20=E2=80=94=20heal?= =?UTF-8?q?th,=20list=5Fproducts,=20get=5Fclaude=5Fcontext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health: pings DB via SELECT 1 and returns status/version/time - list_products: active products owned or shared with the auth user - get_claude_context: bundled product + active sprint + next story (with tasks, status mapped to lowercase) + 50 open todos prisma.ts switches to a lazy proxy so the server bootstrap doesn't crash before tools fire when DATABASE_URL is unset. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 8 ++- src/prisma.ts | 9 ++- src/tools/get-claude-context.ts | 123 ++++++++++++++++++++++++++++++++ src/tools/health.ts | 34 +++++++++ src/tools/list-products.ts | 42 +++++++++++ 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/tools/get-claude-context.ts create mode 100644 src/tools/health.ts create mode 100644 src/tools/list-products.ts diff --git a/src/index.ts b/src/index.ts index 31ccfca..16bd1f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ #!/usr/bin/env node import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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' const VERSION = '0.1.0' @@ -14,7 +17,10 @@ async function main() { }, ) - // Tools and prompts will be registered here in ST-705..ST-709. + registerHealthTool(server) + registerListProductsTool(server) + registerGetClaudeContextTool(server) + // Write tools and prompts in ST-706..ST-709. const transport = new StdioServerTransport() await server.connect(transport) diff --git a/src/prisma.ts b/src/prisma.ts index ca9eab7..8faa9a4 100644 --- a/src/prisma.ts +++ b/src/prisma.ts @@ -2,6 +2,8 @@ import { PrismaClient } from '@prisma/client' import { Pool } from 'pg' import { PrismaPg } from '@prisma/adapter-pg' +let client: PrismaClient | null = null + function createClient(): PrismaClient { const url = process.env.DATABASE_URL if (!url) { @@ -12,4 +14,9 @@ function createClient(): PrismaClient { return new PrismaClient({ adapter, log: ['error'] }) } -export const prisma = createClient() +export const prisma = new Proxy({} as PrismaClient, { + get(_target, prop) { + if (!client) client = createClient() + return Reflect.get(client, prop, client) + }, +}) diff --git a/src/tools/get-claude-context.ts b/src/tools/get-claude-context.ts new file mode 100644 index 0000000..11a8055 --- /dev/null +++ b/src/tools/get-claude-context.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { getAuth } from '../auth.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' +import { storyStatusToApi, taskStatusToApi } from '../status.js' + +const inputSchema = z.object({ + product_id: z.string().min(1), +}) + +export function registerGetClaudeContextTool(server: McpServer) { + server.registerTool( + 'get_claude_context', + { + title: 'Bundled context for Claude Code', + description: + 'Fetch product, active sprint, next story (with tasks) and open todos in one call. ' + + 'Always start a Scrum4Me workflow with this tool.', + inputSchema, + annotations: { readOnlyHint: true }, + }, + async ({ product_id }) => + withToolErrors(async () => { + const auth = await getAuth() + + const product = await prisma.product.findFirst({ + where: { + id: product_id, + OR: [ + { user_id: auth.userId }, + { members: { some: { user_id: auth.userId } } }, + ], + }, + select: { + id: true, + code: true, + name: true, + description: true, + repo_url: true, + definition_of_done: true, + }, + }) + + if (!product) { + return toolError(`Product ${product_id} not found or not accessible`) + } + + const activeSprint = await prisma.sprint.findFirst({ + where: { product_id, status: 'ACTIVE' }, + orderBy: { created_at: 'desc' }, + select: { id: true, sprint_goal: true, status: true }, + }) + + let nextStory = null + if (activeSprint) { + const story = await prisma.story.findFirst({ + where: { + sprint_id: activeSprint.id, + status: { in: ['OPEN', 'IN_SPRINT'] }, + }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + status: true, + tasks: { + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { + id: true, + title: true, + description: true, + implementation_plan: true, + priority: true, + sort_order: true, + status: true, + }, + }, + }, + }) + if (story) { + nextStory = { + ...story, + status: storyStatusToApi(story.status), + tasks: story.tasks.map((t, i) => ({ + ...t, + code: story.code ? `${story.code}.${i + 1}` : null, + status: taskStatusToApi(t.status), + })), + } + } + } + + const openTodos = await prisma.todo.findMany({ + where: { + user_id: auth.userId, + done: false, + archived: false, + OR: [{ product_id: product_id }, { product_id: null }], + }, + orderBy: { created_at: 'asc' }, + take: 50, + select: { + id: true, + title: true, + description: true, + created_at: true, + }, + }) + + return toolJson({ + product, + active_sprint: activeSprint, + next_story: nextStory, + open_todos: openTodos, + }) + }), + ) +} diff --git a/src/tools/health.ts b/src/tools/health.ts new file mode 100644 index 0000000..0bb8492 --- /dev/null +++ b/src/tools/health.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { toolJson, withToolErrors } from '../errors.js' + +const VERSION = '0.1.0' + +export function registerHealthTool(server: McpServer) { + server.registerTool( + 'health', + { + title: 'Health probe', + description: + 'Check that the MCP server and Scrum4Me database are reachable. Always safe to call.', + inputSchema: z.object({}), + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + async () => + withToolErrors(async () => { + let database: 'ok' | 'down' = 'ok' + try { + await prisma.$queryRaw`SELECT 1` + } catch { + database = 'down' + } + return toolJson({ + status: 'ok', + version: VERSION, + time: new Date().toISOString(), + database, + }) + }), + ) +} diff --git a/src/tools/list-products.ts b/src/tools/list-products.ts new file mode 100644 index 0000000..108fa0e --- /dev/null +++ b/src/tools/list-products.ts @@ -0,0 +1,42 @@ +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' + +export function registerListProductsTool(server: McpServer) { + server.registerTool( + 'list_products', + { + title: 'List accessible products', + description: + 'List all active products the authenticated user owns or is a member of. ' + + 'Use this to find a product_id for other tools.', + inputSchema: z.object({}), + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + async () => + withToolErrors(async () => { + const auth = await getAuth() + const products = await prisma.product.findMany({ + where: { + archived: false, + OR: [ + { user_id: auth.userId }, + { members: { some: { user_id: auth.userId } } }, + ], + }, + orderBy: { created_at: 'desc' }, + select: { + id: true, + code: true, + name: true, + description: true, + repo_url: true, + definition_of_done: true, + }, + }) + return toolJson(products) + }), + ) +}