# CLAUDE.md - Lumen Resto MCP Architecture
## Overview
**Lumen Resto MCP** es un servidor MCP (Model Context Protocol) diseñado para operaciones de restaurantes en tiempo real a través de agentes conversacionales (WhatsApp, llamadas telefónicas).
**Características clave:**
- Multi-tenant seguro (aislamiento por `restaurant_id`)
- Solo 2 herramientas (check_restaurant_schedule, create_reservation)
- Asignación automática de mesas con anti-overbooking
- Manejo robusto de horarios (especiales, eventos, cierres regulares)
- Dockerizado, no-root runtime
- FastMCP con transporte stdio
- Supabase PostgREST como backend
## Architecture
### Component Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ MCP Client (Claude) │
│ (WhatsApp/Phone Agent) │
└────────────────────────┬────────────────────────────────────┘
│ stdio (JSON-RPC)
│
┌────────────────────────▼────────────────────────────────────┐
│ lumen_resto_mcp_server.py │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ FastMCP Server │ │
│ │ - check_restaurant_schedule │ │
│ │ - create_reservation │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Validation Layer │ │
│ │ - UUID, date, time, int parsing │ │
│ │ - Phone normalization (E.164) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Business Logic │ │
│ │ - Schedule precedence (events > special > regular) │ │
│ │ - Table best-fit algorithm │ │
│ │ - Overlap detection │ │
│ │ - Alternative time generation │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ HTTP Client (httpx AsyncClient) │ │
│ │ - Timeouts: connect=5s, read=10s │ │
│ │ - Shared client instance │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ HTTPS (PostgREST)
│
┌────────────────────────▼────────────────────────────────────┐
│ Supabase PostgreSQL │
│ Tables: │
│ - restaurants (timezone, default_duration, closed_days) │
│ - restaurant_schedules (day_of_week, service_type, hours) │
│ - special_dates (overrides per date) │
│ - events (closures, blocked tables) │
│ - event_tables (many-to-many: events ↔ tables) │
│ - tables (capacity, is_active, location) │
│ - clients (phone, full_name, language) │
│ - reservations (date, start/end time, table_id, status) │
└─────────────────────────────────────────────────────────────┘
```
## Tool-to-Table Mapping
### check_restaurant_schedule
**READ operations only:**
| Table | Purpose | Filters |
|----------------------|------------------------------------------|----------------------------------|
| restaurants | Get timezone, closed_days | id = restaurant_id |
| events | Check closures | restaurant_id, event_date |
| special_dates | Check date-specific overrides | restaurant_id, date, service |
| restaurant_schedules | Get weekly open hours | restaurant_id, dow, service |
**Write operations:** None (read-only tool)
### create_reservation
**READ operations:**
| Table | Purpose | Filters |
|----------------------|------------------------------------------|----------------------------------|
| restaurants | Get config (timezone, duration, etc.) | id = restaurant_id |
| events | Check closures + blocked tables | restaurant_id, event_date |
| event_tables | Get tables blocked by events | event_id |
| special_dates | Check date-specific overrides | restaurant_id, date, service |
| restaurant_schedules | Get weekly open hours | restaurant_id, dow, service |
| clients | Check existing client by phone | restaurant_id, phone |
| tables | Find available tables | restaurant_id, is_active, cap |
| reservations | Check overlaps + duplicates | restaurant_id, date, table_id |
**WRITE operations:**
| Table | Operation | When | Data Written |
|-------------|-----------|-------------------------------------------|----------------------------------|
| clients | POST | New phone for restaurant | restaurant_id, phone, name, lang |
| clients | PATCH | Existing client with updated name/lang | full_name, language, updated_at |
| reservations| POST | New reservation (no duplicate found) | All fields + table_id |
**NEVER written:**
- restaurants (read-only config)
- restaurant_schedules (read-only config)
- special_dates (read-only config)
- events (read-only config)
- event_tables (read-only config)
- tables (read-only inventory)
## Invariants (NEVER BREAK THESE)
### Data Integrity
1. **No reservation without table_id**
- `reservations.table_id` MUST be set at creation
- If no table available → reject reservation, suggest alternatives
- Never write `table_id = NULL`
2. **No overlapping active reservations on same table**
- Active statuses: `pending`, `confirmed`, `seated`
- Overlap check: `new_start < old_end AND old_start < new_end`
- Must check across ALL active reservations for target table
3. **No reservations when restaurant is closed**
- Must pass schedule check before writing reservation
- Precedence: event closure > special_date disabled > regular_closed_days > schedules
4. **Client uniqueness per restaurant**
- `(restaurant_id, phone)` is unique key
- Always upsert client (reuse existing or create new)
- Never create duplicate clients for same phone
### Security
5. **Table whitelist enforcement**
- Only 8 tables allowed (hardcoded set)
- No dynamic table names from user input
- No DELETE operations
- No raw SQL execution
6. **Multi-tenant isolation**
- Every query MUST filter by `restaurant_id`
- Never allow cross-tenant data access
- Validate `restaurant_id` is UUID-like
7. **Input validation before DB access**
- UUIDs: regex validated
- Dates: strict YYYY-MM-DD parsing
- Times: strict HH:MM parsing
- Integers: safe conversion with bounds check
- Enums: whitelist validation (service_type, status)
### Operational
8. **Idempotency for create_reservation**
- Same client + date + start_time → return existing reservation
- Prevent duplicate bookings from retries
- Check before insert
9. **Best-fit table assignment**
- Sort tables by `capacity ASC`
- Choose smallest table that fits `party_size`
- Prevents wasting large tables on small parties
10. **Timezone consistency**
- Always use `restaurants.timezone` for date/time interpretation
- Store times with timezone (`time with time zone`)
- Never assume server timezone
## Local Testing
### Prerequisite: Set environment variables
```bash
export SUPABASE_URL="https://vgydyxbtqjzuxloqnvuy.supabase.co"
export SUPABASE_SERVICE_ROLE_KEY="your-service-role-key-here"
```
### Build Docker image
```bash
docker build -t lumen-resto-mcp .
```
### Test 1: List available tools
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \
docker run -i --rm \
-e SUPABASE_URL \
-e SUPABASE_SERVICE_ROLE_KEY \
lumen-resto-mcp
```
**Expected output:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "check_restaurant_schedule",
"description": "Consulta horarios de un restaurante para una fecha y hora específica.",
"inputSchema": {...}
},
{
"name": "create_reservation",
"description": "Crea una nueva reserva con asignación automática de mesa.",
"inputSchema": {...}
}
]
}
}
```
### Test 2: Check schedule
```bash
echo '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "check_restaurant_schedule",
"arguments": {
"restaurant_id": "your-restaurant-uuid",
"date": "2025-12-25",
"time": "20:00",
"service_type": "cena"
}
}
}' | docker run -i --rm \
-e SUPABASE_URL \
-e SUPABASE_SERVICE_ROLE_KEY \
lumen-resto-mcp
```
### Test 3: Create reservation
```bash
echo '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "create_reservation",
"arguments": {
"restaurant_id": "your-restaurant-uuid",
"phone": "+5491123456789",
"full_name": "Test User",
"date": "2025-12-25",
"time": "20:00",
"party_size": "2",
"channel": "test"
}
}
}' | docker run -i --rm \
-e SUPABASE_URL \
-e SUPABASE_SERVICE_ROLE_KEY \
lumen-resto-mcp
```
## How to Extend Safely
### Adding a New Tool (Example: `cancel_reservation`)
**❌ BAD APPROACH (unsafe):**
```python
@mcp.tool()
async def update_row(table: str = "", id: str = "", json_data: str = "") -> str:
# NEVER DO THIS - arbitrary table access, no validation
data = json.loads(json_data)
return await _supabase_patch(table, {"id": f"eq.{id}"}, data)
```
**✅ GOOD APPROACH (safe):**
```python
@mcp.tool()
async def cancel_reservation(
restaurant_id: str = "",
reservation_id: str = "",
cancellation_reason: str = "",
channel: str = ""
) -> str:
"""Cancela una reserva existente confirmada."""
# 1. Validate inputs
if not restaurant_id or not _is_uuid_like(restaurant_id):
return "❌ Error: falta restaurant_id válido."
if not reservation_id or not _is_uuid_like(reservation_id):
return "❌ Error: falta reservation_id válido."
# 2. Verify reservation exists and belongs to restaurant
reservations = await _supabase_get("reservations", {
"id": f"eq.{reservation_id}",
"restaurant_id": f"eq.{restaurant_id}"
})
if not reservations:
return "❌ Error: reserva no encontrada."
reservation = reservations[0]
# 3. Check if already cancelled (idempotency)
if reservation["status"] == "cancelled":
return f"⚠️ Reserva ya estaba cancelada.\n🍽️ ID: {reservation_id}"
# 4. Check if cancellable
if reservation["status"] in ["seated", "done"]:
return f"❌ No se puede cancelar: reserva ya {reservation['status']}."
# 5. Update status (only allowed field)
update_data = {
"status": "cancelled",
"updated_at": datetime.utcnow().isoformat()
}
if cancellation_reason:
# Only append to notes, never overwrite
existing_notes = reservation.get("notes", "")
update_data["notes"] = f"{existing_notes}\nCancelación: {cancellation_reason}".strip()
await _supabase_patch("reservations", {"id": f"eq.{reservation_id}"}, update_data)
logger.info(f"Reservation cancelled: {reservation_id}")
return f"✅ Reserva cancelada\n🍽️ ID: {reservation_id}\n📅 {reservation['date']} a las {reservation['start_time']}\n📱 Canal: {channel or 'no especificado'}"
```
**Key principles:**
1. **Specific action** (cancel_reservation, not update_row)
2. **Explicit parameters** (no json_data blob)
3. **Input validation** (UUID, required fields)
4. **Multi-tenant check** (restaurant_id filter)
5. **Idempotency** (check if already cancelled)
6. **Business rules** (can't cancel seated/done)
7. **Limited writes** (only status + notes, not arbitrary fields)
8. **Audit trail** (append to notes, log action)
9. **Consistent response** (Spanish, emoji status)
### Adding a New Validation Helper
```python
def _validate_email(s):
"""Validate email format (basic check)."""
if not s:
return False
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(email_pattern, s))
```
### Adding a New Business Rule
Example: "No reservations within 30 minutes of current time"
```python
async def _check_minimum_advance_time(restaurant_id, date_obj, time_obj):
"""Check if reservation is at least 30 minutes in the future."""
restaurant = await _get_restaurant(restaurant_id)
tz_obj = tz.gettz(restaurant["timezone"])
reservation_datetime = datetime.combine(date_obj, time_obj).replace(tzinfo=tz_obj)
now = datetime.now(tz_obj)
delta = (reservation_datetime - now).total_seconds() / 60 # minutes
if delta < 30:
return False, f"Reserva debe ser con al menos 30 minutos de anticipación (intentaste {int(delta)} min)"
return True, "OK"
```
Then call in `create_reservation` before checking schedule:
```python
# Check minimum advance time
is_valid, reason = await _check_minimum_advance_time(restaurant_id, date_obj, time_obj)
if not is_valid:
return f"❌ {reason}"
```
### Extending Table Whitelist
**When to add a table:**
- New tool needs read/write access to a new table
- Table is part of core restaurant operations domain
- Table has proper FK to `restaurant_id` for multi-tenant isolation
**How to add:**
1. Update `ALLOWED_TABLES` set:
```python
ALLOWED_TABLES = {
"restaurants",
"restaurant_schedules",
"special_dates",
"events",
"event_tables",
"tables",
"clients",
"reservations",
"new_table_name" # ← Add here
}
```
2. Document in this file (Tool-to-Table Mapping section)
3. Ensure all queries filter by `restaurant_id` (unless it's `restaurants` itself)
**Never add:**
- Tables without `restaurant_id` (except `restaurants`)
- Auth tables (auth_credentials, super_admins, etc.)
- Audit tables (admin_audit_log, etc.)
- System tables (pg_*, information_schema, etc.)
## Error Handling Philosophy
### User-Facing Errors (Tool Return Strings)
- **Language:** Spanish
- **Format:** `❌ Error: descripción clara.`
- **Actionable:** Tell user what's wrong and how to fix
- **No stack traces:** Never expose Python internals
Examples:
```
❌ Error: falta restaurant_id válido.
❌ Error: fecha inválida (formato YYYY-MM-DD requerido).
❌ Cerrado: el restaurante no atiende en ese horario.
❌ Sin disponibilidad: no hay mesas para 4 personas.
💡 Horarios disponibles: 19:30, 20:30
```
### Internal Errors (Logs to stderr)
- **Language:** English (technical)
- **Level:** ERROR for failures, INFO for success
- **Details:** Include technical context for debugging
- **Secrets:** Never log keys, tokens, full phone numbers
Examples:
```
2025-12-22 10:30:45 [INFO] Checking schedule for restaurant abc-123, date 2025-12-25, service cena
2025-12-22 10:30:48 [INFO] Reservation created: def-456, table A5
2025-12-22 10:31:00 [ERROR] Supabase 401 on restaurants
2025-12-22 10:31:05 [ERROR] HTTP 422 on reservations: {"message":"duplicate key violation"}
```
### Exception Hierarchy
```
Tool execution
├─ ValueError (user input errors) → return "❌ Error: ..."
├─ httpx.TimeoutException → return "❌ Conexión: timeout consultando Supabase."
├─ httpx.HTTPStatusError (401) → return "❌ Configuración: credenciales inválidas."
├─ httpx.HTTPStatusError (other) → return "❌ Error consultando X: {status}"
└─ Exception (catch-all) → return "❌ Error inesperado: {str(e)}"
```
## Performance Considerations
### Current Implementation
- **HTTP Client:** Single shared `httpx.AsyncClient` (connection pooling)
- **Timeouts:** connect=5s, read=10s (fail fast)
- **Retries:** None (fail fast, let MCP client retry)
- **Caching:** None (fresh data every request)
### When to Optimize
**Symptoms:**
- Tool calls > 3 seconds consistently
- Supabase rate limits hit
- Multiple restaurants sharing same config
**Strategies:**
1. **Cache restaurant config** (timezone, default_duration, closed_days)
- TTL: 1 hour
- Invalidate on write (not applicable with current read-only approach)
2. **Batch queries** (if Supabase supports OR filters)
- Instead of 3 serial GET requests, combine into 1 with `or` filter
3. **Indexes on Supabase** (DBA task, not code change)
- `reservations(restaurant_id, date, table_id)` composite index
- `restaurant_schedules(restaurant_id, day_of_week, service_type)` composite index
4. **Parallel reads** (use `asyncio.gather`)
```python
restaurant, schedules, special_dates = await asyncio.gather(
_get_restaurant(restaurant_id),
_supabase_get("restaurant_schedules", {...}),
_supabase_get("special_dates", {...})
)
```
**Do NOT optimize prematurely.** Current implementation prioritizes correctness and simplicity.
## Deployment Checklist
- [ ] Environment variables set (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
- [ ] Docker image built and tested locally
- [ ] tools/list returns exactly 2 tools
- [ ] check_restaurant_schedule tested with open/closed scenarios
- [ ] create_reservation tested with success/no-availability scenarios
- [ ] Logs reviewed (no secrets leaked)
- [ ] Multi-tenant isolation verified (cannot access other restaurant's data)
- [ ] Error messages in Spanish and user-friendly
- [ ] Non-root user in container (UID 1000)
- [ ] Network timeouts appropriate for production
## Maintenance
### Regular Tasks
- **Weekly:** Review error logs for new patterns
- **Monthly:** Check Supabase usage (API calls, bandwidth)
- **Quarterly:** Review table whitelist (remove unused, add needed)
### When to Update
- **Supabase schema changes:** Update ALLOWED_TABLES, add/remove columns in POST/PATCH
- **New business rules:** Add validation helpers, update schedule precedence
- **New tools:** Follow "How to Extend Safely" guidelines
- **Security issues:** Patch immediately, redeploy
### Rollback Plan
If deployment fails:
1. Revert to previous Docker image tag
2. Check logs for root cause
3. Test fix locally before redeploying
4. No data migration needed (server is stateless)
---
**END OF CLAUDE.MD**