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>
266 lines
9.5 KiB
JavaScript
266 lines
9.5 KiB
JavaScript
/**
|
||
* QR Rápido MCP Server (Node.js — test/dev)
|
||
*
|
||
* Variáveis de ambiente:
|
||
* QR_API_KEY — sua API key (ex: qr_xxxx)
|
||
* QR_BASE_URL — base URL da API (default: https://qrrapido.site)
|
||
* NODE_TLS_REJECT_UNAUTHORIZED=0 — necessário para localhost com cert self-signed
|
||
*/
|
||
|
||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.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";
|
||
import { exec } from "child_process";
|
||
|
||
const API_KEY = process.env.QR_API_KEY || "";
|
||
const BASE_URL = process.env.QR_BASE_URL || "https://qrrapido.site";
|
||
|
||
if (!API_KEY) process.stderr.write("[qrrapido-mcp] AVISO: QR_API_KEY não definida\n");
|
||
|
||
// ── PIX EMV/BRCode builder (port do C# em PagamentoController) ────────────────
|
||
|
||
function pixField(id, value) {
|
||
const len = String(value.length).padStart(2, "0");
|
||
return `${id}${len}${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 addData = pixField("05", txId.substring(0, 25));
|
||
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", addData)
|
||
+ "6304";
|
||
|
||
return payload + crc16(payload);
|
||
}
|
||
|
||
// ── API helper ────────────────────────────────────────────────────────────────
|
||
|
||
async function callApi(body) {
|
||
const res = await fetch(`${BASE_URL}/api/v1/QRManager/generate`, {
|
||
method: "POST",
|
||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||
body: JSON.stringify(body)
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
function errorContent(msg) {
|
||
return { content: [{ type: "text", text: `❌ ${msg}` }], isError: true };
|
||
}
|
||
|
||
function openImage(base64, label = "qr") {
|
||
try {
|
||
const ext = "png";
|
||
const file = join(tmpdir(), `${label}_${Date.now()}.${ext}`);
|
||
writeFileSync(file, Buffer.from(base64, "base64"));
|
||
exec(`start "" "${file}"`);
|
||
return file;
|
||
} catch (e) {
|
||
process.stderr.write(`[qrrapido-mcp] Não abriu imagem: ${e.message}\n`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function successContent(data, label = "qr") {
|
||
const file = openImage(data.qrCodeBase64, label);
|
||
return {
|
||
content: [
|
||
{ type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" },
|
||
{
|
||
type: "text",
|
||
text: [
|
||
`✅ QR code gerado`,
|
||
file ? `🖼 Aberto em: ${file}` : "",
|
||
`⏱ ${data.generationTimeMs}ms`,
|
||
`💾 Cache: ${data.fromCache ? "hit" : "miss"}`,
|
||
data.monthlyQuotaLimit >= 0
|
||
? `📊 Quota mensal: ${data.monthlyQuotaRemaining}/${data.monthlyQuotaLimit}`
|
||
: `📊 Quota mensal: ilimitada`
|
||
].filter(Boolean).join("\n")
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
// ── Server ────────────────────────────────────────────────────────────────────
|
||
|
||
const server = new Server(
|
||
{ name: "qrrapido", version: "0.2.0" },
|
||
{ capabilities: { tools: {} } }
|
||
);
|
||
|
||
// tools/list
|
||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||
tools: [
|
||
{
|
||
name: "generate_qr",
|
||
description:
|
||
"Gera QR code para URL, Wi-Fi, vCard, WhatsApp, Email, SMS ou texto livre. " +
|
||
"Para PIX use generate_pix_qr.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
type: {
|
||
type: "string",
|
||
enum: ["url", "wifi", "vcard", "whatsapp", "email", "sms", "texto"],
|
||
description: "Tipo do QR code"
|
||
},
|
||
content: { type: "string", description: "Conteúdo do QR code" },
|
||
format: { type: "string", enum: ["png", "webp", "svg"], default: "png" },
|
||
size: { type: "number", default: 400, description: "Tamanho em pixels (100–2000)" },
|
||
primaryColor: { type: "string", default: "#000000", description: "Cor hex do QR" },
|
||
backgroundColor: { type: "string", default: "#FFFFFF", description: "Cor hex de fundo" }
|
||
},
|
||
required: ["type", "content"]
|
||
}
|
||
},
|
||
{
|
||
name: "generate_pix_qr",
|
||
description:
|
||
"Gera QR code PIX (BRCode/EMV) para recebimento de pagamento. " +
|
||
"Monte o payload automaticamente com chave, valor, nome e cidade.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
pixKey: {
|
||
type: "string",
|
||
description: "Chave PIX: CPF (somente dígitos), email, telefone (+5511...) ou chave aleatória"
|
||
},
|
||
amount: {
|
||
type: "number",
|
||
description: "Valor em reais (ex: 50.00). Omita para QR de valor aberto."
|
||
},
|
||
merchantName: {
|
||
type: "string",
|
||
description: "Nome do recebedor (max 25 chars, ex: RICARDO CARNEIRO)"
|
||
},
|
||
merchantCity: {
|
||
type: "string",
|
||
description: "Cidade do recebedor (max 15 chars, ex: SAO PAULO)"
|
||
},
|
||
txId: {
|
||
type: "string",
|
||
default: "***",
|
||
description: "ID da transação (opcional, max 25 chars)"
|
||
},
|
||
size: { type: "number", default: 400, description: "Tamanho em pixels" },
|
||
primaryColor: { type: "string", default: "#000000" },
|
||
backgroundColor: { type: "string", default: "#FFFFFF" }
|
||
},
|
||
required: ["pixKey", "merchantName", "merchantCity"]
|
||
}
|
||
}
|
||
]
|
||
}));
|
||
|
||
// tools/call
|
||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||
const { name } = req.params;
|
||
const args = req.params.arguments ?? {};
|
||
|
||
// ── generate_pix_qr ──────────────────────────────────────────────────────
|
||
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 (err) {
|
||
return errorContent(`Erro ao montar payload PIX: ${err.message}`);
|
||
}
|
||
|
||
let data;
|
||
try {
|
||
data = await callApi({ type: "texto", content: payload, size, primaryColor, backgroundColor });
|
||
} catch (err) {
|
||
return errorContent(`Falha na requisição: ${err.message}`);
|
||
}
|
||
|
||
if (!data.success) return errorContent(data.message || data.error || "Erro desconhecido");
|
||
|
||
const pixFile = openImage(data.qrCodeBase64, "pix");
|
||
return {
|
||
content: [
|
||
{ type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" },
|
||
{
|
||
type: "text",
|
||
text: [
|
||
`✅ QR code PIX gerado`,
|
||
pixFile ? `🖼 Aberto em: ${pixFile}` : "",
|
||
`🔑 Chave: ${pixKey}`,
|
||
`💰 Valor: ${amount ? `R$ ${amount.toFixed(2)}` : "aberto"}`,
|
||
`👤 Recebedor: ${merchantName} — ${merchantCity}`,
|
||
`⏱ ${data.generationTimeMs}ms`
|
||
].filter(Boolean).join("\n")
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
// ── generate_qr ──────────────────────────────────────────────────────────
|
||
if (name === "generate_qr") {
|
||
const { type, content, format = "png", size = 400,
|
||
primaryColor = "#000000", backgroundColor = "#FFFFFF" } = args;
|
||
|
||
if (!type || !content) return errorContent("'type' e 'content' são obrigatórios.");
|
||
|
||
let data;
|
||
try {
|
||
data = await callApi({ type, content, outputFormat: format, size, primaryColor, backgroundColor });
|
||
} catch (err) {
|
||
return errorContent(`Falha na requisição: ${err.message}`);
|
||
}
|
||
|
||
if (!data.success) return errorContent(data.message || data.error || "Erro desconhecido");
|
||
return successContent(data);
|
||
}
|
||
|
||
return errorContent(`Tool desconhecida: ${name}`);
|
||
});
|
||
|
||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|
||
process.stderr.write(`[qrrapido-mcp] Servidor iniciado. Base URL: ${BASE_URL}\n`);
|