/** * 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) ───────────────────── function createMcpServer(apiKey) { const server = new Server( { name: "qrrapido", version: "0.2.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "generate_qr", description: "Gera QR code para URL, Wi-Fi, vCard, WhatsApp, Email, SMS ou texto. Para PIX use generate_pix_qr.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["url", "wifi", "vcard", "whatsapp", "email", "sms", "texto"] }, content: { type: "string" }, format: { type: "string", enum: ["png", "webp", "svg"], default: "png" }, size: { type: "number", default: 400 }, primaryColor: { type: "string", default: "#000000" }, backgroundColor: { type: "string", default: "#FFFFFF" } }, required: ["type", "content"] } }, { name: "generate_pix_qr", description: "Gera QR code PIX (BRCode/EMV) com payload completo para recebimento.", inputSchema: { type: "object", properties: { pixKey: { type: "string", description: "Chave PIX: CPF, email, telefone ou aleatória" }, amount: { type: "number", description: "Valor em reais (omita para valor aberto)" }, merchantName: { type: "string", description: "Nome do recebedor (max 25 chars)" }, merchantCity: { type: "string", description: "Cidade (max 15 chars)" }, txId: { type: "string", default: "***" }, size: { type: "number", default: 400 }, primaryColor: { type: "string", default: "#000000" }, backgroundColor: { type: "string", default: "#FFFFFF" } }, required: ["pixKey", "merchantName", "merchantCity"] } } ] })); 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 e merchantCity são obrigatórios."); let payload; try { payload = buildPixPayload({ pixKey, amount, merchantName, merchantCity, txId }); } catch (e) { return errorContent(`Erro payload PIX: ${e.message}`); } let data; try { data = await callApi(apiKey, { type: "texto", content: payload, size, primaryColor, backgroundColor }); } catch (e) { return errorContent(`Falha na requisição: ${e.message}`); } if (!data.success) return errorContent(data.message || "Erro desconhecido"); return { content: [ { type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" }, { type: "text", text: `✅ PIX gerado\n🔑 ${pixKey}\n💰 ${amount ? `R$ ${amount.toFixed(2)}` : "aberto"}\n👤 ${merchantName} — ${merchantCity}\n⏱ ${data.generationTimeMs}ms` } ] }; } if (name === "generate_qr") { const { type, content, format = "png", size = 400, primaryColor = "#000000", backgroundColor = "#FFFFFF" } = args; if (!type || !content) return errorContent("'type' e 'content' obrigatórios."); let data; try { data = await callApi(apiKey, { type, content, outputFormat: format, size, primaryColor, backgroundColor }); } catch (e) { return errorContent(`Falha: ${e.message}`); } if (!data.success) return errorContent(data.message || "Erro desconhecido"); return { content: [ { type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" }, { type: "text", text: `✅ QR gerado (${type})\n⏱ ${data.generationTimeMs}ms\n💾 cache: ${data.fromCache ? "hit" : "miss"}\n📊 quota: ${data.monthlyQuotaRemaining >= 0 ? `${data.monthlyQuotaRemaining}/${data.monthlyQuotaLimit}` : "ilimitada"}` } ] }; } return errorContent(`Tool desconhecida: ${name}`); }); return server; } // ── Express app ─────────────────────────────────────────────────────────────── const app = express(); app.use(express.json()); // Health check (sem auth) app.get("/health", (_, res) => res.json({ status: "ok", service: "qrrapido-mcp", 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] Servidor HTTP na porta ${PORT}`); console.log(`[qrrapido-mcp-http] Base URL: ${BASE_URL}`); console.log(`[qrrapido-mcp-http] Endpoint n8n: http://localhost:${PORT}/mcp`); });