// MCP Server para PrinterCentral // Permite a agentes de IA gestionar impresoras y templates import { getAllTemplates, getTemplateById, createTemplate, updateTemplate, resolveVariables } from './templates' import type { Operation } from './templates' import { getAllPrinters, getSelectedPrinter, getPrinterById } from './printers' import { buildFromOperations } from './eposBuilder' import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from './printer' // Guías de impresión por modelo export const PRINTING_GUIDES: Record rules: string[] operationsSupported?: Record operationsNotSupported?: string[] tips?: string[] operations: string[] example: string }> = { 'TM-U220': { model: 'Epson TM-U220', type: 'Matricial (impact)', columns: 33, columnsDoubleWidth: 16, columnsFontB: 40, columnsFontBDoubleWidth: 20, supportedChars: 'ASCII + Latin-1 + Box Drawing + Bloques + Simbolos. Charset muy amplio.', charsetDetails: { supported: [ 'ASCII completo: A-Z a-z 0-9 = - _ * + | / \\ ( ) [ ] < > " \' ` @ # $ % & ^ ~ , : . ; !', 'Box Drawing Doble: ╔ ╗ ╚ ╝ ║ ═', 'Box Drawing Simple: ┌ ┐ └ ┘ │ ─ ├ ┤ ┬ ┴ ┼', 'Bloques y Sombras: █ ▓ ▒ ░ ▀ ▄', 'Flechas: ► ◄ ▲ ▼', 'Circulos: ● ○ ◘', 'Cartas: ♠ ♣ ♥ ♦', 'Latinos: ñ Ñ á é í ó ú ¿ ¡', 'Otros: ° ±' ], notSupported: [ 'Emojis (todos)', 'Flechas thin Unicode: → ← ↑ ↓' ] }, fonts: { font_a: { columns: 33, columnsDoubleWidth: 16, description: 'Fuente normal (DEFAULT - se usa automaticamente)' }, font_b: { columns: 40, columnsDoubleWidth: 20, description: 'Fuente condensada (hay que activarla con textFont)' } }, rules: [ '╔══════════════════════════════════════════════════════════════╗', '║ CARACTERES POR LINEA - LA FUENTE POR DEFECTO ES FONT A ║', '╠══════════════════════════════════════════════════════════════╣', '║ FONT A (DEFAULT): ║', '║ - Normal (width:1): 33 caracteres por linea ║', '║ - Doble ancho (width:2): 16 caracteres por linea ║', '║ FONT B (condensada, hay que activarla con textFont): ║', '║ - Normal (width:1): 40 caracteres por linea ║', '║ - Doble ancho (width:2): 20 caracteres por linea ║', '║ IMPORTANTE: height NO afecta la cantidad de caracteres ║', '╚══════════════════════════════════════════════════════════════╝', 'Por defecto la impresora usa Font A, entonces el maximo es 33 chars', 'Para usar Font B: { "op": "textFont", "font": "font_b" }', '=== REGLAS DE FORMATO ===', 'text NO tiene salto de linea automatico, usar feedLine despues de cada text', 'Siempre usar feedLine entre elementos para legibilidad', 'feedLine:1 entre items, feedLine:2 entre secciones', 'feedLine:4 antes del cut final', 'Solo usar cut SIN parametro type (cut con type da SchemaError)', 'Docs largos pueden dar EX_TIMEOUT pero se imprimen igual' ], operationsSupported: { text: { op: 'text', params: 'value: string', description: 'Imprime texto' }, textAlign: { op: 'textAlign', params: 'align: left|center|right', description: 'Alineacion' }, textFont: { op: 'textFont', params: 'font: font_a|font_b', description: 'Fuente (solo A y B funcionan)' }, textSize: { op: 'textSize', params: 'width: 1-2, height: 1-2', description: 'Tamano (max 2, valores >2 son igual a 2)' }, textStyle: { op: 'textStyle', params: 'em: bool, ul: bool', description: 'Solo bold y underline funcionan' }, textDouble: { op: 'textDouble', params: 'dw: bool, dh: bool', description: 'Alternativa a textSize' }, textRotate: { op: 'textRotate', params: 'rotate: bool', description: 'Solo 0 y 90 grados' }, textLineSpace: { op: 'textLineSpace', params: 'linespc: number', description: 'Espaciado entre lineas' }, feed: { op: 'feed', params: 'ninguno', description: 'Avance simple' }, feedLine: { op: 'feedLine', params: 'line: number', description: 'Avance N lineas' }, feedUnit: { op: 'feedUnit', params: 'unit: number', description: 'Avance en dots (mas preciso)' }, cut: { op: 'cut', params: 'ninguno', description: 'Corte (NO usar type, da error)' }, pulse: { op: 'pulse', params: 'drawer: drawer_1|drawer_2, time: pulse_100-500', description: 'Abrir cajon' } }, operationsNotSupported: [ 'textPosition (posicion X no funciona)', 'textVPosition (posicion Y no funciona)', 'textSmooth (sin efecto)', 'textLang (sin efecto, solo ASCII)', 'textStyle.reverse (no funciona)', 'textStyle.color (solo color_1/negro)', 'textFont font_c/d/e/special (sin efecto)', 'cut con type (SchemaError)', 'barcode (matricial no soporta)', 'qrcode (matricial no soporta)', 'imageRaw (matricial no soporta)' ], tips: [ 'TABLAS: Usar box drawing ┌─┬─┐ │ ├─┼─┤ └─┴─┘', 'BARRAS PROGRESO: Combinar █ y ░ ej: [█████░░░░░]', 'CAJAS: Box doble ╔═╗ ║ ╚═╝ para destacar', 'BULLETS: Usar ● ○ ► o [X] [ ]', 'ASCII ART: Funciona perfecto con todos los caracteres' ], operations: [ '{ op: "text", value: "texto" } - Imprime texto', '{ op: "feedLine", line: N } - Avanza N lineas', '{ op: "cut" } - Corta el papel (SIN type)', '{ op: "textAlign", align: "left|center|right" } - Alineacion', '{ op: "textFont", font: "font_a|font_b" } - Fuente', '{ op: "textSize", width: 1-2, height: 1-2 } - Tamano', '{ op: "textStyle", em: true, ul: true } - Bold y underline', '{ op: "textRotate", rotate: true } - Rotar 90 grados', '{ op: "pulse", drawer: "drawer_1", time: "pulse_100" } - Abrir cajon' ], example: `[ { "op": "textAlign", "align": "center" }, { "op": "textSize", "width": 2, "height": 2 }, { "op": "textStyle", "em": true }, { "op": "text", "value": "TITULO" }, { "op": "textStyle", "em": false }, { "op": "textSize", "width": 1, "height": 1 }, { "op": "feedLine", "line": 2 }, { "op": "text", "value": "╔═══════════════════════════════╗" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "║ Contenido importante ║" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "╚═══════════════════════════════╝" }, { "op": "feedLine", "line": 1 }, { "op": "textAlign", "align": "left" }, { "op": "text", "value": "┌──────────┬──────────┬─────────┐" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "│ COL 1 │ COL 2 │ COL 3 │" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "├──────────┼──────────┼─────────┤" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "│ dato │ dato │ dato │" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "└──────────┴──────────┴─────────┘" }, { "op": "feedLine", "line": 1 }, { "op": "text", "value": "Progreso: [████████░░░░░] 60%" }, { "op": "feedLine", "line": 4 }, { "op": "cut" } ]` }, 'TM-T20II': { model: 'Epson TM-T20II', type: 'Térmica', columns: 48, columnsDoubleWidth: 24, supportedChars: 'ASCII extendido, algunos caracteres Unicode soportados', rules: [ 'Máximo 48 caracteres por línea (24 con width:2)', 'Soporta más caracteres que matriciales', 'Impresión rápida y silenciosa', 'Usar feed para espaciado', 'Soporta códigos QR y de barras', 'Ideal para recibos y tickets' ], operations: [ '{ op: "text", value: "texto" } - Imprime texto', '{ op: "feed", lines: N } - Avanza N líneas', '{ op: "cut" } - Corta el papel', '{ op: "align", align: "left|center|right" } - Alineación', '{ op: "style", bold: true, underline: true, width: 1-2, height: 1-2 } - Estilos', '{ op: "qrcode", data: "texto", size: 4 } - Código QR', '{ op: "barcode", data: "123456", type: "ean13" } - Código de barras' ], example: `[ { "op": "align", "align": "center" }, { "op": "style", "bold": true, "width": 2, "height": 2 }, { "op": "text", "value": "RECIBO" }, { "op": "style", "bold": false, "width": 1, "height": 1 }, { "op": "feed", "lines": 1 }, { "op": "text", "value": "================================================" }, { "op": "feed", "lines": 1 }, { "op": "align", "align": "left" }, { "op": "text", "value": "Contenido aquí" }, { "op": "feed", "lines": 2 }, { "op": "qrcode", "data": "https://example.com", "size": 4 }, { "op": "feed", "lines": 4 }, { "op": "cut" } ]` }, 'TM-T88': { model: 'Epson TM-T88 (series)', type: 'Térmica', columns: 48, columnsDoubleWidth: 24, supportedChars: 'ASCII extendido, algunos caracteres Unicode soportados', rules: [ 'Máximo 48 caracteres por línea (24 con width:2)', 'Soporta más caracteres que matriciales', 'Impresión más rápida y silenciosa', 'Usar feed para espaciado', 'Soporta imágenes y códigos QR' ], operations: [ '{ op: "text", value: "texto" } - Imprime texto', '{ op: "feed", lines: N } - Avanza N líneas', '{ op: "cut" } - Corta el papel', '{ op: "align", align: "left|center|right" } - Alineación', '{ op: "style", bold: true, underline: true, width: 1-2, height: 1-2 } - Estilos', '{ op: "qrcode", data: "texto", size: 4 } - Código QR', '{ op: "barcode", data: "123456", type: "ean13" } - Código de barras' ], example: `[ { "op": "align", "align": "center" }, { "op": "style", "bold": true, "width": 2, "height": 2 }, { "op": "text", "value": "RECIBO" }, { "op": "style", "bold": false, "width": 1, "height": 1 }, { "op": "feed", "lines": 1 }, { "op": "qrcode", "data": "https://example.com", "size": 4 }, { "op": "feed", "lines": 2 }, { "op": "cut" } ]` } } // Obtener guía por modelo (búsqueda flexible) export function getPrintingGuide(model: string): typeof PRINTING_GUIDES[string] | null { // Búsqueda exacta if (PRINTING_GUIDES[model]) return PRINTING_GUIDES[model] // Búsqueda parcial (case-insensitive) const modelLower = model.toLowerCase() for (const [key, guide] of Object.entries(PRINTING_GUIDES)) { if (key.toLowerCase().includes(modelLower) || modelLower.includes(key.toLowerCase())) { return guide } } // Default a TM-U220 si no se encuentra return PRINTING_GUIDES['TM-U220'] } // Definición de las tools MCP export const MCP_TOOLS = [ { name: 'printercentral_list_templates', description: 'Lista todos los templates de impresión disponibles. Retorna id, nombre, descripción (con instrucciones de uso) y variables requeridas para cada template.', inputSchema: { type: 'object' as const, properties: {}, required: [] as string[] } }, { name: 'printercentral_list_printers', description: 'Lista todas las impresoras configuradas. Retorna id, nombre, host e indica cuál es la impresora por defecto.', inputSchema: { type: 'object' as const, properties: {}, required: [] as string[] } }, { name: 'printercentral_print_template', description: 'Imprime un template guardado con variables resueltas. Usa list_templates primero para ver los templates disponibles y sus variables.', inputSchema: { type: 'object' as const, properties: { templateId: { type: 'string', description: 'ID del template a imprimir' }, variables: { type: 'object', description: 'Objeto con las variables a resolver. Las claves son los nombres de las variables definidas en el template.', additionalProperties: { type: 'string' } }, printerId: { type: 'string', description: 'ID de la impresora a usar. Si no se especifica, usa la impresora por defecto.' } }, required: ['templateId'] } }, { name: 'printercentral_print_raw', description: `Imprime operaciones ePOS directamente. Útil para impresiones personalizadas sin crear un template. ╔═══════════════════════════════════════════════════════╗ ║ CARACTERES POR LÍNEA - FONT A ES EL DEFAULT ║ ╠═══════════════════════════════════════════════════════╣ ║ FONT A (DEFAULT - no necesita textFont): ║ ║ Normal (width:1): 33 caracteres ║ ║ Doble ancho (width:2): 16 caracteres ║ ║ FONT B (activar con textFont font_b): ║ ║ Normal (width:1): 40 caracteres ║ ║ Doble ancho (width:2): 20 caracteres ║ ║ NOTA: height NO afecta cantidad de caracteres ║ ╚═══════════════════════════════════════════════════════╝ DEFAULT = Font A = 33 chars max (o 16 con width:2) OPERACIONES DISPONIBLES: - { op: "text", value: "texto" } - Imprime texto (SIN salto de linea automatico) - { op: "feedLine", line: N } - Avanza N líneas (SIEMPRE usar despues de text) - { op: "cut" } - Corta papel (SIN parametro type) - { op: "textAlign", align: "left|center|right" } - Alineación - { op: "textFont", font: "font_a|font_b" } - Selecciona fuente - { op: "textSize", width: 1-2, height: 1-2 } - Tamaño - { op: "textStyle", em: true, ul: true } - Bold y underline - { op: "textRotate", rotate: true } - Rotar 90° - { op: "pulse", drawer: "drawer_1", time: "pulse_100" } - Abrir cajón CHARSET SOPORTADO: ASCII, Box Drawing (┌─┐│└┘╔═╗║╚╝), Bloques (█▓▒░▀▄), Símbolos (●○◘►◄▲▼♠♣♥♦), Latinos (ñáéíóú¿¡°±) NO FUNCIONA en TM-U220: textPosition, textVPosition, textSmooth, textLang, textStyle.reverse, textStyle.color (solo negro), cut con type, barcode, qrcode, imageRaw EJEMPLO: [ { "op": "textAlign", "align": "center" }, { "op": "textSize", "width": 2, "height": 2 }, { "op": "textStyle", "em": true }, { "op": "text", "value": "TITULO" }, { "op": "textStyle", "em": false }, { "op": "textSize", "width": 1, "height": 1 }, { "op": "feedLine", "line": 2 }, { "op": "text", "value": "Contenido aquí" }, { "op": "feedLine", "line": 4 }, { "op": "cut" } ]`, inputSchema: { type: 'object' as const, properties: { operations: { type: 'array', description: 'Array de operaciones ePOS a ejecutar', items: { type: 'object' } }, variables: { type: 'object', description: 'Variables a resolver en el texto. Usa sintaxis {{nombreVariable}} en los valores de text.', additionalProperties: { type: 'string' } }, printerId: { type: 'string', description: 'ID de la impresora a usar. Si no se especifica, usa la impresora por defecto.' } }, required: ['operations'] } }, { name: 'printercentral_create_template', description: 'Crea un nuevo template de impresión. El template queda guardado para uso futuro.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string', description: 'Nombre del template' }, description: { type: 'string', description: 'Descripción del template. IMPORTANTE: Incluir instrucciones de uso y explicación de cada variable para que otros agentes sepan cómo usarlo.' }, operations: { type: 'array', description: 'Array de operaciones ePOS. Usa {{variable}} para definir variables.', items: { type: 'object' } } }, required: ['name', 'operations'] } }, { name: 'printercentral_update_template', description: 'Actualiza un template existente. Solo se actualizan los campos proporcionados.', inputSchema: { type: 'object' as const, properties: { templateId: { type: 'string', description: 'ID del template a actualizar' }, name: { type: 'string', description: 'Nuevo nombre del template' }, description: { type: 'string', description: 'Nueva descripción del template' }, operations: { type: 'array', description: 'Nuevas operaciones del template', items: { type: 'object' } } }, required: ['templateId'] } }, { name: 'printercentral_get_printing_guide', description: 'Obtiene la guía de formato para un modelo de impresora específico. Incluye límites de caracteres, reglas de formato, operaciones disponibles y ejemplos.', inputSchema: { type: 'object' as const, properties: { model: { type: 'string', description: 'Modelo de impresora (ej: "TM-U220", "TM-T88"). Si no se especifica, retorna todas las guías disponibles.' } }, required: [] as string[] } }, { name: 'printercentral_list_models', description: 'Lista todos los modelos de impresora soportados con sus características básicas.', inputSchema: { type: 'object' as const, properties: {}, required: [] as string[] } } ] // Handlers para cada tool export async function handleToolCall(toolName: string, args: Record): Promise<{ content: Array<{ type: string; text: string }> }> { switch (toolName) { case 'printercentral_list_templates': { const templates = await getAllTemplates() const result = templates.map(t => ({ id: t.id, name: t.name, description: t.description, variables: t.variables, createdAt: t.createdAt, updatedAt: t.updatedAt })) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } } case 'printercentral_list_printers': { const printers = await getAllPrinters() const selected = await getSelectedPrinter() const result = printers.map(p => ({ id: p.id, name: p.name, host: p.host, deviceId: p.deviceId, model: p.model || 'No especificado', isDefault: p.isDefault, isSelected: selected?.id === p.id })) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } } case 'printercentral_print_template': { const { templateId, variables = {}, printerId } = args // Obtener template const template = await getTemplateById(templateId) if (!template) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: `Template no encontrado: ${templateId}` }) }] } } // Resolver variables const resolvedOps = resolveVariables(template.operations, variables) // Construir XML const inner = buildFromOperations(resolvedOps) const soap = buildSoapEnvelope(inner) // Obtener impresora const printer = printerId ? await getPrinterById(printerId) : await getSelectedPrinter() if (!printer) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'No hay impresora configurada' }) }] } } // Enviar a impresora try { const result = await sendToPrinter(soap, printer.host, printer.deviceId, printer.timeout) const { success, code } = parsePrinterResponse(result.data) return { content: [{ type: 'text', text: JSON.stringify({ ok: success, templateName: template.name, printerUsed: { id: printer.id, name: printer.name }, code }) }] } } catch (err: any) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err.message }) }] } } } case 'printercentral_print_raw': { const { operations, variables = {}, printerId } = args if (!operations || !Array.isArray(operations) || operations.length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'operations es requerido y debe ser un array no vacío' }) }] } } // Resolver variables const resolvedOps = resolveVariables(operations as Operation[], variables) // Construir XML const inner = buildFromOperations(resolvedOps) const soap = buildSoapEnvelope(inner) // Obtener impresora const printer = printerId ? await getPrinterById(printerId) : await getSelectedPrinter() if (!printer) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'No hay impresora configurada' }) }] } } // Enviar a impresora try { const result = await sendToPrinter(soap, printer.host, printer.deviceId, printer.timeout) const { success, code } = parsePrinterResponse(result.data) return { content: [{ type: 'text', text: JSON.stringify({ ok: success, printerUsed: { id: printer.id, name: printer.name }, code }) }] } } catch (err: any) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err.message }) }] } } } case 'printercentral_create_template': { const { name, description, operations } = args if (!name || !operations) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'name y operations son requeridos' }) }] } } try { const template = await createTemplate({ name, description: description || '', operations }) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, template: { id: template.id, name: template.name, variables: template.variables } }) }] } } catch (err: any) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err.message }) }] } } } case 'printercentral_update_template': { const { templateId, name, description, operations } = args if (!templateId) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'templateId es requerido' }) }] } } const updateData: any = {} if (name !== undefined) updateData.name = name if (description !== undefined) updateData.description = description if (operations !== undefined) updateData.operations = operations try { const template = await updateTemplate(templateId, updateData) if (!template) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: `Template no encontrado: ${templateId}` }) }] } } return { content: [{ type: 'text', text: JSON.stringify({ ok: true, template: { id: template.id, name: template.name, variables: template.variables } }) }] } } catch (err: any) { return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err.message }) }] } } } case 'printercentral_get_printing_guide': { const { model } = args if (model) { const guide = getPrintingGuide(model) return { content: [{ type: 'text', text: JSON.stringify(guide, null, 2) }] } } else { // Retornar todas las guías return { content: [{ type: 'text', text: JSON.stringify(PRINTING_GUIDES, null, 2) }] } } } case 'printercentral_list_models': { const models = Object.entries(PRINTING_GUIDES).map(([key, guide]) => ({ id: key, model: guide.model, type: guide.type, columns: guide.columns, columnsDoubleWidth: guide.columnsDoubleWidth })) return { content: [{ type: 'text', text: JSON.stringify(models, null, 2) }] } } default: return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: `Tool desconocida: ${toolName}` }) }] } } }