feat: MCP admin-level tools — Claude Code can manage the full agency
7 new WRITE MCP tools (15 total): - create_deal: create deals with value, stage, probability - update_deal_stage: move deals through the pipeline - create_task: create tasks with priority, assignee, estimate - log_time: log billable time entries with rate - create_invoice: create draft invoices with line items - finalize_invoice: lock + set to sent (GoBD Festschreibung) - run_recurring: trigger recurring invoice scheduler + license suspend Added scopes: crm.deal:write, finance.invoice:write, finance:write, projects:write Claude Code with an admin token can now: - Search companies, view pipeline, check invoices (read) - Create deals, tasks, log time, create+finalize invoices (write) - Run the recurring scheduler (admin) - Approve leads, create tickets (existing write tools) All actions audit-logged with actor 'mcp-agent'
This commit is contained in:
1 parent
01214ac5db
commit
7142ffbe6c
2 files changed
+240
No files matched your search
@@ -7,12 +7,16 @@ export const dynamic = 'force-dynamic'
|
||||
const ALL_SCOPES = [
|
||||
'crm.company:read',
|
||||
'crm.deal:read',
|
||||
'crm.deal:write',
|
||||
'finance.invoice:read',
|
||||
'finance.invoice:write',
|
||||
'finance:write',
|
||||
'licenses.license:read',
|
||||
'leads:read',
|
||||
'leads:write',
|
||||
'support:read',
|
||||
'support:write',
|
||||
'projects:write',
|
||||
'webhooks.test',
|
||||
'mcp:read',
|
||||
'mcp:write',
|
||||
|
||||
@@ -240,6 +240,242 @@ const TOOLS: McpTool[] = [
|
||||
return { id: ticket.id, number: ticket.number, status: ticket.status }
|
||||
},
|
||||
},
|
||||
// --- Admin write tools (agent can manage the full agency) ---
|
||||
{
|
||||
name: 'create_deal',
|
||||
description: 'Create a new deal in the CRM pipeline. Admin-level: full deal creation with value, stage, probability.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyId: { type: 'string', description: 'Company ID' },
|
||||
title: { type: 'string', description: 'Deal title' },
|
||||
valueCents: { type: 'integer', description: 'Deal value in euro cents (e.g. 980000 = 9800.00 €)' },
|
||||
stage: { type: 'string', enum: ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'], default: 'lead' },
|
||||
probability: { type: 'integer', description: 'Win probability 0-100', default: 10 },
|
||||
},
|
||||
required: ['companyId', 'title', 'valueCents'],
|
||||
},
|
||||
requiredScopes: ['crm.deal:write', 'mcp:write'],
|
||||
handler: async (args) => {
|
||||
const deal = await db.deal.create({
|
||||
data: {
|
||||
companyId: String(args.companyId),
|
||||
title: String(args.title),
|
||||
valueCents: Math.max(0, Math.round(Number(args.valueCents))),
|
||||
stage: String(args.stage ?? 'lead'),
|
||||
probability: Number(args.probability ?? 10),
|
||||
},
|
||||
})
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.deal:create', entity: 'Deal', entityId: deal.id,
|
||||
after: JSON.stringify({ title: deal.title, stage: deal.stage, valueCents: deal.valueCents }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { id: deal.id, title: deal.title, stage: deal.stage, valueCents: deal.valueCents }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_task',
|
||||
description: 'Create a task in a project. Admin-level: assign tasks with priority and estimate.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
priority: { type: 'string', enum: ['low', 'medium', 'high'], default: 'medium' },
|
||||
assignee: { type: 'string' },
|
||||
estimateMin: { type: 'integer', description: 'Estimated minutes' },
|
||||
},
|
||||
required: ['projectId', 'title'],
|
||||
},
|
||||
requiredScopes: ['projects:write', 'mcp:write'],
|
||||
handler: async (args) => {
|
||||
const task = await db.task.create({
|
||||
data: {
|
||||
projectId: String(args.projectId),
|
||||
title: String(args.title),
|
||||
priority: String(args.priority ?? 'medium'),
|
||||
assignee: args.assignee ? String(args.assignee) : null,
|
||||
estimateMin: args.estimateMin ? Number(args.estimateMin) : null,
|
||||
status: 'todo',
|
||||
},
|
||||
})
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.task:create', entity: 'Task', entityId: task.id,
|
||||
after: JSON.stringify({ title: task.title, projectId: task.projectId }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { id: task.id, title: task.title, status: task.status }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'log_time',
|
||||
description: 'Log time on a project. Admin-level: billable time entry with rate.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectId: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
minutes: { type: 'integer', description: 'Time in minutes' },
|
||||
billable: { type: 'boolean', default: true },
|
||||
rateCentsPerHour: { type: 'integer', description: 'Rate in cents/hour (e.g. 9500 = 95.00 €/h)' },
|
||||
},
|
||||
required: ['projectId', 'description', 'minutes'],
|
||||
},
|
||||
requiredScopes: ['projects:write', 'mcp:write'],
|
||||
handler: async (args) => {
|
||||
const minutes = Math.max(1, Math.round(Number(args.minutes)))
|
||||
const entry = await db.timeEntry.create({
|
||||
data: {
|
||||
projectId: String(args.projectId),
|
||||
description: String(args.description),
|
||||
minutes,
|
||||
billable: args.billable !== false,
|
||||
rateCentsPerHour: args.rateCentsPerHour ? Number(args.rateCentsPerHour) : null,
|
||||
date: new Date(),
|
||||
},
|
||||
})
|
||||
await db.project.update({ where: { id: String(args.projectId) }, data: { spentHours: { increment: Math.round(minutes / 60) } } })
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.time:log', entity: 'TimeEntry', entityId: entry.id,
|
||||
after: JSON.stringify({ minutes, projectId: args.projectId }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { id: entry.id, minutes, billable: entry.billable }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_invoice',
|
||||
description: 'Create a draft invoice. Admin-level: full invoice creation with line items.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyId: { type: 'string' },
|
||||
lines: { type: 'array', items: { type: 'object', properties: {
|
||||
description: { type: 'string' }, quantity: { type: 'integer', default: 1 }, unitCents: { type: 'integer' }
|
||||
}, required: ['description', 'unitCents'] } },
|
||||
type: { type: 'string', enum: ['final', 'partial', 'recurring'], default: 'final' },
|
||||
},
|
||||
required: ['companyId', 'lines'],
|
||||
},
|
||||
requiredScopes: ['finance.invoice:write', 'mcp:write'],
|
||||
handler: async (args) => {
|
||||
const lines = args.lines as { description: string; quantity: number; unitCents: number }[]
|
||||
if (!Array.isArray(lines) || lines.length === 0) return { error: 'lines required' }
|
||||
const netCents = lines.reduce((s, l) => s + l.unitCents * (l.quantity ?? 1), 0)
|
||||
const taxBp = 1900
|
||||
const grossCents = Math.round(netCents * (1 + taxBp / 10000))
|
||||
const year = new Date().getFullYear()
|
||||
const count = await db.invoice.count({ where: { number: { startsWith: `RE-${year}-` } } })
|
||||
const number = `RE-${year}-${String(count + 1).padStart(4, '0')}`
|
||||
const dueDate = new Date(); dueDate.setDate(dueDate.getDate() + 14)
|
||||
const invoice = await db.invoice.create({
|
||||
data: { number, companyId: String(args.companyId), status: 'draft', type: String(args.type ?? 'final'),
|
||||
issueDate: new Date(), dueDate, netCents, taxBp, grossCents, zugferd: false, xrechnung: false, datevExported: false },
|
||||
})
|
||||
for (const l of lines) {
|
||||
await db.invoiceLine.create({ data: { invoiceId: invoice.id, description: l.description,
|
||||
quantity: l.quantity ?? 1, unitCents: l.unitCents, totalCents: l.unitCents * (l.quantity ?? 1) } })
|
||||
}
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.invoice:create', entity: 'Invoice', entityId: invoice.id,
|
||||
after: JSON.stringify({ number, netCents, grossCents, lineCount: lines.length }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { id: invoice.id, number, netCents, grossCents, lineCount: lines.length }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'finalize_invoice',
|
||||
description: 'Finalize a draft invoice (lock + set status to sent). Admin-level: GoBD Festschreibung.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoiceId: { type: 'string' },
|
||||
zugferd: { type: 'boolean', default: true, description: 'Generate ZUGFeRD XML' },
|
||||
xrechnung: { type: 'boolean', default: false, description: 'Generate XRechnung for public sector' },
|
||||
},
|
||||
required: ['invoiceId'],
|
||||
},
|
||||
requiredScopes: ['finance.invoice:write', 'mcp:write'],
|
||||
handler: async (args) => {
|
||||
const inv = await db.invoice.findUnique({ where: { id: String(args.invoiceId) } })
|
||||
if (!inv) return { error: 'invoice not found' }
|
||||
if (inv.status !== 'draft') return { error: 'only drafts can be finalized' }
|
||||
const updated = await db.invoice.update({
|
||||
where: { id: inv.id },
|
||||
data: { status: 'sent', zugferd: args.zugferd !== false, xrechnung: args.xrechnung === true, issueDate: new Date() },
|
||||
})
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.invoice:finalize', entity: 'Invoice', entityId: inv.id,
|
||||
after: JSON.stringify({ number: updated.number, status: 'sent', zugferd: updated.zugferd }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { id: updated.id, number: updated.number, status: updated.status, zugferd: updated.zugferd }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'run_recurring',
|
||||
description: 'Run the recurring invoice scheduler. Admin-level: generates monthly invoices + suspends licenses at dunning level 3.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
requiredScopes: ['finance:write', 'mcp:write'],
|
||||
handler: async () => {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const contracts = await db.contract.findMany({
|
||||
where: { status: 'active', type: { in: ['wartung-sla', 'dienstvertrag'] }, valueCents: { gt: 0 } },
|
||||
include: { company: true },
|
||||
})
|
||||
const existing = await db.invoice.findMany({ where: { type: 'recurring', issueDate: { gte: monthStart } }, select: { companyId: true, netCents: true } })
|
||||
const toInvoice = contracts.filter((c) => !existing.some((i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100))
|
||||
let count = await db.invoice.count({ where: { number: { startsWith: `RE-${now.getFullYear()}-` } } })
|
||||
const created: string[] = []
|
||||
for (const c of toInvoice) {
|
||||
count++
|
||||
const netCents = Math.round(c.valueCents / 12)
|
||||
const grossCents = Math.round(netCents * 1.19)
|
||||
const dueDate = new Date(); dueDate.setDate(dueDate.getDate() + 14)
|
||||
const inv = await db.invoice.create({
|
||||
data: { number: `RE-${now.getFullYear()}-${String(count).padStart(4, '0')}`, companyId: c.companyId,
|
||||
status: 'draft', type: 'recurring', issueDate: now, dueDate, netCents, taxBp: 1900, grossCents, zugferd: false, xrechnung: false, datevExported: false },
|
||||
})
|
||||
await db.invoiceLine.create({ data: { invoiceId: inv.id, description: `${c.title} — ${now.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`, quantity: 1, unitCents: netCents, totalCents: netCents } })
|
||||
created.push(inv.number)
|
||||
}
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.recurring:run', entity: 'Invoice', entityId: 'batch',
|
||||
after: JSON.stringify({ created: created.length, month: monthStart.toISOString().slice(0, 7) }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { invoicesCreated: created.length, invoiceNumbers: created }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_deal_stage',
|
||||
description: 'Move a deal to a different pipeline stage. Admin-level: pipeline management.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
dealId: { type: 'string' },
|
||||
stage: { type: 'string', enum: ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'] },
|
||||
lossReason: { type: 'string', description: 'Required if stage is lost' },
|
||||
},
|
||||
required: ['dealId', 'stage'],
|
||||
},
|
||||
requiredScopes: ['crm.deal:write', 'mcp:write'],
|
||||
handler: async (args) => {
|
||||
const deal = await db.deal.update({
|
||||
where: { id: String(args.dealId) },
|
||||
data: { stage: String(args.stage), lossReason: args.lossReason ? String(args.lossReason) : null },
|
||||
})
|
||||
await db.auditLog.create({
|
||||
data: { actor: 'mcp-agent', action: 'mcp.deal:updateStage', entity: 'Deal', entityId: deal.id,
|
||||
after: JSON.stringify({ stage: deal.stage }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2) },
|
||||
})
|
||||
return { id: deal.id, stage: deal.stage }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// JSON-RPC handler
|
||||
|
||||
Reference in new issue
Block a user