115e52dd-042f-444c-9ed4-9bfde9257c45
This commit is contained in:
1 parent
7640b78975
commit
37cb23d245
5 files changed
+388
-1
No files matched your search
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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 "<module>:read",
|
||||
// write tools need "<module>:write".
|
||||
|
||||
interface McpTool {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: Record<string, unknown>
|
||||
requiredScopes: string[]
|
||||
handler: (args: Record<string, unknown>, token: { id: string; name: string; scopes: string[] }) => Promise<unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
|
||||
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<string, unknown>
|
||||
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_<token> (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' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -378,6 +378,43 @@ const sig = createHmac('sha256', WEBHOOK_SECRET)
|
||||
<li>• Alle API-Zugriffe werden im Audit-Log protokolliert</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* MCP Server */}
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<p className="font-semibold">MCP Server (Model Context Protocol)</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
AOS exposes its data as MCP tools (PRD §8) so external agents (Claude Desktop, ZeroClaw) can read agency data and perform scoped actions.
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`# MCP endpoint (JSON-RPC 2.0)
|
||||
POST /api/mcp
|
||||
Authorization: Bearer aos_<token> # 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)`}</pre>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`# Example: call search_companies via MCP
|
||||
curl -X POST https://aos.nordlicht.digital/api/mcp \\
|
||||
-H "Authorization: Bearer aos_<token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_companies","arguments":{"query":"Hamburg"}},"id":1}'`}</pre>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Claude Desktop config (<code className="font-mono">claude_desktop_config.json</code>):
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`{
|
||||
"mcpServers": {
|
||||
"aos": {
|
||||
"url": "https://aos.nordlicht.digital/api/mcp",
|
||||
"headers": { "Authorization": "Bearer aos_<token>" }
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
+1
-1
@@ -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'] },
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user