feat: Claude Code auto-detect (.mcp.json) + 20 MCP tools + health endpoint
Claude Code integration: - .mcp.json at repo root for auto-detection (uses seed admin token) - CLAUDE.md project instructions (commands, architecture, rules) - Pre-seeded admin token: aos_admin_seed_token_for_claude_code_2026 - Scopes: all read + all write + mcp:read + mcp:write - Works out of the box after bun run src/lib/seed.ts 5 new MCP read tools (20 total: 11 read + 9 write): - get_buchhaltung: SKR04 Konten, Journal, Saldenliste, EÜR summary - get_anlagen: Anlagenverzeichnis with AfA, book values, GWG - get_kassenbuch: Cash book entries, running balance - get_belege: Receipts with SHA-256 content hashes - get_finanzamt: VAT returns, Jahreserklärungen, Jahresarchive Fix: api-auth regex updated to allow underscores in tokens (aos_[a-z0-9_]+) Health endpoint: GET /api/health (DB connection + version + uptime)
This commit is contained in:
1 parent
1a3241971c
commit
ab9fd10b5e
7 files changed
+231
-1
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"aos": {
|
||||||
|
"url": "http://localhost:3000/api/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer aos_admin_seed_token_for_claude_code_2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# AOS — Agency Operating System
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Self-hosted platform for German software agencies. CRM, Leads Finder, Projects, Licenses, Contracts, Expenses, Finance, full Accounting (SKR04, GoBD, ELSTER, DATEV), Client Portal, MCP Server, PWA.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Framework**: Next.js 16 (App Router, Turbopack)
|
||||||
|
- **Language**: TypeScript 5 (strict)
|
||||||
|
- **Database**: Prisma ORM (SQLite for dev)
|
||||||
|
- **UI**: shadcn/ui + Tailwind CSS 4 (monochrome design)
|
||||||
|
- **State**: Zustand + TanStack Query patterns
|
||||||
|
- **AI**: z-ai-web-dev-sdk (server-side only)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
```bash
|
||||||
|
bun run dev # Start dev server (port 3000)
|
||||||
|
bun run lint # ESLint check
|
||||||
|
bun run db:push # Push Prisma schema to DB
|
||||||
|
bun run db:generate # Generate Prisma Client
|
||||||
|
bun run src/lib/seed.ts # Seed database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **Single deployable** (modular monolith per PRD D1)
|
||||||
|
- **Money**: integer cents, tax in basis points. No floats.
|
||||||
|
- **GoBD**: immutable after Festschreibung, corrections via Storno (new row)
|
||||||
|
- **Audit**: hash-chain on all business operations
|
||||||
|
- **Auth**: Bearer token (SHA-256 hashed at rest) with scoped permissions
|
||||||
|
|
||||||
|
## Key Directories
|
||||||
|
```
|
||||||
|
src/app/api/aos/ # Internal API routes
|
||||||
|
src/app/api/v1/ # Public REST API (scoped tokens)
|
||||||
|
src/app/api/mcp/ # MCP Server (JSON-RPC for Claude Code)
|
||||||
|
src/components/aos/ # All UI components
|
||||||
|
src/lib/ # Shared logic (db, format, i18n, api-auth, webhook)
|
||||||
|
prisma/ # Schema + migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
- Endpoint: `POST /api/mcp` (JSON-RPC 2.0)
|
||||||
|
- 15 tools: 7 read + 8 write
|
||||||
|
- Auth: Bearer token with `mcp:read` or `mcp:write` scope
|
||||||
|
- All write actions audit-logged with actor `mcp-agent`
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
- GoBD: Festschreibung (SHA-256 hash), immutable Buchungssätze, 8/6 year retention
|
||||||
|
- DSGVO: Art. 15/17/20/30, consent log, data export with checksum
|
||||||
|
- E-Rechnung: ZUGFeRD/XRechnung (EN 16931)
|
||||||
|
- SKR04 Kontenrahmen, EÜR, UStVA, DATEV EXTF export
|
||||||
|
- ELSTER: UStVA XML (ERiC requires cert in production)
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
- No mocks in production code — real implementations only
|
||||||
|
- No `any` types — all interfaces explicitly typed
|
||||||
|
- All API routes have try/catch error handling
|
||||||
|
- All icon-only buttons have `aria-label` (WCAG 2.1 AA)
|
||||||
|
- Client Portal: no agency-internal details visible to clients
|
||||||
|
- German UI by default (i18n: DE/EN/TR)
|
||||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/health — system health check
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const companyCount = await db.company.count()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0.0',
|
||||||
|
database: { connected: true, companyCount },
|
||||||
|
uptime: process.uptime ? `${Math.round(process.uptime())}s` : 'n/a',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: { connected: false },
|
||||||
|
}, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,127 @@ const TOOLS: McpTool[] = [
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Accounting read tools ---
|
||||||
|
{
|
||||||
|
name: 'get_buchhaltung',
|
||||||
|
description: 'Get SKR04 accounting data: Konten, Journal (Buchungssätze), Saldenliste, EÜR summary. Full doppelte Buchführung overview.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
requiredScopes: ['mcp:read'],
|
||||||
|
handler: async () => {
|
||||||
|
const [konten, buchungen, euerEntries] = await Promise.all([
|
||||||
|
db.konto.findMany({ orderBy: { nummer: 'asc' } }),
|
||||||
|
db.buchungssatz.findMany({ take: 50, orderBy: { belegdatum: 'desc' }, include: { sollKonto: true, habenKonto: true } }),
|
||||||
|
db.euerEntry.findMany({ where: { jahr: 2026 }, orderBy: { zeile: 'asc' } }),
|
||||||
|
])
|
||||||
|
const einnahmen = euerEntries.filter((e) => e.art === 'einnahme').reduce((s, e) => s + e.betragCents, 0)
|
||||||
|
const ausgaben = euerEntries.filter((e) => e.art === 'ausgabe').reduce((s, e) => s + e.betragCents, 0)
|
||||||
|
return {
|
||||||
|
kontenCount: konten.length,
|
||||||
|
buchungenCount: buchungen.length,
|
||||||
|
festgeschrieben: buchungen.filter((b) => b.festgeschrieben).length,
|
||||||
|
euer: { einnahmen, ausgaben, gewinn: einnahmen - ausgaben },
|
||||||
|
journal: buchungen.slice(0, 10).map((b) => ({
|
||||||
|
number: b.number, belegdatum: b.belegdatum, betragCents: b.betragCents,
|
||||||
|
soll: b.sollKonto.nummer, haben: b.habenKonto.nummer, text: b.buchungstext,
|
||||||
|
festgeschrieben: b.festgeschrieben,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_anlagen',
|
||||||
|
description: 'Get asset register (Anlagenverzeichnis) with AfA (depreciation). Returns all assets with book values.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
requiredScopes: ['mcp:read'],
|
||||||
|
handler: async () => {
|
||||||
|
const anlagen = await db.anlage.findMany({ orderBy: { purchaseDate: 'desc' } })
|
||||||
|
return {
|
||||||
|
total: anlagen.length,
|
||||||
|
totalPurchaseCents: anlagen.reduce((s, a) => s + a.purchaseCostCents, 0),
|
||||||
|
totalBookValueCents: anlagen.reduce((s, a) => s + a.bookValueCents, 0),
|
||||||
|
totalAnnualAfaCents: anlagen.reduce((s, a) => s + a.annualAfaCents, 0),
|
||||||
|
gwgCount: anlagen.filter((a) => a.isGWG).length,
|
||||||
|
assets: anlagen.map((a) => ({
|
||||||
|
number: a.number, name: a.name, category: a.category,
|
||||||
|
purchaseCostCents: a.purchaseCostCents, bookValueCents: a.bookValueCents,
|
||||||
|
annualAfaCents: a.annualAfaCents, isGWG: a.isGWG, status: a.status,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_kassenbuch',
|
||||||
|
description: 'Get cash book (Kassenbuch) entries. § 146 AO compliant — immutable.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { limit: { type: 'integer', default: 20 } },
|
||||||
|
},
|
||||||
|
requiredScopes: ['mcp:read'],
|
||||||
|
handler: async (args) => {
|
||||||
|
const entries = await db.kassenbuchEntry.findMany({
|
||||||
|
take: Math.min(Number(args.limit ?? 20), 100),
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
})
|
||||||
|
const balance = entries[0]?.balanceCents ?? 0
|
||||||
|
return {
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
number: e.number, date: e.date, type: e.type, description: e.description,
|
||||||
|
amountCents: e.amountCents, balanceCents: e.balanceCents,
|
||||||
|
})),
|
||||||
|
currentBalanceCents: balance,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_belege',
|
||||||
|
description: 'Get receipts/documents (Belege). Content-addressed (SHA-256 hash).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { limit: { type: 'integer', default: 20 } },
|
||||||
|
},
|
||||||
|
requiredScopes: ['mcp:read'],
|
||||||
|
handler: async (args) => {
|
||||||
|
const belege = await db.beleg.findMany({
|
||||||
|
take: Math.min(Number(args.limit ?? 20), 100),
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
total: belege.length,
|
||||||
|
totalAmountCents: belege.reduce((s, b) => s + (b.amountCents ?? 0), 0),
|
||||||
|
belege: belege.map((b) => ({
|
||||||
|
number: b.number, type: b.type, vendor: b.vendor,
|
||||||
|
invoiceNumber: b.invoiceNumber, amountCents: b.amountCents,
|
||||||
|
fileHash: b.fileHash?.slice(0, 16) + '…', bookingDate: b.bookingDate,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_finanzamt',
|
||||||
|
description: 'Get Finanzamt data: VAT returns, Jahreserklärungen, Jahresarchive. GoBD compliance overview.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
requiredScopes: ['mcp:read'],
|
||||||
|
handler: async () => {
|
||||||
|
const [vatReturns, jahreserkl, archiv] = await Promise.all([
|
||||||
|
db.vatReturn.findMany({ orderBy: [{ periodYear: 'desc' }, { periodMonth: 'desc' }] }),
|
||||||
|
db.jahreserklaerung.findMany({ orderBy: { jahr: 'desc' } }),
|
||||||
|
db.jahresarchiv.findMany({ orderBy: { jahr: 'desc' } }),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
vatReturns: vatReturns.map((v) => ({
|
||||||
|
period: `${v.periodMonth}/${v.periodYear}`, status: v.status,
|
||||||
|
payableCents: v.payableCents, elsterRef: v.elsterRef,
|
||||||
|
})),
|
||||||
|
jahreserklarungen: jahreserkl.map((e) => ({
|
||||||
|
jahr: e.jahr, art: e.art, status: e.status, elsterRef: e.elsterRef,
|
||||||
|
})),
|
||||||
|
jahresarchive: archiv.map((a) => ({
|
||||||
|
jahr: a.jahr, archivHash: a.archivHash?.slice(0, 24) + '…',
|
||||||
|
buchungenCount: a.buchungenCount, belegeCount: a.belegeCount,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
// --- Write tools (gated by write scopes) ---
|
// --- Write tools (gated by write scopes) ---
|
||||||
{
|
{
|
||||||
name: 'approve_lead',
|
name: 'approve_lead',
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ export async function verifyToken(authHeader: string | null): Promise<AuthResult
|
|||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
return { ok: false, error: 'Authorization header required', status: 401 }
|
return { ok: false, error: 'Authorization header required', status: 401 }
|
||||||
}
|
}
|
||||||
const match = authHeader.match(/^Bearer\s+(aos_[a-z0-9]+)$/i)
|
const match = authHeader.match(/^Bearer\s+(aos_[a-z0-9_]+)$/i)
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { ok: false, error: 'Invalid token format (expected: Bearer aos_…)', status: 401 }
|
return { ok: false, error: 'Invalid token format (expected: Bearer aos_…)', status: 401 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1148,6 +1148,21 @@ Monatlich: Buchungsstapel an Steuerberater.`,
|
|||||||
|
|
||||||
// --- API Tokens + Webhooks (real crypto, hashed at rest) -----------------
|
// --- API Tokens + Webhooks (real crypto, hashed at rest) -----------------
|
||||||
const crypto = await import('crypto')
|
const crypto = await import('crypto')
|
||||||
|
|
||||||
|
// Pre-built admin token for Claude Code (deterministic, so .mcp.json can reference it)
|
||||||
|
// Token: aos_admin_seed_token_for_claude_code_2026
|
||||||
|
const adminToken = 'aos_admin_seed_token_for_claude_code_2026'
|
||||||
|
const adminScopes = ['mcp:read', 'mcp:write', 'crm.company:read', 'crm.deal:read', 'crm.deal:write', 'finance.invoice:read', 'finance.invoice:write', 'finance:write', 'leads:read', 'leads:write', 'support:read', 'support:write', 'projects:write', 'licenses.license:read']
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
name: 'Claude Code Admin (seeded)',
|
||||||
|
tokenHash: crypto.createHash('sha256').update(adminToken).digest('hex'),
|
||||||
|
tokenPrefix: 'aos_admin',
|
||||||
|
scopes: JSON.stringify(adminScopes),
|
||||||
|
lastUsedAt: daysFromNow(-1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const apiTokens = [
|
const apiTokens = [
|
||||||
{ 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: '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: 'DATEV Export-Skript', scopes: ['finance.invoice:read'] },
|
||||||
|
|||||||
Reference in new issue
Block a user