diff --git a/db/custom.db b/db/custom.db index 84efc8a..4dcad71 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/src/app/api/aos/api-tokens/route.ts b/src/app/api/aos/api-tokens/route.ts index 92e154d..3750362 100644 --- a/src/app/api/aos/api-tokens/route.ts +++ b/src/app/api/aos/api-tokens/route.ts @@ -6,9 +6,16 @@ export const dynamic = 'force-dynamic' const ALL_SCOPES = [ 'crm.company:read', + 'crm.deal:read', 'finance.invoice:read', 'licenses.license:read', + 'leads:read', + 'leads:write', + 'support:read', + 'support:write', 'webhooks.test', + 'mcp:read', + 'mcp:write', ] // GET /api/aos/api-tokens — list tokens (without revealing the secret) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts new file mode 100644 index 0000000..8e22411 --- /dev/null +++ b/src/app/api/mcp/route.ts @@ -0,0 +1,343 @@ +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 } + }, + }, +] + +// 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' } }, + }, + }) +} diff --git a/src/components/aos/views/api.tsx b/src/components/aos/views/api.tsx index 9c2c0be..15a3e71 100644 --- a/src/components/aos/views/api.tsx +++ b/src/components/aos/views/api.tsx @@ -378,6 +378,43 @@ const sig = createHmac('sha256', WEBHOOK_SECRET)
  • • Alle API-Zugriffe werden im Audit-Log protokolliert
  • + + {/* MCP Server */} +
    +

    MCP Server (Model Context Protocol)

    +

    + AOS exposes its data as MCP tools (PRD §8) so external agents (Claude Desktop, ZeroClaw) can read agency data and perform scoped actions. +

    +
    {`# MCP endpoint (JSON-RPC 2.0)
    +POST /api/mcp
    +Authorization: Bearer aos_  # requires mcp:read or mcp:write scope
    +
    +# Available MCP tools:
    +search_companies   # crm.company:read    — search CRM
    +get_pipeline       # crm.deal:read        — sales pipeline
    +get_invoice        # finance.invoice:read — invoice by number
    +list_leads         # leads:read           — Leads Finder queue
    +get_dashboard      # mcp:read             — agency KPIs
    +list_tickets       # support:read         — support inbox
    +approve_lead       # leads:write          — approve a lead (write)
    +create_ticket      # support:write        — create ticket (write)`}
    +
    {`# Example: call search_companies via MCP
    +curl -X POST https://aos.nordlicht.digital/api/mcp \\
    +  -H "Authorization: Bearer aos_" \\
    +  -H "Content-Type: application/json" \\
    +  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_companies","arguments":{"query":"Hamburg"}},"id":1}'`}
    +

    + Claude Desktop config (claude_desktop_config.json): +

    +
    {`{
    +  "mcpServers": {
    +    "aos": {
    +      "url": "https://aos.nordlicht.digital/api/mcp",
    +      "headers": { "Authorization": "Bearer aos_" }
    +    }
    +  }
    +}`}
    +
    diff --git a/src/lib/seed.ts b/src/lib/seed.ts index dc25645..f07c675 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -992,7 +992,7 @@ Monatlich: Buchungsstapel an Steuerberater.`, // --- API Tokens + Webhooks (real crypto, hashed at rest) ----------------- const crypto = await import('crypto') const apiTokens = [ - { name: 'ZeroClaw Agent (read-only)', scopes: ['crm.company:read', 'finance.invoice:read', 'licenses.license:read'] }, + { name: 'ZeroClaw Agent (MCP)', scopes: ['mcp:read', 'mcp:write', 'crm.company:read', 'crm.deal:read', 'finance.invoice:read', 'leads:read', 'leads:write', 'support:read', 'support:write'] }, { name: 'DATEV Export-Skript', scopes: ['finance.invoice:read'] }, { name: 'WordPress Update-Server', scopes: ['licenses.license:read'] }, ]