Files
printerCentral/server/utils/mcp.ts
josedario87 1514109e85
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 36s
docs: Actualizar guía TM-U220 con charset detallado (box drawing, bloques, latinos)
2025-11-25 13:59:27 -06:00

650 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, {
model: string
type: string
columns: number
columnsDoubleWidth: number
supportedChars: string
charsetDetails?: {
supported: string[]
notSupported: string[]
}
rules: string[]
tips?: string[]
operations: string[]
example: string
}> = {
'TM-U220': {
model: 'Epson TM-U220',
type: 'Matricial (impact)',
columns: 40,
columnsDoubleWidth: 20,
supportedChars: 'Latin-1 extendido + Box Drawing + Bloques. NO emojis ni flechas Unicode.',
charsetDetails: {
supported: [
'ASCII completo: A-Z a-z 0-9 = - _ * + | / \\ ( ) [ ] < > " \' ` @ # % & ^ ~',
'Box Drawing Doble: ╔ ╗ ╚ ╝ ║ ═',
'Box Drawing Simple: ┌ ┐ └ ┘ │ ─ ├ ┤ ┬ ┴ ┼',
'Bloques y Sombras: █ ▓ ▒ ░ ▀ ▄',
'Latinos extendidos: ñ Ñ á é í ó ú Á É Í Ó Ú ¿ ¡ ç',
'Monedas: $ € £ ¥',
'Matemáticos: ± × ÷ ≤ ≥ ∞'
],
notSupported: [
'Flechas Unicode: → ← ↑ ↓ ↔ ⇒ ⇐',
'Estrellas/Círculos: ★ ☆ ● ○ ◆ ◇',
'Emojis (todos): 😀 ❤ ✓ ✗ ☀ ☁ ✔ ✘',
'Específicos: ₿ (Bitcoin) ≠ (Distinto)'
]
},
rules: [
'Máximo 40 caracteres por línea (20 con width:2)',
'Siempre usar feed entre elementos para legibilidad',
'feed:1 entre items, feed:2 entre secciones',
'feed:4 antes del cut final',
'Los headers con width:2 deben ser max 20 chars',
'Docs largos pueden dar EX_TIMEOUT pero se imprimen igual'
],
tips: [
'TABLAS: Usar box drawing ┌─┬─┐ │ ├─┼─┤ └─┴─┘ para tablas legibles',
'BARRAS PROGRESO: Combinar █ y ░ ej: [█████░░░░░]',
'CAJAS: Box doble ╔═╗ ║ ╚═╝ para destacar contenido importante',
'BULLETS: Usar * - + > o [*] [X] en lugar de ✓ ✗',
'ASCII ART: Funciona perfecto con caracteres básicos'
],
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'
],
example: `[
{ "op": "align", "align": "center" },
{ "op": "style", "bold": true, "width": 2, "height": 2 },
{ "op": "text", "value": "TITULO" },
{ "op": "style", "bold": false, "width": 1, "height": 1 },
{ "op": "feed", "lines": 2 },
{ "op": "text", "value": "╔══════════════════════════════════════╗" },
{ "op": "text", "value": "║ Contenido importante aquí ║" },
{ "op": "text", "value": "╚══════════════════════════════════════╝" },
{ "op": "feed", "lines": 1 },
{ "op": "text", "value": "┌────────┬────────┬────────┐" },
{ "op": "text", "value": "│ COL 1 │ COL 2 │ COL 3 │" },
{ "op": "text", "value": "├────────┼────────┼────────┤" },
{ "op": "text", "value": "│ dato │ dato │ dato │" },
{ "op": "text", "value": "└────────┴────────┴────────┘" },
{ "op": "feed", "lines": 1 },
{ "op": "text", "value": "Progreso: [██████████░░░░░░░░░░] 50%" },
{ "op": "feed", "lines": 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.
FORMATO DE OPERACIONES (para impresora TM-U220, max 40 caracteres por línea):
- { op: "text", value: "texto" } - Imprime texto
- { op: "feed", lines: N } - Avanza N líneas (usar entre elementos para legibilidad)
- { op: "cut" } - Corta el papel
- { op: "align", align: "left|center|right" } - Alinea el texto
- { op: "style", bold: true/false, underline: true/false, width: 1-2, height: 1-2 } - Estilo de texto
EJEMPLO:
[
{ "op": "align", "align": "center" },
{ "op": "style", "bold": true, "width": 2, "height": 2 },
{ "op": "text", "value": "TITULO" },
{ "op": "style", "bold": false, "width": 1, "height": 1 },
{ "op": "feed", "lines": 2 },
{ "op": "text", "value": "Contenido aquí" },
{ "op": "feed", "lines": 4 },
{ "op": "cut" }
]
REGLAS IMPORTANTES:
- Max 40 caracteres por línea (20 con width:2)
- Usar solo caracteres ASCII (evitar unicode especial)
- Siempre terminar con feed y 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<string, any>): 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}` })
}]
}
}
}