From 7142ffbe6cbb233abbcf86f02552f8d099996056 Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 19:50:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20MCP=20admin-level=20tools=20=E2=80=94?= =?UTF-8?q?=20Claude=20Code=20can=20manage=20the=20full=20agency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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' --- src/app/api/aos/api-tokens/route.ts | 4 + src/app/api/mcp/route.ts | 236 ++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) diff --git a/src/app/api/aos/api-tokens/route.ts b/src/app/api/aos/api-tokens/route.ts index abc1741..c41dba0 100755 --- a/src/app/api/aos/api-tokens/route.ts +++ b/src/app/api/aos/api-tokens/route.ts @@ -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', diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 8e22411..c4a4403 100755 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -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