/** * QR Rápido MCP Server — HTTP/SSE transport (para n8n cloud e clientes remotos) * * Variáveis de ambiente: * QR_BASE_URL — URL da API (default: https://qrrapido.site) * PORT — porta HTTP (default: 3000) * ALLOWED_KEYS — lista de API keys válidas separadas por vírgula (opcional; vazio = delega à API) * * Cada request deve enviar o header: X-API-Key: qr_xxx * O server valida a key chamando a API antes de aceitar a sessão MCP. * * Endpoint n8n: https://mcp.qrrapido.site/mcp (ou porta 3000 internamente) */ import express from "express"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { fetch } from "undici"; import { writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; const BASE_URL = process.env.QR_BASE_URL || "https://qrrapido.site"; const PORT = parseInt(process.env.PORT || "3000", 10); // ── PIX EMV builder ─────────────────────────────────────────────────────────── function pixField(id, value) { return `${id}${String(value.length).padStart(2, "0")}${value}`; } function removeAccents(text) { return text.toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Z0-9 ]/g, " "); } function crc16(data) { let crc = 0xFFFF; for (const ch of data) { crc ^= ch.charCodeAt(0) << 8; for (let i = 0; i < 8; i++) crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF; } return crc.toString(16).toUpperCase().padStart(4, "0"); } function buildPixPayload({ pixKey, amount, merchantName, merchantCity, txId = "***" }) { const merchantInfo = pixField("00", "br.gov.bcb.pix") + pixField("01", pixKey); const name = removeAccents(merchantName).substring(0, 25); const city = removeAccents(merchantCity).substring(0, 15); let payload = pixField("00", "01") + pixField("26", merchantInfo) + pixField("52", "0000") + pixField("53", "986"); if (amount && amount > 0) payload += pixField("54", amount.toFixed(2)); payload += pixField("58", "BR") + pixField("59", name) + pixField("60", city) + pixField("62", pixField("05", txId.substring(0, 25))) + "6304"; return payload + crc16(payload); } // ── API helpers ─────────────────────────────────────────────────────────────── async function callApi(apiKey, body) { const res = await fetch(`${BASE_URL}/api/v1/QRManager/generate`, { method: "POST", headers: { "X-API-Key": apiKey, "Content-Type": "application/json" }, body: JSON.stringify(body) }); return res.json(); } async function validateKey(apiKey) { // Basic format check — real auth happens on every tool call via the API if (!apiKey || !apiKey.startsWith("qr_") || apiKey.length < 20) return false; return true; } function errorContent(msg) { return { content: [{ type: "text", text: `❌ ${msg}` }], isError: true }; } // ── MCP server factory (uma instância por sessão/request) ───────────────────── const QR_OUTPUT_SCHEMA = { type: "object", properties: { content: { type: "array", description: "Array with the QR code image (base64 PNG) and a text summary.", items: { oneOf: [ { type: "object", description: "Base64-encoded QR code image.", properties: { type: { type: "string", const: "image" }, data: { type: "string", description: "Base64-encoded image data." }, mimeType: { type: "string", description: "MIME type, e.g. image/png." } }, required: ["type", "data", "mimeType"] }, { type: "object", description: "Text summary with timing, cache status, and quota info.", properties: { type: { type: "string", const: "text" }, text: { type: "string" } }, required: ["type", "text"] } ] } } }, required: ["content"] }; function createMcpServer(apiKey) { const server = new Server( { name: "qrrapido", version: "1.0.3" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "generate_qr", description: "Generate a QR code for a URL, Wi-Fi network, vCard contact, WhatsApp message, " + "email, SMS, or free text. Returns an inline base64 PNG — no file hosting needed. " + "For Brazilian PIX payments use generate_pix_qr instead.", annotations: { title: "Generate QR Code", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, inputSchema: { type: "object", properties: { type: { type: "string", enum: ["url", "wifi", "vcard", "whatsapp", "email", "sms", "texto"], description: "QR code type. Use 'url' for websites, 'wifi' for network credentials, " + "'vcard' for contacts, 'whatsapp' to open a WhatsApp chat, " + "'email' for pre-filled emails, 'sms' for text messages, 'texto' for free text." }, content: { type: "string", description: "Content to encode. Examples by type — " + "url: 'https://example.com'; " + "wifi: 'WIFI:T:WPA;S:MyNet;P:pass123;;'; " + "vcard: 'BEGIN:VCARD\\nFN:John\\nTEL:+1234\\nEND:VCARD'; " + "whatsapp: '+5511999998888'; " + "email: 'user@example.com'; " + "sms: '+5511999998888'; " + "texto: any plain text." }, format: { type: "string", enum: ["png", "webp", "svg"], default: "png", description: "Output image format. PNG is recommended for broadest compatibility." }, size: { type: "number", default: 400, description: "Image size in pixels (width = height). Range: 100–2000. Default: 400." }, primaryColor: { type: "string", default: "#000000", description: "QR module color as hex, e.g. '#1a1a1a'. Default: black (#000000)." }, backgroundColor: { type: "string", default: "#FFFFFF", description: "Background color as hex, e.g. '#FFFFFF'. Default: white (#FFFFFF)." } }, required: ["type", "content"] }, outputSchema: QR_OUTPUT_SCHEMA }, { name: "generate_pix_qr", description: "Generate a Brazilian PIX payment QR code (BRCode/EMV standard). " + "Builds the payload automatically from the PIX key, amount, recipient name, and city. " + "Returns an inline base64 PNG ready to display or share.", annotations: { title: "Generate PIX QR Code", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, inputSchema: { type: "object", properties: { pixKey: { type: "string", description: "PIX key of the recipient. Accepted formats: " + "CPF (digits only, e.g. '12345678901'), " + "email (e.g. 'user@example.com'), " + "phone with country code (e.g. '+5511987654321'), " + "or random key (UUID, e.g. '123e4567-e89b-12d3-a456-426614174000')." }, amount: { type: "number", description: "Payment amount in BRL (e.g. 49.90). " + "Omit or set to 0 to create an open-value QR code where the payer enters the amount." }, merchantName: { type: "string", description: "Recipient name as it appears in the payer's banking app. " + "Max 25 characters. Accents are stripped automatically. Example: 'RICARDO CARNEIRO'." }, merchantCity: { type: "string", description: "Recipient city. Max 15 characters. Accents are stripped automatically. " + "Example: 'SAO PAULO'." }, txId: { type: "string", default: "***", description: "Optional transaction identifier for reconciliation. Max 25 characters. " + "Use '***' (default) to omit. Example: 'PEDIDO123'." }, size: { type: "number", default: 400, description: "Image size in pixels (width = height). Range: 100–2000. Default: 400." }, primaryColor: { type: "string", default: "#000000", description: "QR module color as hex. Default: black (#000000)." }, backgroundColor: { type: "string", default: "#FFFFFF", description: "Background color as hex. Default: white (#FFFFFF)." } }, required: ["pixKey", "merchantName", "merchantCity"] }, outputSchema: QR_OUTPUT_SCHEMA } ] })); server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name } = req.params; const args = req.params.arguments ?? {}; if (name === "generate_pix_qr") { const { pixKey, amount, merchantName, merchantCity, txId = "***", size = 400, primaryColor = "#000000", backgroundColor = "#FFFFFF" } = args; if (!pixKey || !merchantName || !merchantCity) return errorContent("pixKey, merchantName and merchantCity are required."); let payload; try { payload = buildPixPayload({ pixKey, amount, merchantName, merchantCity, txId }); } catch (e) { return errorContent(`Failed to build PIX payload: ${e.message}`); } let data; try { data = await callApi(apiKey, { type: "texto", content: payload, size, primaryColor, backgroundColor }); } catch (e) { return errorContent(`API request failed: ${e.message}`); } if (!data.success) return errorContent(data.message || "Unknown error"); return { content: [ { type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" }, { type: "text", text: [ `PIX QR code generated successfully`, `PIX key: ${pixKey}`, `Amount: ${amount ? `BRL ${amount.toFixed(2)}` : "open (payer chooses)"}`, `Recipient: ${merchantName} — ${merchantCity}`, `Generation time: ${data.generationTimeMs}ms` ].join("\n") } ] }; } if (name === "generate_qr") { const { type, content, format = "png", size = 400, primaryColor = "#000000", backgroundColor = "#FFFFFF" } = args; if (!type || !content) return errorContent("'type' and 'content' are required."); let data; try { data = await callApi(apiKey, { type, content, outputFormat: format, size, primaryColor, backgroundColor }); } catch (e) { return errorContent(`API request failed: ${e.message}`); } if (!data.success) return errorContent(data.message || "Unknown error"); return { content: [ { type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" }, { type: "text", text: [ `QR code generated successfully`, `Generation time: ${data.generationTimeMs}ms`, `Cache: ${data.fromCache ? "hit" : "miss"}`, data.monthlyQuotaLimit >= 0 ? `Monthly quota: ${data.monthlyQuotaRemaining}/${data.monthlyQuotaLimit}` : `Monthly quota: unlimited` ].join("\n") } ] }; } return errorContent(`Unknown tool: ${name}`); }); return server; } // ── Express app ─────────────────────────────────────────────────────────────── const app = express(); app.use(express.json()); // Health check (no auth required) app.get("/health", (_, res) => res.json({ status: "ok", service: "qrrapido-mcp", version: "1.0.3", baseUrl: BASE_URL })); // MCP endpoint — cada POST cria sessão stateless app.all("/mcp", async (req, res) => { const apiKey = req.headers["x-api-key"] || req.query.key; if (!apiKey) { res.status(401).json({ error: "X-API-Key header required" }); return; } const valid = await validateKey(apiKey); if (!valid) { res.status(401).json({ error: "Invalid or revoked API key" }); return; } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const server = createMcpServer(apiKey); res.on("close", () => { transport.close(); server.close(); }); try { await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (err) { if (!res.headersSent) res.status(500).json({ error: "Internal server error", detail: err.message }); } }); app.listen(PORT, () => { console.log(`[qrrapido-mcp-http] HTTP server on port ${PORT}`); console.log(`[qrrapido-mcp-http] Base URL: ${BASE_URL}`); console.log(`[qrrapido-mcp-http] MCP endpoint: http://localhost:${PORT}/mcp`); });