QrRapido/mcp-server/index.mjs
Ricardo Carneiro a3238ca6c5 feat: MCP server + landing page + OAuth returnUrl fix
- Add Node.js MCP server (stdio + HTTP/SSE) with generate_qr and generate_pix_qr tools
- Add landing pages PT/EN at /mcp and /mcp/en with hreflang SEO
- Fix OAuth returnUrl via RedirectUri query param (state was always null in callback)
- Fix API key requests bypassing web credit check (use rate limiter instead)
- Add /api/mcp nginx route + Docker Swarm service for n8n cloud integration
- Auto-create API key on first OAuth login with TempData display
- Add UseDefaultFiles() for /mcp → /mcp/index.html serving
- Fix Serilog console log level in Development (was Error, now Info for app logs)
- Add /api/v1/QRManager/me endpoint for API key validation
- Update CI/CD to build and deploy qrrapido-mcp image alongside .NET app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:23:50 -03:00

264 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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"}`,
`📊 Créditos restantes: ${data.remainingCredits ?? "N/A"}`
].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 (1002000)" },
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`);