- 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>
220 lines
8.8 KiB
JavaScript
220 lines
8.8 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"}` }
|
|
]
|
|
};
|
|
}
|
|
|
|
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`);
|
|
});
|