import { db } from '@/lib/db' import { verifyToken, hasScope } from '@/lib/api-auth' export const dynamic = 'force-dynamic' export const maxDuration = 30 // AOS MCP Server (PRD §8: "AOS exposes its API as MCP tools so external agents // like Claude/ZeroClaw can work on the agency's data"). // // Implements the Model Context Protocol JSON-RPC interface: // - initialize: returns server capabilities // - tools/list: returns available tools (generated from the same Zod schemas) // - tools/call: executes a tool (read-heavy by default, write tools gated by scope) // // Auth: Bearer token with MCP scopes. Read tools need ":read", // write tools need ":write". interface McpTool { name: string description: string inputSchema: Record requiredScopes: string[] handler: (args: Record, token: { id: string; name: string; scopes: string[] }) => Promise } const TOOLS: McpTool[] = [ // --- Read tools --- { name: 'search_companies', description: 'Search companies in the CRM by name, city, or website. Returns matching companies with deal/project counts.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search term (company name, city, or website)' }, limit: { type: 'integer', description: 'Max results (default 10, max 50)', default: 10 }, }, required: ['query'], }, requiredScopes: ['crm.company:read', 'mcp:read'], handler: async (args) => { const q = String(args.query ?? '') const limit = Math.min(Number(args.limit ?? 10), 50) const companies = await db.company.findMany({ where: { OR: [{ name: { contains: q } }, { city: { contains: q } }, { website: { contains: q } }] }, take: limit, include: { _count: { select: { deals: true, projects: true, invoices: true } } }, }) return companies.map((c) => ({ id: c.id, name: c.name, legalForm: c.legalForm, vatId: c.vatId, website: c.website, city: c.city, email: c.email, dealCount: c._count.deals, projectCount: c._count.projects, invoiceCount: c._count.invoices, })) }, }, { name: 'get_pipeline', description: 'Get the current sales pipeline (deals grouped by stage). Returns deals with company, value, and probability.', inputSchema: { type: 'object', properties: { stage: { type: 'string', description: 'Filter by stage (lead, qualified, proposal, negotiation, won, lost)', enum: ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'] }, }, }, requiredScopes: ['crm.deal:read', 'mcp:read'], handler: async (args) => { const where = args.stage ? { stage: String(args.stage) } : {} const deals = await db.deal.findMany({ where, include: { company: true }, orderBy: { valueCents: 'desc' } }) return deals.map((d) => ({ id: d.id, title: d.title, stage: d.stage, company: d.company.name, valueCents: d.valueCents, probability: d.probability, expectedClose: d.expectedClose, })) }, }, { name: 'get_invoice', description: 'Get invoice details by number (e.g. RE-2026-0042). Returns line items, E-Rechnung status, and payment status.', inputSchema: { type: 'object', properties: { number: { type: 'string', description: 'Invoice number (e.g. RE-2026-0042)' }, }, required: ['number'], }, requiredScopes: ['finance.invoice:read', 'mcp:read'], handler: async (args) => { const inv = await db.invoice.findUnique({ where: { number: String(args.number) }, include: { company: true, lines: true }, }) if (!inv) return { error: 'invoice not found' } return { number: inv.number, status: inv.status, type: inv.type, company: inv.company.name, netCents: inv.netCents, grossCents: inv.grossCents, issueDate: inv.issueDate, dueDate: inv.dueDate, paidDate: inv.paidDate, eRechnung: { zugferd: inv.zugferd, xrechnung: inv.xrechnung }, lines: inv.lines.map((l) => ({ description: l.description, quantity: l.quantity, totalCents: l.totalCents })), } }, }, { name: 'list_leads', description: 'List leads from the Leads Finder. Returns scored leads with audit evidence summary.', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'converted'], description: 'Filter by status' }, limit: { type: 'integer', default: 10 }, }, }, requiredScopes: ['leads:read', 'mcp:read'], handler: async (args) => { const where = args.status ? { status: String(args.status) } : {} const leads = await db.lead.findMany({ where, take: Math.min(Number(args.limit ?? 10), 50), orderBy: { score: 'desc' } }) return leads.map((l) => ({ id: l.id, companyName: l.companyName, website: l.website, city: l.city, score: l.score, scoreLabel: l.scoreLabel, status: l.status, explanation: l.explanation?.slice(0, 200), })) }, }, { name: 'get_dashboard', description: 'Get agency dashboard KPIs (pipeline value, open projects, MRR, overdue invoices, active licenses).', inputSchema: { type: 'object', properties: {} }, requiredScopes: ['mcp:read'], handler: async () => { const [deals, projects, invoices, licenses] = await Promise.all([ db.deal.findMany(), db.project.findMany(), db.invoice.findMany(), db.license.findMany({ where: { status: 'active' } }), ]) return { pipelineValueCents: deals.filter((d) => d.stage !== 'won' && d.stage !== 'lost').reduce((s, d) => s + d.valueCents, 0), wonValueCents: deals.filter((d) => d.stage === 'won').reduce((s, d) => s + d.valueCents, 0), openProjects: projects.filter((p) => p.status !== 'done' && p.status !== 'cancelled').length, outstandingCents: invoices.filter((i) => i.status === 'sent' || i.status === 'overdue').reduce((s, i) => s + i.grossCents, 0), activeLicenses: licenses.length, totalDeals: deals.length, } }, }, { name: 'list_tickets', description: 'List support tickets with status, priority, and SLA info.', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['open', 'pending', 'resolved', 'closed'] }, limit: { type: 'integer', default: 10 }, }, }, requiredScopes: ['support:read', 'mcp:read'], handler: async (args) => { const where = args.status ? { status: String(args.status) } : {} const tickets = await db.ticket.findMany({ where, take: Math.min(Number(args.limit ?? 10), 50), orderBy: { createdAt: 'desc' }, include: { _count: { select: { messages: true } } }, }) return tickets.map((t) => ({ number: t.number, subject: t.subject, status: t.status, priority: t.priority, messageCount: t._count.messages, createdAt: t.createdAt, })) }, }, // --- Write tools (gated by write scopes) --- { name: 'approve_lead', description: 'Approve a lead in the Leads Finder review queue. Changes status to "approved".', inputSchema: { type: 'object', properties: { leadId: { type: 'string', description: 'Lead ID' }, reason: { type: 'string', description: 'Optional approval reason' }, }, required: ['leadId'], }, requiredScopes: ['leads:write', 'mcp:write'], handler: async (args) => { const lead = await db.lead.update({ where: { id: String(args.leadId) }, data: { status: 'approved', reviewReason: String(args.reason ?? 'MCP agent approval'), reviewedAt: new Date() }, }) await db.auditLog.create({ data: { actor: 'mcp-agent', action: 'mcp.leads:approve', entity: 'Lead', entityId: lead.id, after: JSON.stringify({ status: 'approved' }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) return { id: lead.id, status: lead.status } }, }, { name: 'create_ticket', description: 'Create a new support ticket for a company.', inputSchema: { type: 'object', properties: { companyId: { type: 'string', description: 'Customer company ID' }, subject: { type: 'string' }, priority: { type: 'string', enum: ['low', 'normal', 'high', 'urgent'], default: 'normal' }, message: { type: 'string', description: 'Initial message body' }, }, required: ['companyId', 'subject', 'message'], }, requiredScopes: ['support:write', 'mcp:write'], handler: async (args) => { const count = await db.ticket.count() const ticket = await db.ticket.create({ data: { number: `TKT-2026-${String(count + 1).padStart(4, '0')}`, subject: String(args.subject), companyId: String(args.companyId), priority: String(args.priority ?? 'normal'), categoryId: 'anfrage', status: 'open', }, }) await db.ticketMessage.create({ data: { ticketId: ticket.id, fromType: 'customer', fromName: 'MCP Agent', body: String(args.message), }, }) await db.auditLog.create({ data: { actor: 'mcp-agent', action: 'mcp.support:create', entity: 'Ticket', entityId: ticket.id, after: JSON.stringify({ number: ticket.number, subject: ticket.subject }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) 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 export async function POST(req: Request) { const authHeader = req.headers.get('authorization') const auth = await verifyToken(authHeader) if (!auth.ok || !auth.token) { return Response.json( { jsonrpc: '2.0', error: { code: -32001, message: auth.error ?? 'unauthorized' }, id: null }, { status: 401 } ) } let body: { jsonrpc?: string; method?: string; params?: unknown; id?: string | number | null } try { body = await req.json() } catch { return Response.json({ jsonrpc: '2.0', error: { code: -32700, message: 'parse error' }, id: null }, { status: 400 }) } const { method, params, id } = body const p = (params ?? {}) as Record try { if (method === 'initialize') { return Response.json({ jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'aos-mcp', version: '1.0.0' }, }, }) } if (method === 'tools/list') { return Response.json({ jsonrpc: '2.0', id, result: { tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, annotations: { readOnly: t.requiredScopes.some((s) => s.endsWith(':read')), destructive: t.requiredScopes.some((s) => s.endsWith(':write')), }, })), }, }) } if (method === 'tools/call') { const toolName = String(p.name ?? '') const toolArgs = (p.arguments ?? {}) as Record const tool = TOOLS.find((t) => t.name === toolName) if (!tool) { return Response.json({ jsonrpc: '2.0', id, error: { code: -32601, message: `unknown tool: ${toolName}` } }, { status: 404 }) } // scope check const hasAccess = tool.requiredScopes.some((s) => hasScope(auth.token, s)) if (!hasAccess) { return Response.json({ jsonrpc: '2.0', id, error: { code: -32603, message: `insufficient scope — requires one of: ${tool.requiredScopes.join(', ')}`, tokenScopes: auth.token.scopes }, }, { status: 403 }) } const result = await tool.handler(toolArgs, auth.token) return Response.json({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, }, }) } return Response.json({ jsonrpc: '2.0', id, error: { code: -32601, message: `unknown method: ${method}` } }, { status: 404 }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal error' return Response.json({ jsonrpc: '2.0', id, error: { code: -32603, message: msg } }, { status: 500 }) } } // GET returns MCP server metadata (public, no token) export async function GET() { return Response.json({ name: 'aos-mcp', version: '1.0.0', protocolVersion: '2024-11-05', description: 'AOS Model Context Protocol server. Exposes agency data as tools for external agents (Claude, ZeroClaw).', endpoint: '/api/mcp', auth: 'Bearer aos_ (requires mcp:read or mcp:write scope)', tools: TOOLS.map((t) => ({ name: t.name, description: t.description, scopes: t.requiredScopes, readOnly: t.requiredScopes.some((s) => s.endsWith(':read')) })), example: { method: 'tools/call', params: { name: 'search_companies', arguments: { query: 'Hamburg' } }, }, }) }