QrRapido/mcp-server/server-http.mjs
Ricardo Carneiro 4570e9b70b
All checks were successful
Deploy QR Rapido / test (push) Successful in 51s
Deploy QR Rapido / build-and-push (push) Successful in 15m14s
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Successful in 3m9s
feat: add monthly quota to API response body
Add monthlyQuotaRemaining/monthlyQuotaLimit fields to QRResponseDto
populated from X-Quota-* headers set by rate limiter. MCP server
now displays quota in tool result text instead of web credits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:01:20 -03:00

220 lines
9.0 KiB
JavaScript

/**
* 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`);
});