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 = [
|
const ALL_SCOPES = [
|
||||||
'crm.company:read',
|
'crm.company:read',
|
||||||
'crm.deal:read',
|
'crm.deal:read',
|
||||||
|
'crm.deal:write',
|
||||||
'finance.invoice:read',
|
'finance.invoice:read',
|
||||||
|
'finance.invoice:write',
|
||||||
|
'finance:write',
|
||||||
'licenses.license:read',
|
'licenses.license:read',
|
||||||
'leads:read',
|
'leads:read',
|
||||||
'leads:write',
|
'leads:write',
|
||||||
'support:read',
|
'support:read',
|
||||||
'support:write',
|
'support:write',
|
||||||
|
'projects:write',
|
||||||
'webhooks.test',
|
'webhooks.test',
|
||||||
'mcp:read',
|
'mcp:read',
|
||||||
'mcp:write',
|
'mcp:write',
|
||||||
|
|||||||
@@ -240,6 +240,242 @@ const TOOLS: McpTool[] = [
|
|||||||
return { id: ticket.id, number: ticket.number, status: ticket.status }
|
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
|
// JSON-RPC handler
|
||||||
|
|||||||
Reference in new issue
Block a user