115e52dd-042f-444c-9ed4-9bfde9257c45

This commit is contained in:
Z User committed 2026-10-26 23:40:17 +00:00
1 parent 7640b78975
commit 37cb23d245
5 files changed
+388 -1

No files matched your search

+7
View File
@@ -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)
+343
View File
@@ -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' } },
},
})
}