#!/usr/bin/env node
// src/index.ts - MCP Server para Google Sheets
// Implementa especificación MCP 2025-11-25 con Resources, Prompts, Tasks y Server Identity
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// Tipos locales para Tools, Resources y Prompts
interface Tool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, any>;
required?: string[];
};
}
interface Resource {
uri: string;
name: string;
description?: string;
mimeType?: string;
}
interface Prompt {
name: string;
description?: string;
arguments?: Array<{
name: string;
description?: string;
required?: boolean;
}>;
}
import { sheetsClient } from './sheets-client.ts';
import { dbClient } from './db-client.ts';
import * as fs from 'fs';
import * as path from 'path';
// ═══════════════════════════════════════════════════════════════════════════
// SERVER IDENTITY (MCP 2025-11-25)
// ═══════════════════════════════════════════════════════════════════════════
const SERVER_INFO = {
name: 'mcp-spreadsheets',
version: '2.0.0',
vendor: 'Pa-Cartagena',
homepage: 'https://github.com/pa-cartagena/mcp-spreadsheets',
protocolVersion: '2025-11-25',
description: 'MCP Server completo para Google Sheets y PostgreSQL con soporte de Resources, Prompts y Tasks',
};
// ═══════════════════════════════════════════════════════════════════════════
// TASK MANAGER (Async Tasks - MCP 2025-11-25)
// ═══════════════════════════════════════════════════════════════════════════
interface Task {
id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number;
result?: any;
error?: string;
createdAt: Date;
updatedAt: Date;
}
class TaskManager {
private tasks: Map<string, Task> = new Map();
createTask(): Task {
const id = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const task: Task = {
id,
status: 'pending',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
this.tasks.set(id, task);
return task;
}
updateTask(id: string, updates: Partial<Task>): Task | null {
const task = this.tasks.get(id);
if (!task) return null;
Object.assign(task, updates, { updatedAt: new Date() });
return task;
}
getTask(id: string): Task | null {
return this.tasks.get(id) || null;
}
async runAsync<T>(
taskId: string,
operation: (updateProgress: (progress: number) => void) => Promise<T>
): Promise<void> {
this.updateTask(taskId, { status: 'running' });
try {
const result = await operation((progress) => {
this.updateTask(taskId, { progress });
});
this.updateTask(taskId, { status: 'completed', progress: 100, result });
} catch (error) {
this.updateTask(taskId, {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
});
}
}
}
const taskManager = new TaskManager();
// ═══════════════════════════════════════════════════════════════════════════
// RESOURCES (MCP 2025-11-25 - Primitivo Core)
// ═══════════════════════════════════════════════════════════════════════════
const resources: Resource[] = [
{
uri: 'db://reservas/schema',
name: 'Esquema de Reservas',
description: 'Estructura de la tabla de reservas en PostgreSQL',
mimeType: 'application/json',
},
{
uri: 'db://reservas/stats',
name: 'Estadisticas de Reservas',
description: 'Conteo y estadisticas actuales de reservas',
mimeType: 'application/json',
},
{
uri: 'db://reservas/canales',
name: 'Estadisticas por Canal',
description: 'Distribucion de reservas por canal (Airbnb, Booking, etc)',
mimeType: 'application/json',
},
{
uri: 'config://column-mapping',
name: 'Mapeo de Columnas',
description: 'Configuracion de mapeo entre columnas DB y Sheets',
mimeType: 'application/json',
},
{
uri: 'config://apartamentos',
name: 'Lista de Apartamentos',
description: 'Room IDs de los apartamentos de Pa Cartagena',
mimeType: 'application/json',
},
{
uri: 'sheets://active/info',
name: 'Info Spreadsheet Activo',
description: 'Metadata del spreadsheet configurado actualmente',
mimeType: 'application/json',
},
];
async function handleReadResource(uri: string): Promise<any> {
switch (uri) {
case 'db://reservas/schema':
return {
table: 'reservas',
columns: [
{ name: 'id', type: 'serial', primaryKey: true },
{ name: 'bookingid', type: 'varchar(255)', unique: true },
{ name: 'arrival', type: 'timestamp' },
{ name: 'departure', type: 'timestamp' },
{ name: 'guestfirstname', type: 'varchar(255)' },
{ name: 'guestlastname', type: 'varchar(255)' },
{ name: 'guestemail', type: 'varchar(255)' },
{ name: 'guestphone', type: 'varchar(100)' },
{ name: 'adults', type: 'integer' },
{ name: 'children', type: 'integer' },
{ name: 'status', type: 'varchar(100)' },
{ name: 'channel', type: 'varchar(100)' },
{ name: 'roomid', type: 'integer' },
{ name: 'price', type: 'decimal(10,2)' },
{ name: 'deposit', type: 'decimal(10,2)' },
{ name: 'invoiceitems', type: 'jsonb' },
{ name: 'notes', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
],
};
case 'db://reservas/stats':
if (dbClient.isConnected()) {
return await dbClient.countReservas();
}
return { error: 'Database not connected' };
case 'db://reservas/canales':
if (dbClient.isConnected()) {
return await dbClient.getEstadisticasCanales();
}
return { error: 'Database not connected' };
case 'config://column-mapping':
return {
mappings: [
{ db: 'bookingid', sheet: 'Booking ID' },
{ db: 'arrival', sheet: 'Check-in' },
{ db: 'departure', sheet: 'Check-out' },
{ db: 'guestfirstname', sheet: 'Nombre' },
{ db: 'guestlastname', sheet: 'Apellido' },
{ db: 'guestemail', sheet: 'Email' },
{ db: 'guestphone', sheet: 'Telefono' },
{ db: 'adults', sheet: 'Adultos' },
{ db: 'children', sheet: 'Ninos' },
{ db: 'status', sheet: 'Estado' },
{ db: 'channel', sheet: 'Canal' },
{ db: 'roomid', sheet: 'Apartamento' },
{ db: 'price', sheet: 'Precio Total' },
{ db: 'deposit', sheet: 'Deposito' },
{ db: '_totalPagado', sheet: 'Total Pagado', computed: true },
],
};
case 'config://apartamentos':
return {
apartamentos: [
{ roomId: 584775, nombre: 'Apartamento 101' },
{ roomId: 584776, nombre: 'Apartamento 102' },
{ roomId: 584777, nombre: 'Apartamento 201' },
{ roomId: 584778, nombre: 'Apartamento 202' },
{ roomId: 584779, nombre: 'Apartamento 301' },
{ roomId: 584780, nombre: 'Apartamento 302' },
{ roomId: 584854, nombre: 'Tours y Terceros' },
],
toursYTerceros: 584854,
};
case 'sheets://active/info':
const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID;
if (spreadsheetId && sheetsClient.isInitialized()) {
try {
return await sheetsClient.getSpreadsheetInfo(spreadsheetId);
} catch {
return { error: 'Could not fetch spreadsheet info' };
}
}
return { error: 'No spreadsheet configured or client not initialized' };
default:
throw new Error(`Resource not found: ${uri}`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PROMPTS (MCP 2025-11-25 - Primitivo Core)
// ═══════════════════════════════════════════════════════════════════════════
const prompts: Prompt[] = [
{
name: 'sync_reservas',
description: 'Sincroniza reservas entre PostgreSQL y Google Sheets',
arguments: [
{
name: 'direction',
description: 'Direccion de sincronizacion: db_to_sheet, sheet_to_db, o bidirectional',
required: true,
},
{
name: 'spreadsheetId',
description: 'ID del Google Spreadsheet (opcional, usa el configurado por defecto)',
required: false,
},
],
},
{
name: 'reporte_ocupacion',
description: 'Genera un reporte de ocupacion por apartamento',
arguments: [
{
name: 'desde',
description: 'Fecha inicio en formato YYYY-MM-DD',
required: true,
},
{
name: 'hasta',
description: 'Fecha fin en formato YYYY-MM-DD',
required: true,
},
],
},
{
name: 'analisis_canales',
description: 'Analiza el rendimiento de cada canal de reservas',
arguments: [],
},
{
name: 'formato_profesional',
description: 'Aplica formato profesional a una hoja de reservas',
arguments: [
{
name: 'spreadsheetId',
description: 'ID del spreadsheet',
required: true,
},
{
name: 'sheetName',
description: 'Nombre de la hoja a formatear',
required: true,
},
],
},
{
name: 'buscar_reserva',
description: 'Busca una reserva por diferentes criterios',
arguments: [
{
name: 'criterio',
description: 'Tipo de busqueda: bookingId, guestName, email, fecha',
required: true,
},
{
name: 'valor',
description: 'Valor a buscar',
required: true,
},
],
},
];
async function handleGetPrompt(name: string, args: Record<string, string>): Promise<any> {
switch (name) {
case 'sync_reservas':
return {
description: 'Sincronizacion de reservas',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Por favor sincroniza las reservas en direccion "${args.direction || 'db_to_sheet'}".
## Instrucciones:
1. Primero usa db_count_reservas para verificar el estado actual
2. Luego usa db_get_reservas_futuras para obtener las reservas a sincronizar
3. Usa sheets_get_rows para ver el estado actual del spreadsheet
4. Compara los datos y usa sheets_write_range o sheets_append_rows segun corresponda
5. Reporta cuantas filas fueron agregadas/actualizadas
## Reglas de negocio:
- No sobrescribir columnas E-G que contienen datos manuales
- Formato de fecha: DD/MM/YYYY
- Zona horaria: America/Bogota
- Excluir reservas canceladas`,
},
},
],
};
case 'reporte_ocupacion':
return {
description: 'Reporte de ocupacion',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Genera un reporte de ocupacion desde ${args.desde} hasta ${args.hasta}.
## Pasos:
1. Usa db_get_reservas_por_fecha con las fechas proporcionadas
2. Agrupa por roomid (apartamento)
3. Calcula noches ocupadas vs noches disponibles
4. Calcula porcentaje de ocupacion
5. Identifica el apartamento mas y menos ocupado
## Formato de salida:
- Tabla con: Apartamento | Noches Ocupadas | % Ocupacion | Ingresos
- Resumen con totales y promedios`,
},
},
],
};
case 'analisis_canales':
return {
description: 'Analisis de canales de reserva',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Analiza el rendimiento de los canales de reserva.
## Pasos:
1. Usa db_get_estadisticas_canales para obtener los datos
2. Para cada canal, calcula:
- Numero de reservas
- Porcentaje del total
- Valor promedio de reserva
3. Identifica el canal con mejor rendimiento
4. Sugiere optimizaciones
## Canales esperados:
- Airbnb
- Booking.com
- Direct (reservas directas)
- Expedia
- Otros`,
},
},
],
};
case 'formato_profesional':
return {
description: 'Aplicar formato profesional',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Aplica formato profesional a la hoja "${args.sheetName}" del spreadsheet ${args.spreadsheetId}.
## Pasos:
1. Usa sheets_freeze_rows para congelar la fila de headers
2. Usa sheets_format_range para:
- Headers: fondo azul oscuro (#1a365d), texto blanco, negrita
- Filas alternas: fondo gris claro (#f7fafc)
3. Usa sheets_set_column_width para ajustar anchos:
- Booking ID: 120px
- Fechas: 100px
- Nombres: 150px
- Email: 200px
4. Agrega formato condicional:
- Airbnb: fondo rosa claro
- Booking: fondo azul claro
- Direct: fondo verde claro
5. Activa filtros en los headers`,
},
},
],
};
case 'buscar_reserva':
const criterioMap: Record<string, string> = {
bookingId: `Usa db_get_reserva_by_id con bookingId="${args.valor}"`,
guestName: `Usa db_get_reservas_futuras y filtra por nombre que contenga "${args.valor}"`,
email: `Usa db_get_reservas_futuras y filtra por email="${args.valor}"`,
fecha: `Usa db_get_reservas_por_fecha con desde="${args.valor}" hasta="${args.valor}"`,
};
return {
description: 'Busqueda de reserva',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Busca reserva por ${args.criterio}: "${args.valor}"
## Instruccion:
${criterioMap[args.criterio] || 'Criterio no reconocido'}
## Si encuentras la reserva, muestra:
- Booking ID
- Huesped (nombre completo)
- Fechas (check-in y check-out)
- Apartamento
- Canal
- Estado
- Total pagado`,
},
},
],
};
default:
throw new Error(`Prompt not found: ${name}`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DEFINICION DE TOOLS
// ═══════════════════════════════════════════════════════════════════════════
const tools: Tool[] = [
// ─────────────────────────────────────────────────────────────────────────
// TASKS (Async Operations - MCP 2025-11-25)
// ─────────────────────────────────────────────────────────────────────────
{
name: 'task_get_status',
description: 'Obtiene el estado de una tarea asincrona por su ID.',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'string', description: 'ID de la tarea' },
},
required: ['taskId'],
},
},
{
name: 'task_sync_full',
description: 'Inicia una sincronizacion completa asincrona. Retorna un taskId para monitorear progreso.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
direction: {
type: 'string',
enum: ['db_to_sheet', 'sheet_to_db', 'bidirectional'],
description: 'Direccion de sincronizacion'
},
},
required: ['spreadsheetId', 'direction'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// DATOS
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_read_range',
description: 'Lee datos de un rango de celdas. Retorna una matriz 2D con los valores.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango en formato A1 (ej: A1:C10)' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
{
name: 'sheets_write_range',
description: 'Escribe datos en un rango de celdas.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango inicial (ej: A1)' },
values: {
type: 'array',
items: { type: 'array', items: {} },
description: 'Matriz 2D con los valores a escribir',
},
},
required: ['spreadsheetId', 'sheetName', 'range', 'values'],
},
},
{
name: 'sheets_append_rows',
description: 'Agrega filas al final de la hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
rows: {
type: 'array',
items: { type: 'object' },
description: 'Array de objetos donde las keys son los nombres de columna',
},
},
required: ['spreadsheetId', 'sheetName', 'rows'],
},
},
{
name: 'sheets_delete_rows',
description: 'Elimina filas de la hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
startIndex: { type: 'number', description: 'Indice de la primera fila a eliminar (1-based)' },
count: { type: 'number', description: 'Cantidad de filas a eliminar' },
},
required: ['spreadsheetId', 'sheetName', 'startIndex', 'count'],
},
},
{
name: 'sheets_get_rows',
description: 'Obtiene todas las filas como objetos.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
limit: { type: 'number', description: 'Limite de filas (opcional)' },
},
required: ['spreadsheetId', 'sheetName'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// FORMATO
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_format_range',
description: 'Aplica formato a un rango de celdas (color de fondo, texto, negrita, etc).',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango en formato A1 (ej: A1:C10)' },
backgroundColor: { type: 'string', description: 'Color de fondo en hex (ej: #ff0000)' },
textColor: { type: 'string', description: 'Color de texto en hex' },
bold: { type: 'boolean', description: 'Texto en negrita' },
italic: { type: 'boolean', description: 'Texto en cursiva' },
fontSize: { type: 'number', description: 'Tamano de fuente' },
horizontalAlignment: { type: 'string', enum: ['LEFT', 'CENTER', 'RIGHT'], description: 'Alineacion horizontal' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
{
name: 'sheets_clear_format',
description: 'Limpia todo el formato de un rango (vuelve a blanco sin estilo).',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango en formato A1' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// FORMATO CONDICIONAL
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_get_conditional_rules',
description: 'Obtiene todas las reglas de formato condicional de una hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
},
required: ['spreadsheetId', 'sheetName'],
},
},
{
name: 'sheets_add_conditional_rule',
description: 'Agrega una regla de formato condicional basada en formula.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
formula: { type: 'string', description: 'Formula de condicion (ej: =LOWER($A2)="airbnb")' },
backgroundColor: { type: 'string', description: 'Color de fondo en hex' },
textColor: { type: 'string', description: 'Color de texto en hex' },
bold: { type: 'boolean', description: 'Texto en negrita' },
priority: { type: 'number', description: 'Prioridad (0 = mas alta)' },
},
required: ['spreadsheetId', 'sheetName', 'formula'],
},
},
{
name: 'sheets_delete_conditional_rule',
description: 'Elimina una regla de formato condicional por su indice.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
index: { type: 'number', description: 'Indice de la regla a eliminar' },
},
required: ['spreadsheetId', 'sheetName', 'index'],
},
},
{
name: 'sheets_clear_conditional_rules',
description: 'Elimina TODAS las reglas de formato condicional de una hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
},
required: ['spreadsheetId', 'sheetName'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// HOJAS
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_list_sheets',
description: 'Lista todas las hojas de un spreadsheet.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
},
required: ['spreadsheetId'],
},
},
{
name: 'sheets_create_sheet',
description: 'Crea una nueva hoja en el spreadsheet.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
title: { type: 'string', description: 'Nombre de la nueva hoja' },
headers: {
type: 'array',
items: { type: 'string' },
description: 'Headers opcionales para la primera fila',
},
},
required: ['spreadsheetId', 'title'],
},
},
{
name: 'sheets_delete_sheet',
description: 'Elimina una hoja del spreadsheet.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja a eliminar' },
},
required: ['spreadsheetId', 'sheetName'],
},
},
{
name: 'sheets_rename_sheet',
description: 'Renombra una hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
oldName: { type: 'string', description: 'Nombre actual de la hoja' },
newName: { type: 'string', description: 'Nuevo nombre' },
},
required: ['spreadsheetId', 'oldName', 'newName'],
},
},
{
name: 'sheets_duplicate_sheet',
description: 'Duplica una hoja existente.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja a duplicar' },
newName: { type: 'string', description: 'Nombre de la copia' },
},
required: ['spreadsheetId', 'sheetName', 'newName'],
},
},
{
name: 'sheets_hide_sheet',
description: 'Oculta o muestra una hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
hidden: { type: 'boolean', description: 'true para ocultar, false para mostrar' },
},
required: ['spreadsheetId', 'sheetName', 'hidden'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// CELDAS
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_merge_cells',
description: 'Combina un rango de celdas en una sola.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango a combinar (ej: A1:C1)' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
{
name: 'sheets_unmerge_cells',
description: 'Separa celdas combinadas.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango a separar' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
{
name: 'sheets_freeze_rows',
description: 'Congela las primeras N filas (quedan fijas al hacer scroll).',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
count: { type: 'number', description: 'Numero de filas a congelar' },
},
required: ['spreadsheetId', 'sheetName', 'count'],
},
},
{
name: 'sheets_freeze_columns',
description: 'Congela las primeras N columnas.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
count: { type: 'number', description: 'Numero de columnas a congelar' },
},
required: ['spreadsheetId', 'sheetName', 'count'],
},
},
{
name: 'sheets_set_column_width',
description: 'Establece el ancho de una columna.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
column: { type: 'string', description: 'Letra de la columna (ej: A, B, C)' },
width: { type: 'number', description: 'Ancho en pixeles' },
},
required: ['spreadsheetId', 'sheetName', 'column', 'width'],
},
},
{
name: 'sheets_set_row_height',
description: 'Establece la altura de una fila.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
row: { type: 'number', description: 'Numero de fila (1-based)' },
height: { type: 'number', description: 'Altura en pixeles' },
},
required: ['spreadsheetId', 'sheetName', 'row', 'height'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// VALIDACION
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_add_dropdown',
description: 'Agrega una lista desplegable (dropdown) a un rango de celdas.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango donde aplicar el dropdown' },
options: {
type: 'array',
items: { type: 'string' },
description: 'Opciones del dropdown',
},
},
required: ['spreadsheetId', 'sheetName', 'range', 'options'],
},
},
{
name: 'sheets_remove_validation',
description: 'Elimina la validacion de datos de un rango.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango donde eliminar validacion' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// FILTROS
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_set_filter',
description: 'Activa el filtro automatico en un rango.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango donde aplicar filtro' },
},
required: ['spreadsheetId', 'sheetName', 'range'],
},
},
{
name: 'sheets_clear_filter',
description: 'Elimina el filtro de una hoja.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
},
required: ['spreadsheetId', 'sheetName'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// PROTECCION
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_protect_range',
description: 'Protege un rango para evitar ediciones accidentales.',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
sheetName: { type: 'string', description: 'Nombre de la hoja' },
range: { type: 'string', description: 'Rango a proteger' },
description: { type: 'string', description: 'Descripcion de la proteccion' },
},
required: ['spreadsheetId', 'sheetName', 'range', 'description'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// INFO
// ─────────────────────────────────────────────────────────────────────────
{
name: 'sheets_get_info',
description: 'Obtiene informacion general del spreadsheet (titulo, hojas, etc).',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'ID del spreadsheet' },
},
required: ['spreadsheetId'],
},
},
// ─────────────────────────────────────────────────────────────────────────
// BASE DE DATOS - RESERVAS
// ─────────────────────────────────────────────────────────────────────────
{
name: 'db_get_reservas_futuras',
description: 'Obtiene las reservas futuras de los apartamentos de Pa\'Cartagena (no incluye canceladas).',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'db_get_tours_terceros',
description: 'Obtiene las reservas futuras de Tours y Terceros.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'db_get_reserva_by_id',
description: 'Busca una reserva especifica por su bookingid.',
inputSchema: {
type: 'object',
properties: {
bookingId: { type: 'string', description: 'ID de la reserva (bookingid)' },
},
required: ['bookingId'],
},
},
{
name: 'db_count_reservas',
description: 'Cuenta el total de reservas en la base de datos (total, futuras y tours).',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'db_get_reservas_por_fecha',
description: 'Obtiene reservas que llegan entre dos fechas.',
inputSchema: {
type: 'object',
properties: {
desde: { type: 'string', description: 'Fecha inicio (YYYY-MM-DD)' },
hasta: { type: 'string', description: 'Fecha fin (YYYY-MM-DD)' },
},
required: ['desde', 'hasta'],
},
},
{
name: 'db_get_reservas_por_canal',
description: 'Obtiene reservas futuras de un canal especifico (airbnb, booking, direct, expedia).',
inputSchema: {
type: 'object',
properties: {
canal: { type: 'string', description: 'Nombre del canal' },
},
required: ['canal'],
},
},
{
name: 'db_get_estadisticas_canales',
description: 'Obtiene estadisticas de reservas futuras agrupadas por canal.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
];
// ═══════════════════════════════════════════════════════════════════════════
// MANEJADOR DE TOOLS
// ═══════════════════════════════════════════════════════════════════════════
async function handleToolCall(name: string, args: Record<string, any>): Promise<any> {
switch (name) {
// TASKS
case 'task_get_status':
const task = taskManager.getTask(args.taskId);
if (!task) {
return { error: 'Task not found', taskId: args.taskId };
}
return task;
case 'task_sync_full':
const newTask = taskManager.createTask();
// Ejecutar en background
taskManager.runAsync(newTask.id, async (updateProgress) => {
updateProgress(10);
const reservas = await dbClient.getReservasFuturas();
updateProgress(30);
// Aqui iria la logica de sincronizacion
// Por ahora simulamos el proceso
updateProgress(60);
const sheets = await sheetsClient.listSheets(args.spreadsheetId);
updateProgress(90);
return {
reservasProcesadas: reservas.length,
hojas: sheets.length,
direction: args.direction,
};
});
return {
taskId: newTask.id,
status: 'started',
message: 'Sincronizacion iniciada. Usa task_get_status para monitorear.',
};
// DATOS
case 'sheets_read_range':
return await sheetsClient.readRange(args.spreadsheetId, args.sheetName, args.range);
case 'sheets_write_range':
await sheetsClient.writeRange(args.spreadsheetId, args.sheetName, args.range, args.values);
return { success: true, message: 'Datos escritos correctamente' };
case 'sheets_append_rows':
const added = await sheetsClient.appendRows(args.spreadsheetId, args.sheetName, args.rows);
return { success: true, rowsAdded: added };
case 'sheets_delete_rows':
await sheetsClient.deleteRows(args.spreadsheetId, args.sheetName, args.startIndex, args.count);
return { success: true, message: `${args.count} filas eliminadas` };
case 'sheets_get_rows':
return await sheetsClient.getRows(args.spreadsheetId, args.sheetName, args.limit);
// FORMATO
case 'sheets_format_range':
await sheetsClient.formatRange(args.spreadsheetId, args.sheetName, args.range, {
backgroundColor: args.backgroundColor,
textColor: args.textColor,
bold: args.bold,
italic: args.italic,
fontSize: args.fontSize,
horizontalAlignment: args.horizontalAlignment,
});
return { success: true, message: 'Formato aplicado' };
case 'sheets_clear_format':
await sheetsClient.clearFormat(args.spreadsheetId, args.sheetName, args.range);
return { success: true, message: 'Formato limpiado' };
// FORMATO CONDICIONAL
case 'sheets_get_conditional_rules':
return await sheetsClient.getConditionalRules(args.spreadsheetId, args.sheetName);
case 'sheets_add_conditional_rule':
await sheetsClient.addConditionalRule(
args.spreadsheetId,
args.sheetName,
args.formula,
{
backgroundColor: args.backgroundColor,
textColor: args.textColor,
bold: args.bold,
},
args.priority || 0
);
return { success: true, message: 'Regla condicional agregada' };
case 'sheets_delete_conditional_rule':
await sheetsClient.deleteConditionalRule(args.spreadsheetId, args.sheetName, args.index);
return { success: true, message: 'Regla eliminada' };
case 'sheets_clear_conditional_rules':
const cleared = await sheetsClient.clearConditionalRules(args.spreadsheetId, args.sheetName);
return { success: true, rulesCleared: cleared };
// HOJAS
case 'sheets_list_sheets':
return await sheetsClient.listSheets(args.spreadsheetId);
case 'sheets_create_sheet':
await sheetsClient.createSheet(args.spreadsheetId, args.title, args.headers);
return { success: true, message: `Hoja "${args.title}" creada` };
case 'sheets_delete_sheet':
await sheetsClient.deleteSheet(args.spreadsheetId, args.sheetName);
return { success: true, message: `Hoja "${args.sheetName}" eliminada` };
case 'sheets_rename_sheet':
await sheetsClient.renameSheet(args.spreadsheetId, args.oldName, args.newName);
return { success: true, message: `Hoja renombrada a "${args.newName}"` };
case 'sheets_duplicate_sheet':
await sheetsClient.duplicateSheet(args.spreadsheetId, args.sheetName, args.newName);
return { success: true, message: `Hoja duplicada como "${args.newName}"` };
case 'sheets_hide_sheet':
await sheetsClient.hideSheet(args.spreadsheetId, args.sheetName, args.hidden);
return { success: true, message: args.hidden ? 'Hoja ocultada' : 'Hoja visible' };
// CELDAS
case 'sheets_merge_cells':
await sheetsClient.mergeCells(args.spreadsheetId, args.sheetName, args.range);
return { success: true, message: 'Celdas combinadas' };
case 'sheets_unmerge_cells':
await sheetsClient.unmergeCells(args.spreadsheetId, args.sheetName, args.range);
return { success: true, message: 'Celdas separadas' };
case 'sheets_freeze_rows':
await sheetsClient.freezeRows(args.spreadsheetId, args.sheetName, args.count);
return { success: true, message: `${args.count} filas congeladas` };
case 'sheets_freeze_columns':
await sheetsClient.freezeColumns(args.spreadsheetId, args.sheetName, args.count);
return { success: true, message: `${args.count} columnas congeladas` };
case 'sheets_set_column_width':
await sheetsClient.setColumnWidth(args.spreadsheetId, args.sheetName, args.column, args.width);
return { success: true, message: `Columna ${args.column} ajustada a ${args.width}px` };
case 'sheets_set_row_height':
await sheetsClient.setRowHeight(args.spreadsheetId, args.sheetName, args.row, args.height);
return { success: true, message: `Fila ${args.row} ajustada a ${args.height}px` };
// VALIDACION
case 'sheets_add_dropdown':
await sheetsClient.addDropdown(args.spreadsheetId, args.sheetName, args.range, args.options);
return { success: true, message: 'Dropdown agregado' };
case 'sheets_remove_validation':
await sheetsClient.removeValidation(args.spreadsheetId, args.sheetName, args.range);
return { success: true, message: 'Validacion eliminada' };
// FILTROS
case 'sheets_set_filter':
await sheetsClient.setFilter(args.spreadsheetId, args.sheetName, args.range);
return { success: true, message: 'Filtro aplicado' };
case 'sheets_clear_filter':
await sheetsClient.clearFilter(args.spreadsheetId, args.sheetName);
return { success: true, message: 'Filtro eliminado' };
// PROTECCION
case 'sheets_protect_range':
await sheetsClient.protectRange(args.spreadsheetId, args.sheetName, args.range, args.description);
return { success: true, message: 'Rango protegido' };
// INFO
case 'sheets_get_info':
return await sheetsClient.getSpreadsheetInfo(args.spreadsheetId);
// BASE DE DATOS - RESERVAS
case 'db_get_reservas_futuras':
return await dbClient.getReservasFuturas();
case 'db_get_tours_terceros':
return await dbClient.getReservasTercerosTours();
case 'db_get_reserva_by_id':
return await dbClient.getReservaByBookingId(args.bookingId);
case 'db_count_reservas':
return await dbClient.countReservas();
case 'db_get_reservas_por_fecha':
return await dbClient.getReservasPorFecha(args.desde, args.hasta);
case 'db_get_reservas_por_canal':
return await dbClient.getReservasPorCanal(args.canal);
case 'db_get_estadisticas_canales':
return await dbClient.getEstadisticasCanales();
default:
throw new Error(`Tool desconocida: ${name}`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SERVIDOR MCP (Con Resources, Prompts y Server Identity)
// ═══════════════════════════════════════════════════════════════════════════
async function main() {
// Cargar credenciales
const credentialsPath = process.env.GOOGLE_SERVICE_ACCOUNT_PATH ||
process.env.SERVICE_ACCOUNT_PATH ||
'./credentials.json';
let credentials;
try {
if (process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL && process.env.GOOGLE_PRIVATE_KEY) {
credentials = {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY,
};
} else {
const credentialsFile = fs.readFileSync(path.resolve(credentialsPath), 'utf-8');
credentials = JSON.parse(credentialsFile);
}
} catch (error) {
console.error('Error cargando credenciales:', error);
process.exit(1);
}
// Inicializar cliente de Sheets
await sheetsClient.initialize(credentials);
// Inicializar cliente de DB si hay DATABASE_URL
const databaseUrl = process.env.DATABASE_URL;
if (databaseUrl) {
try {
await dbClient.initialize(databaseUrl);
console.error('Cliente de DB inicializado');
} catch (error) {
console.error('Advertencia: No se pudo conectar a la DB:', error);
}
}
// Crear servidor MCP con Server Identity (MCP 2025-11-25)
const server = new Server(
{
name: SERVER_INFO.name,
version: SERVER_INFO.version,
},
{
capabilities: {
tools: {},
resources: { subscribe: true }, // Nuevo en MCP 2025-11-25
prompts: {}, // Nuevo en MCP 2025-11-25
},
}
);
// ─────────────────────────────────────────────────────────────────────────
// HANDLERS DE TOOLS
// ─────────────────────────────────────────────────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
const result = await handleToolCall(name, args as Record<string, any>);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// ─────────────────────────────────────────────────────────────────────────
// HANDLERS DE RESOURCES (MCP 2025-11-25)
// ─────────────────────────────────────────────────────────────────────────
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources,
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
try {
const content = await handleReadResource(uri);
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(content, null, 2),
},
],
};
} catch (error) {
throw new Error(`Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`);
}
});
// ─────────────────────────────────────────────────────────────────────────
// HANDLERS DE PROMPTS (MCP 2025-11-25)
// ─────────────────────────────────────────────────────────────────────────
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts,
}));
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
return await handleGetPrompt(name, args || {});
} catch (error) {
throw new Error(`Failed to get prompt ${name}: ${error instanceof Error ? error.message : String(error)}`);
}
});
// ─────────────────────────────────────────────────────────────────────────
// INICIAR SERVIDOR
// ─────────────────────────────────────────────────────────────────────────
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`MCP Server "${SERVER_INFO.name}" v${SERVER_INFO.version} iniciado`);
console.error(`Protocol: ${SERVER_INFO.protocolVersion}`);
console.error(`Vendor: ${SERVER_INFO.vendor}`);
console.error(`Tools: ${tools.length} | Resources: ${resources.length} | Prompts: ${prompts.length}`);
}
main().catch(console.error);