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:
parent
f5a630c143
commit
e9d87dd8ff
5 changed files with 214 additions and 2 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
123
src/tools/get-claude-context.ts
Normal file
123
src/tools/get-claude-context.ts
Normal 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
34
src/tools/health.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/tools/list-products.ts
Normal file
42
src/tools/list-products.ts
Normal 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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue