feat(ST-705): read tools — health, list_products, get_claude_context

- 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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 23:04:51 +02:00
parent f5a630c143
commit e9d87dd8ff
5 changed files with 214 additions and 2 deletions

View file

@ -1,6 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.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' 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() const transport = new StdioServerTransport()
await server.connect(transport) await server.connect(transport)

View file

@ -2,6 +2,8 @@ import { PrismaClient } from '@prisma/client'
import { Pool } from 'pg' import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg'
let client: PrismaClient | null = null
function createClient(): PrismaClient { function createClient(): PrismaClient {
const url = process.env.DATABASE_URL const url = process.env.DATABASE_URL
if (!url) { if (!url) {
@ -12,4 +14,9 @@ function createClient(): PrismaClient {
return new PrismaClient({ adapter, log: ['error'] }) 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)
},
})

View file

@ -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,
})
}),
)
}

34
src/tools/health.ts Normal file
View file

@ -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,
})
}),
)
}

View file

@ -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)
}),
)
}