feat: MCP Server para control de impresoras
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m8s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m8s
- Endpoint HTTP JSON-RPC en /api/mcp - 6 tools: list_templates, list_printers, print_template, print_raw, create_template, update_template - Guia de formato para impresora TM-U220 - Protegido por Authentik forward auth
This commit is contained in:
115
server/api/mcp/index.post.ts
Normal file
115
server/api/mcp/index.post.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// MCP Server Endpoint - JSON-RPC 2.0 over HTTP
|
||||
// Protocolo MCP para agentes de IA
|
||||
|
||||
import { MCP_TOOLS, handleToolCall } from '../../utils/mcp'
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number | null
|
||||
result?: any
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: any
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event) as JsonRpcRequest
|
||||
|
||||
// Validar JSON-RPC
|
||||
if (body.jsonrpc !== '2.0') {
|
||||
return createJsonRpcError(body.id, -32600, 'Invalid Request: jsonrpc must be "2.0"')
|
||||
}
|
||||
|
||||
if (!body.method) {
|
||||
return createJsonRpcError(body.id, -32600, 'Invalid Request: method is required')
|
||||
}
|
||||
|
||||
// Manejar métodos MCP
|
||||
switch (body.method) {
|
||||
case 'initialize': {
|
||||
// Handshake inicial del protocolo MCP
|
||||
return createJsonRpcResponse(body.id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'printercentral-mcp',
|
||||
version: '1.0.0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
case 'initialized': {
|
||||
// Notificación de que el cliente está listo
|
||||
return createJsonRpcResponse(body.id, {})
|
||||
}
|
||||
|
||||
case 'tools/list': {
|
||||
// Listar todas las tools disponibles
|
||||
return createJsonRpcResponse(body.id, {
|
||||
tools: MCP_TOOLS
|
||||
})
|
||||
}
|
||||
|
||||
case 'tools/call': {
|
||||
// Ejecutar una tool
|
||||
const { name, arguments: args } = body.params || {}
|
||||
|
||||
if (!name) {
|
||||
return createJsonRpcError(body.id, -32602, 'Invalid params: tool name is required')
|
||||
}
|
||||
|
||||
// Verificar que la tool existe
|
||||
const tool = MCP_TOOLS.find(t => t.name === name)
|
||||
if (!tool) {
|
||||
return createJsonRpcError(body.id, -32602, `Tool not found: ${name}`)
|
||||
}
|
||||
|
||||
// Ejecutar la tool
|
||||
const result = await handleToolCall(name, args || {})
|
||||
return createJsonRpcResponse(body.id, result)
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
return createJsonRpcResponse(body.id, {})
|
||||
}
|
||||
|
||||
default:
|
||||
return createJsonRpcError(body.id, -32601, `Method not found: ${body.method}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('MCP Error:', err)
|
||||
return createJsonRpcError(null, -32603, `Internal error: ${err.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
function createJsonRpcResponse(id: string | number | null, result: any): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: id ?? null,
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
function createJsonRpcError(id: string | number | null, code: number, message: string, data?: any): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: id ?? null,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(data && { data })
|
||||
}
|
||||
}
|
||||
}
|
||||
414
server/utils/mcp.ts
Normal file
414
server/utils/mcp.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
// 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'
|
||||
|
||||
// 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']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 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,
|
||||
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 })
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: false, error: `Tool desconocida: ${toolName}` })
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user