feat(mcp): set_pbi_pr + mark_pbi_pr_merged tools voor PBI-PR-gating (#18)

* feat(ST-mhj9f2la): add set_pbi_pr MCP tool

- Add pr_url and pr_merged_at fields to Pbi model in schema
- Implement set_pbi_pr tool: writes pr_url, clears pr_merged_at (idempotent)
- AuthZ via requireWriteAccess + userCanAccessProduct through pbi.product_id
- 10 tests: happy path, not-found, no-access, demo-denied, schema validation
- Update README tools table and bump version to 0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-mhj9f2la): add mark_pbi_pr_merged MCP tool

- Implement mark_pbi_pr_merged: sets pr_merged_at = now() on a PBI
- Requires pr_url to be set; returns error if not (geen gekoppelde PR)
- Idempotent: re-calling overwrites the timestamp
- AuthZ via requireWriteAccess + userCanAccessProduct through pbi.product_id
- 6 tests: happy path, no-pr_url, idempotent, no-access, not-found, demo-denied
- Update README tools table with mark_pbi_pr_merged entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-mhj9f2la): expand README with set_pbi_pr + mark_pbi_pr_merged docs

Add full signature/input/output/error documentation sections for both
new tools, following the verify_task_against_plan pattern.
Version already bumped to 0.2.0 in earlier commit.
Tag + MCP_GIT_REF pin in scrum4me-docker to be done by maintainer after merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-03 16:25:53 +02:00 committed by GitHub
parent 2c85f4d239
commit 3ce2c044c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 404 additions and 36 deletions

View file

@ -21,13 +21,15 @@ import { registerWaitForJobTool } from './tools/wait-for-job.js'
import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerSetPbiPrTool } from './tools/set-pbi-pr.js'
import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
import { getAuth } from './auth.js'
import { registerWorker } from './presence/worker.js'
import { startHeartbeat } from './presence/heartbeat.js'
import { registerShutdownHandlers } from './presence/shutdown.js'
const VERSION = '0.1.0'
const VERSION = '0.2.0'
async function main() {
const server = new McpServer(
@ -59,6 +61,8 @@ async function main() {
registerUpdateJobStatusTool(server)
registerVerifyTaskAgainstPlanTool(server)
registerCleanupMyWorktreesTool(server)
registerSetPbiPrTool(server)
registerMarkPbiPrMergedTool(server)
registerImplementNextStoryPrompt(server)
// Presence bootstrap MUST run before server.connect — the stdio transport

View file

@ -0,0 +1,48 @@
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),
})
export async function handleMarkPbiPrMerged({ pbi_id }: z.infer<typeof inputSchema>) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true, pr_url: true },
})
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not found or not accessible`)
}
if (!pbi.pr_url) {
return toolError(`PBI ${pbi_id} heeft geen gekoppelde PR`)
}
const updated = await prisma.pbi.update({
where: { id: pbi_id },
data: { pr_merged_at: new Date() },
select: { id: true, pr_url: true, pr_merged_at: true },
})
return toolJson({ ok: true, pbi_id, pr_url: updated.pr_url, pr_merged_at: updated.pr_merged_at })
})
}
export function registerMarkPbiPrMergedTool(server: McpServer) {
server.registerTool(
'mark_pbi_pr_merged',
{
title: 'Mark PBI PR Merged',
description:
'Set pr_merged_at = now() on a PBI, signalling the PR has been merged. Requires pr_url to already be set. Idempotent: re-calling overwrites the timestamp. Forbidden for demo accounts.',
inputSchema,
},
handleMarkPbiPrMerged,
)
}

45
src/tools/set-pbi-pr.ts Normal file
View file

@ -0,0 +1,45 @@
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'
export const inputSchema = z.object({
pbi_id: z.string().min(1),
pr_url: z.string().regex(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/),
})
export async function handleSetPbiPr({ pbi_id, pr_url }: z.infer<typeof inputSchema>) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true },
})
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not found or not accessible`)
}
await prisma.pbi.update({
where: { id: pbi_id },
data: { pr_url, pr_merged_at: null },
})
return toolJson({ ok: true, pbi_id, pr_url })
})
}
export function registerSetPbiPrTool(server: McpServer) {
server.registerTool(
'set_pbi_pr',
{
title: 'Set PBI PR URL',
description:
'Write pr_url on a PBI and clear pr_merged_at. Idempotent: re-calling overwrites pr_url and resets pr_merged_at to null. Forbidden for demo accounts.',
inputSchema,
},
handleSetPbiPr,
)
}