Merge pull request #25 from madhura68/feat/worker-quota-tools
feat(M13): get_worker_settings + worker_heartbeat tools (v0.7.0)
This commit is contained in:
commit
f5887da1f5
6 changed files with 192 additions and 8 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
|
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ enum Role {
|
||||||
PRODUCT_OWNER
|
PRODUCT_OWNER
|
||||||
SCRUM_MASTER
|
SCRUM_MASTER
|
||||||
DEVELOPER
|
DEVELOPER
|
||||||
|
ADMIN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StoryStatus {
|
enum StoryStatus {
|
||||||
|
|
@ -32,6 +33,7 @@ enum ClaudeJobStatus {
|
||||||
DONE
|
DONE
|
||||||
FAILED
|
FAILED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
|
SKIPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerifyResult {
|
enum VerifyResult {
|
||||||
|
|
@ -85,6 +87,7 @@ enum ClaudeJobKind {
|
||||||
TASK_IMPLEMENTATION
|
TASK_IMPLEMENTATION
|
||||||
IDEA_GRILL
|
IDEA_GRILL
|
||||||
IDEA_MAKE_PLAN
|
IDEA_MAKE_PLAN
|
||||||
|
PLAN_CHAT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IdeaLogType {
|
enum IdeaLogType {
|
||||||
|
|
@ -96,6 +99,11 @@ enum IdeaLogType {
|
||||||
JOB_EVENT
|
JOB_EVENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserQuestionStatus {
|
||||||
|
pending
|
||||||
|
answered
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
|
|
@ -104,10 +112,12 @@ model User {
|
||||||
is_demo Boolean @default(false)
|
is_demo Boolean @default(false)
|
||||||
bio String? @db.VarChar(160)
|
bio String? @db.VarChar(160)
|
||||||
bio_detail String? @db.VarChar(2000)
|
bio_detail String? @db.VarChar(2000)
|
||||||
|
must_reset_password Boolean @default(false)
|
||||||
avatar_data Bytes?
|
avatar_data Bytes?
|
||||||
active_product_id String?
|
active_product_id String?
|
||||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||||
idea_code_counter Int @default(0)
|
idea_code_counter Int @default(0)
|
||||||
|
min_quota_pct Int @default(20)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
|
|
@ -175,6 +185,7 @@ model Product {
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
ideas Idea[]
|
ideas Idea[]
|
||||||
|
idea_products IdeaProduct[]
|
||||||
|
|
||||||
@@unique([user_id, name])
|
@@unique([user_id, name])
|
||||||
@@unique([user_id, code])
|
@@unique([user_id, code])
|
||||||
|
|
@ -322,6 +333,11 @@ model ClaudeJob {
|
||||||
finished_at DateTime?
|
finished_at DateTime?
|
||||||
pushed_at DateTime?
|
pushed_at DateTime?
|
||||||
verify_result VerifyResult?
|
verify_result VerifyResult?
|
||||||
|
model_id String?
|
||||||
|
input_tokens Int?
|
||||||
|
output_tokens Int?
|
||||||
|
cache_read_tokens Int?
|
||||||
|
cache_write_tokens Int?
|
||||||
plan_snapshot String?
|
plan_snapshot String?
|
||||||
branch String?
|
branch String?
|
||||||
pr_url String?
|
pr_url String?
|
||||||
|
|
@ -339,15 +355,31 @@ model ClaudeJob {
|
||||||
@@map("claude_jobs")
|
@@map("claude_jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ModelPrice {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
model_id String @unique
|
||||||
|
input_price_per_1m Decimal @db.Decimal(12, 6)
|
||||||
|
output_price_per_1m Decimal @db.Decimal(12, 6)
|
||||||
|
cache_read_price_per_1m Decimal @db.Decimal(12, 6)
|
||||||
|
cache_write_price_per_1m Decimal @db.Decimal(12, 6)
|
||||||
|
currency String @default("USD")
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("model_prices")
|
||||||
|
}
|
||||||
|
|
||||||
model ClaudeWorker {
|
model ClaudeWorker {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
||||||
token_id String
|
token_id String
|
||||||
product_id String?
|
product_id String?
|
||||||
started_at DateTime @default(now())
|
started_at DateTime @default(now())
|
||||||
last_seen_at DateTime @default(now())
|
last_seen_at DateTime @default(now())
|
||||||
|
last_quota_pct Int?
|
||||||
|
last_quota_check_at DateTime?
|
||||||
|
|
||||||
@@unique([token_id])
|
@@unique([token_id])
|
||||||
@@index([user_id, last_seen_at])
|
@@index([user_id, last_seen_at])
|
||||||
|
|
@ -403,9 +435,11 @@ model Idea {
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
questions ClaudeQuestion[]
|
questions ClaudeQuestion[]
|
||||||
jobs ClaudeJob[]
|
jobs ClaudeJob[]
|
||||||
logs IdeaLog[]
|
logs IdeaLog[]
|
||||||
|
user_questions UserQuestion[]
|
||||||
|
secondary_products IdeaProduct[]
|
||||||
|
|
||||||
@@unique([user_id, code])
|
@@unique([user_id, code])
|
||||||
@@index([user_id, archived, status])
|
@@index([user_id, archived, status])
|
||||||
|
|
@ -413,6 +447,20 @@ model Idea {
|
||||||
@@map("ideas")
|
@@map("ideas")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model IdeaProduct {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
idea_id String
|
||||||
|
product_id String
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([idea_id, product_id])
|
||||||
|
@@index([product_id])
|
||||||
|
@@map("idea_products")
|
||||||
|
}
|
||||||
|
|
||||||
model IdeaLog {
|
model IdeaLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||||
|
|
@ -426,6 +474,23 @@ model IdeaLog {
|
||||||
@@map("idea_logs")
|
@@map("idea_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserQuestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
idea_id String
|
||||||
|
user_id String
|
||||||
|
question String @db.Text
|
||||||
|
answer String? @db.Text
|
||||||
|
status UserQuestionStatus @default(pending)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([idea_id, status])
|
||||||
|
@@index([user_id])
|
||||||
|
@@map("user_questions")
|
||||||
|
}
|
||||||
|
|
||||||
model LoginPairing {
|
model LoginPairing {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
secret_hash String
|
secret_hash String
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import { registerGetIdeaContextTool } from './tools/get-idea-context.js'
|
||||||
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
|
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
|
||||||
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
|
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
|
||||||
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
|
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
|
||||||
|
import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js'
|
||||||
|
import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js'
|
||||||
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
|
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
|
||||||
import { getAuth } from './auth.js'
|
import { getAuth } from './auth.js'
|
||||||
import { registerWorker } from './presence/worker.js'
|
import { registerWorker } from './presence/worker.js'
|
||||||
|
|
@ -89,6 +91,9 @@ async function main() {
|
||||||
registerUpdateIdeaGrillMdTool(server)
|
registerUpdateIdeaGrillMdTool(server)
|
||||||
registerUpdateIdeaPlanMdTool(server)
|
registerUpdateIdeaPlanMdTool(server)
|
||||||
registerLogIdeaDecisionTool(server)
|
registerLogIdeaDecisionTool(server)
|
||||||
|
// M13: worker quota-gate tools
|
||||||
|
registerGetWorkerSettingsTool(server)
|
||||||
|
registerWorkerHeartbeatTool(server)
|
||||||
registerImplementNextStoryPrompt(server)
|
registerImplementNextStoryPrompt(server)
|
||||||
|
|
||||||
// Presence bootstrap MUST run before server.connect — the stdio transport
|
// Presence bootstrap MUST run before server.connect — the stdio transport
|
||||||
|
|
|
||||||
33
src/tools/get-worker-settings.ts
Normal file
33
src/tools/get-worker-settings.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// MCP read-tool: lees de worker-instellingen van de geauthenticeerde user.
|
||||||
|
//
|
||||||
|
// Worker roept dit aan vóór elke wait_for_job iteratie zodat hij weet
|
||||||
|
// wanneer hij stand-by moet (pre-flight quota-gate).
|
||||||
|
//
|
||||||
|
// Auth: api-token; user_id afgeleid uit token. Demo mag.
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function registerGetWorkerSettingsTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'get_worker_settings',
|
||||||
|
{
|
||||||
|
title: 'Get worker settings',
|
||||||
|
description:
|
||||||
|
'Read the authenticated user\'s worker settings (min_quota_pct). Worker should call this each iteration before doing the pre-flight quota probe.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await getAuth()
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: auth.userId },
|
||||||
|
select: { min_quota_pct: true },
|
||||||
|
})
|
||||||
|
if (!user) return toolError('User not found')
|
||||||
|
return toolJson({ min_quota_pct: user.min_quota_pct })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/tools/worker-heartbeat.ts
Normal file
81
src/tools/worker-heartbeat.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// MCP write-tool: worker rapporteert quota-pct na pre-flight probe.
|
||||||
|
//
|
||||||
|
// Aanvulling op de bestaande achtergrond-heartbeat (die alleen last_seen_at
|
||||||
|
// elke 5s tickt). Deze tool wordt expliciet aangeroepen door de worker
|
||||||
|
// nadat scripts/worker-quota-probe.sh een quota-meting heeft gedaan.
|
||||||
|
//
|
||||||
|
// Updates ClaudeWorker.{last_quota_pct, last_quota_check_at, last_seen_at}
|
||||||
|
// en emit een pg_notify-event op 'scrum4me_changes' zodat de UI de
|
||||||
|
// stand-by-badge real-time kan tonen.
|
||||||
|
//
|
||||||
|
// Auth: api-token; demo mag niet (worker is geen demo-flow).
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { Client } from 'pg'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { requireWriteAccess } from '../auth.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
last_quota_pct: z.number().int().min(0).max(100),
|
||||||
|
last_quota_check_at: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerWorkerHeartbeatTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'worker_heartbeat',
|
||||||
|
{
|
||||||
|
title: 'Worker heartbeat with quota',
|
||||||
|
description:
|
||||||
|
'Report the worker\'s most recent rate-limit quota percentage to the server. Updates ClaudeWorker.last_quota_pct + last_quota_check_at. Emits a SSE event so the UI can show stand-by status. Forbidden for demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
async ({ last_quota_pct, last_quota_check_at }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
const checkAt = last_quota_check_at ? new Date(last_quota_check_at) : new Date()
|
||||||
|
|
||||||
|
const result = await prisma.claudeWorker.updateMany({
|
||||||
|
where: { token_id: auth.tokenId },
|
||||||
|
data: {
|
||||||
|
last_seen_at: new Date(),
|
||||||
|
last_quota_pct,
|
||||||
|
last_quota_check_at: checkAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
return toolError(
|
||||||
|
'Worker record not found — call register_worker first or wait for the next heartbeat tick',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg_notify zodat NavBar realtime kan updaten. Failure is non-fatal:
|
||||||
|
// de DB-write is al gebeurd, alleen de live-update mist dan.
|
||||||
|
try {
|
||||||
|
const pg = new Client({ connectionString: process.env.DATABASE_URL })
|
||||||
|
await pg.connect()
|
||||||
|
await pg.query('SELECT pg_notify($1, $2)', [
|
||||||
|
'scrum4me_changes',
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'worker_heartbeat',
|
||||||
|
user_id: auth.userId,
|
||||||
|
token_id: auth.tokenId,
|
||||||
|
last_quota_pct,
|
||||||
|
last_quota_check_at: checkAt.toISOString(),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
await pg.end()
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolJson({
|
||||||
|
ok: true,
|
||||||
|
last_quota_pct,
|
||||||
|
last_quota_check_at: checkAt.toISOString(),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
||||||
Subproject commit 2893573004cf1df28ff5ad69752ddcf8b66ddb1e
|
Subproject commit 555ed8fe89f0a3c9e52098fa0590ab8ba16e357a
|
||||||
Loading…
Add table
Add a link
Reference in a new issue