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'
580 lines
25 KiB
TypeScript
Executable File
580 lines
25 KiB
TypeScript
Executable File
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 }
|
|
},
|
|
},
|
|
// --- 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<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' } },
|
|
},
|
|
})
|
|
}
|