Add Hermes API and MCP access

This commit is contained in:
2026-05-31 21:35:50 +01:00
parent 865c4a78ea
commit 3e2501721a
10 changed files with 653 additions and 1 deletions

View File

@@ -0,0 +1,50 @@
# OptiqueStock Hermes MCP
This MCP server gives a Hermes agent safe, token-scoped access to OptiqueStock.
## Environment
Set the same key on the Next.js app and the MCP server:
```bash
HERMES_API_KEY=change-me
OPTICZ_API_BASE=http://192.168.1.30:3000
```
In local development only, the fallback key is:
```text
hermes-demo-key-change-me
```
## Run
```bash
node mini-services/hermes-mcp/server.mjs
```
## MCP Tools
- `opticz_status`
- `opticz_summary`
- `opticz_search_clients`
- `opticz_search_products`
- `opticz_create_client`
- `opticz_create_repair_sale`
## Direct REST API
All endpoints require:
```http
Authorization: Bearer <HERMES_API_KEY>
```
Available endpoints:
- `GET /api/hermes/status`
- `GET /api/hermes/summary`
- `GET /api/hermes/clients?q=amina`
- `POST /api/hermes/clients`
- `GET /api/hermes/products?q=monture`
- `POST /api/hermes/sales/repair`

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env node
const apiBase = (process.env.OPTICZ_API_BASE || 'http://127.0.0.1:3000').replace(/\/$/, '')
const apiKey = process.env.HERMES_API_KEY || 'hermes-demo-key-change-me'
const tools = [
{
name: 'opticz_status',
description: 'Check OptiqueStock Hermes API health.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'opticz_summary',
description: 'Get store counts, low stock products, pending workshop count, and recent sales.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'opticz_search_clients',
description: 'Search OptiqueStock clients by name, phone, or email.',
inputSchema: {
type: 'object',
properties: {
q: { type: 'string', description: 'Search query.' },
limit: { type: 'number', description: 'Maximum results, default 20, max 50.' },
},
},
},
{
name: 'opticz_search_products',
description: 'Search active products by reference, designation, brand, or category.',
inputSchema: {
type: 'object',
properties: {
q: { type: 'string', description: 'Search query.' },
category: { type: 'string', description: 'Optional category filter, e.g. MONTURE or VERRE.' },
inStockOnly: { type: 'boolean', description: 'Only include products with stock. Defaults true.' },
limit: { type: 'number', description: 'Maximum results, default 25, max 75.' },
},
},
},
{
name: 'opticz_create_client',
description: 'Create a client, or return the existing client when the phone number already exists.',
inputSchema: {
type: 'object',
required: ['nom', 'prenom', 'telephone'],
properties: {
nom: { type: 'string' },
prenom: { type: 'string' },
telephone: { type: 'string' },
email: { type: 'string' },
adresse: { type: 'string' },
ville: { type: 'string' },
codePostal: { type: 'string' },
notes: { type: 'string' },
},
},
},
{
name: 'opticz_create_repair_sale',
description: 'Create a paid repair sale for an existing client.',
inputSchema: {
type: 'object',
required: ['clientId', 'repairType', 'repairDescription', 'repairPrice'],
properties: {
clientId: { type: 'string' },
repairType: { type: 'string' },
repairDescription: { type: 'string' },
repairPrice: { type: 'number' },
additionalCharges: { type: 'number' },
paymentMode: {
type: 'string',
enum: ['ESPECES', 'CARTE', 'CHEQUE', 'VIREMENT', 'BON_CAISSE'],
},
},
},
},
]
async function api(path, options = {}) {
const response = await fetch(`${apiBase}${path}`, {
...options,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...(options.headers || {}),
},
})
const text = await response.text()
const data = text ? JSON.parse(text) : null
if (!response.ok) {
throw new Error(data?.error || `HTTP ${response.status}`)
}
return data
}
function query(params) {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params || {})) {
if (value !== undefined && value !== null && value !== '') {
search.set(key, String(value))
}
}
const value = search.toString()
return value ? `?${value}` : ''
}
async function callTool(name, args) {
switch (name) {
case 'opticz_status':
return api('/api/hermes/status')
case 'opticz_summary':
return api('/api/hermes/summary')
case 'opticz_search_clients':
return api(`/api/hermes/clients${query(args)}`)
case 'opticz_search_products':
return api(`/api/hermes/products${query(args)}`)
case 'opticz_create_client':
return api('/api/hermes/clients', {
method: 'POST',
body: JSON.stringify(args || {}),
})
case 'opticz_create_repair_sale':
return api('/api/hermes/sales/repair', {
method: 'POST',
body: JSON.stringify(args || {}),
})
default:
throw new Error(`Unknown tool: ${name}`)
}
}
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`)
}
async function handle(message) {
const { id, method, params } = message
if (method === 'initialize') {
send({
jsonrpc: '2.0',
id,
result: {
protocolVersion: params?.protocolVersion || '2024-11-05',
capabilities: {
tools: {},
},
serverInfo: {
name: 'opticz-hermes-mcp',
version: '0.1.0',
},
},
})
return
}
if (method === 'notifications/initialized') return
if (method === 'tools/list') {
send({
jsonrpc: '2.0',
id,
result: { tools },
})
return
}
if (method === 'tools/call') {
try {
const result = await callTool(params?.name, params?.arguments || {})
send({
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
},
})
} catch (error) {
send({
jsonrpc: '2.0',
id,
result: {
isError: true,
content: [
{
type: 'text',
text: error instanceof Error ? error.message : 'Unknown error',
},
],
},
})
}
return
}
send({
jsonrpc: '2.0',
id,
error: {
code: -32601,
message: `Unsupported method: ${method}`,
},
})
}
let buffer = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
buffer += chunk
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) continue
try {
handle(JSON.parse(line))
} catch (error) {
send({
jsonrpc: '2.0',
id: null,
error: {
code: -32700,
message: error instanceof Error ? error.message : 'Parse error',
},
})
}
}
})

View File

@@ -10,7 +10,8 @@
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset"
"db:reset": "prisma migrate reset",
"hermes:mcp": "node mini-services/hermes-mcp/server.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const searchParams = request.nextUrl.searchParams
const q = (searchParams.get('q') || '').trim()
const limit = Math.min(Number(searchParams.get('limit') || 20), 50)
const clients = await db.client.findMany({
where: q
? {
OR: [
{ nom: { contains: q } },
{ prenom: { contains: q } },
{ telephone: { contains: q } },
{ email: { contains: q } },
],
}
: undefined,
orderBy: [{ nom: 'asc' }, { prenom: 'asc' }],
take: limit,
select: {
id: true,
nom: true,
prenom: true,
email: true,
telephone: true,
ville: true,
codePostal: true,
createdAt: true,
},
})
return NextResponse.json({ clients })
}
export async function POST(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const body = await request.json()
const nom = String(body.nom || '').trim()
const prenom = String(body.prenom || '').trim()
const telephone = String(body.telephone || '').trim()
if (!nom || !prenom || !telephone) {
return NextResponse.json(
{ error: 'nom, prenom and telephone are required' },
{ status: 400 }
)
}
const existingClient = await db.client.findUnique({ where: { telephone } })
if (existingClient) {
return NextResponse.json({ client: existingClient, existed: true })
}
const client = await db.client.create({
data: {
nom,
prenom,
telephone,
email: body.email || null,
adresse: body.adresse || null,
ville: body.ville || null,
codePostal: body.codePostal || null,
notes: body.notes || null,
},
})
return NextResponse.json({ client, existed: false }, { status: 201 })
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const searchParams = request.nextUrl.searchParams
const q = (searchParams.get('q') || '').trim()
const category = (searchParams.get('category') || '').trim()
const inStockOnly = searchParams.get('inStockOnly') !== 'false'
const limit = Math.min(Number(searchParams.get('limit') || 25), 75)
const products = await db.produit.findMany({
where: {
actif: true,
...(inStockOnly ? { stock: { gt: 0 } } : {}),
...(category ? { categorie: category } : {}),
...(q
? {
OR: [
{ reference: { contains: q } },
{ designation: { contains: q } },
{ marque: { contains: q } },
{ categorie: { contains: q } },
],
}
: {}),
},
orderBy: [{ categorie: 'asc' }, { designation: 'asc' }],
take: limit,
select: {
id: true,
reference: true,
designation: true,
categorie: true,
marque: true,
prixVenteTTC: true,
tva: true,
stock: true,
stockMin: true,
emplacement: true,
},
})
return NextResponse.json({ products })
}

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { ModePaiement } from '@prisma/client'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
const SERVICE_TVA = 20
async function generateSaleNumber(): Promise<string> {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const salesThisMonth = await db.vente.count({
where: {
date: {
gte: new Date(year, today.getMonth(), 1),
lt: new Date(year, today.getMonth() + 1, 1),
},
},
})
return `H${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}`
}
function toHt(ttc: number) {
return ttc / (1 + SERVICE_TVA / 100)
}
async function getRepairProduct() {
return db.produit.upsert({
where: { reference: 'HERMES_SERVICE_REPAIR' },
update: {
designation: 'Hermes service reparation',
actif: true,
stock: { increment: 1 },
},
create: {
reference: 'HERMES_SERVICE_REPAIR',
designation: 'Hermes service reparation',
categorie: 'SERVICE',
prixAchatHT: 0,
prixVenteTTC: 0,
tva: SERVICE_TVA,
stock: 999999,
stockMin: 0,
actif: true,
},
})
}
export async function POST(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const body = await request.json()
const clientId = String(body.clientId || '').trim()
const repairType = String(body.repairType || '').trim()
const repairDescription = String(body.repairDescription || '').trim()
const repairPrice = Math.max(0, Number(body.repairPrice || 0))
const additionalCharges = Math.max(0, Number(body.additionalCharges || 0))
const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement
if (!clientId || !repairType || !repairDescription) {
return NextResponse.json(
{ error: 'clientId, repairType and repairDescription are required' },
{ status: 400 }
)
}
const client = await db.client.findUnique({ where: { id: clientId } })
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
const repairProduct = await getRepairProduct()
const totalTTC = repairPrice + additionalCharges
const totalHT = toHt(totalTTC)
const numero = await generateSaleNumber()
const sale = await db.vente.create({
data: {
numero,
clientId,
statut: 'PAYEE',
montantHT: totalHT,
montantTVA: totalTTC - totalHT,
montantTTC: totalTTC,
notes: [
`Hermes repair: ${repairType}`,
`Description: ${repairDescription}`,
additionalCharges > 0 ? `Additional charges: ${additionalCharges.toFixed(2)}` : '',
]
.filter(Boolean)
.join('\n'),
lignes: {
create: {
produitId: repairProduct.id,
quantite: 1,
prixUnitaireHT: totalHT,
prixUnitaireTTC: totalTTC,
remise: 0,
montantHT: totalHT,
montantTTC: totalTTC,
},
},
paiements: {
create: {
mode: paymentMode,
montant: totalTTC,
reference: 'HERMES',
},
},
},
include: {
client: true,
lignes: {
include: {
produit: true,
},
},
paiements: true,
},
})
return NextResponse.json({ sale }, { status: 201 })
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
await db.$queryRaw`SELECT 1`
return NextResponse.json({
ok: true,
service: 'OptiqueStock Hermes API',
time: new Date().toISOString(),
})
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const [clients, activeProducts, lowStock, pendingWorkshop, sales] = await Promise.all([
db.client.count(),
db.produit.count({ where: { actif: true } }),
db.produit.findMany({
where: { actif: true },
orderBy: { stock: 'asc' },
take: 10,
select: {
id: true,
reference: true,
designation: true,
categorie: true,
stock: true,
stockMin: true,
prixVenteTTC: true,
},
}),
db.vente.count({
where: {
statutAtelier: {
in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'],
},
},
}),
db.vente.findMany({
orderBy: { date: 'desc' },
take: 8,
include: {
client: {
select: {
id: true,
nom: true,
prenom: true,
telephone: true,
},
},
},
}),
])
return NextResponse.json({
counts: {
clients,
activeProducts,
pendingWorkshop,
},
lowStock: lowStock.filter((item) => item.stock <= item.stockMin),
recentSales: sales.map((sale) => ({
id: sale.id,
numero: sale.numero,
date: sale.date,
statut: sale.statut,
statutAtelier: sale.statutAtelier,
montantTTC: sale.montantTTC,
client: sale.client,
})),
})
}

27
src/lib/hermes-auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
const DEV_HERMES_KEY = 'hermes-demo-key-change-me'
export function getHermesApiKey() {
return process.env.HERMES_API_KEY || (process.env.NODE_ENV !== 'production' ? DEV_HERMES_KEY : '')
}
export function requireHermesAccess(request: NextRequest) {
const expectedKey = getHermesApiKey()
const authHeader = request.headers.get('authorization') || ''
const bearerKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
const headerKey = request.headers.get('x-hermes-key') || ''
const providedKey = bearerKey || headerKey
if (!expectedKey || providedKey !== expectedKey) {
return NextResponse.json(
{
error: 'Hermes access denied',
hint: 'Send Authorization: Bearer <HERMES_API_KEY> or x-hermes-key.',
},
{ status: 401 }
)
}
return null
}

View File

@@ -7,6 +7,7 @@ export async function proxy(request: NextRequest) {
if (
pathname.startsWith('/login') ||
pathname.startsWith('/api/auth') ||
pathname.startsWith('/api/hermes') ||
pathname.startsWith('/_next/static') ||
pathname.startsWith('/_next/image') ||
pathname === '/favicon.ico'