- Add outputSchema to both generate_qr and generate_pix_qr tools - Add MCP annotations (title, readOnlyHint, idempotentHint, etc.) - Full English parameter descriptions for all inputs - Sync server-http.mjs (HTTP/Smithery endpoint) with index.mjs (stdio/npm) - Bump server version to 1.0.3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
371 lines
14 KiB
JavaScript
371 lines
14 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) ─────────────────────
|
||
|
||
const QR_OUTPUT_SCHEMA = {
|
||
type: "object",
|
||
properties: {
|
||
content: {
|
||
type: "array",
|
||
description: "Array with the QR code image (base64 PNG) and a text summary.",
|
||
items: {
|
||
oneOf: [
|
||
{
|
||
type: "object",
|
||
description: "Base64-encoded QR code image.",
|
||
properties: {
|
||
type: { type: "string", const: "image" },
|
||
data: { type: "string", description: "Base64-encoded image data." },
|
||
mimeType: { type: "string", description: "MIME type, e.g. image/png." }
|
||
},
|
||
required: ["type", "data", "mimeType"]
|
||
},
|
||
{
|
||
type: "object",
|
||
description: "Text summary with timing, cache status, and quota info.",
|
||
properties: {
|
||
type: { type: "string", const: "text" },
|
||
text: { type: "string" }
|
||
},
|
||
required: ["type", "text"]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
required: ["content"]
|
||
};
|
||
|
||
function createMcpServer(apiKey) {
|
||
const server = new Server(
|
||
{ name: "qrrapido", version: "1.0.3" },
|
||
{ capabilities: { tools: {} } }
|
||
);
|
||
|
||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||
tools: [
|
||
{
|
||
name: "generate_qr",
|
||
description:
|
||
"Generate a QR code for a URL, Wi-Fi network, vCard contact, WhatsApp message, " +
|
||
"email, SMS, or free text. Returns an inline base64 PNG — no file hosting needed. " +
|
||
"For Brazilian PIX payments use generate_pix_qr instead.",
|
||
annotations: {
|
||
title: "Generate QR Code",
|
||
readOnlyHint: false,
|
||
destructiveHint: false,
|
||
idempotentHint: true,
|
||
openWorldHint: true
|
||
},
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
type: {
|
||
type: "string",
|
||
enum: ["url", "wifi", "vcard", "whatsapp", "email", "sms", "texto"],
|
||
description:
|
||
"QR code type. Use 'url' for websites, 'wifi' for network credentials, " +
|
||
"'vcard' for contacts, 'whatsapp' to open a WhatsApp chat, " +
|
||
"'email' for pre-filled emails, 'sms' for text messages, 'texto' for free text."
|
||
},
|
||
content: {
|
||
type: "string",
|
||
description:
|
||
"Content to encode. Examples by type — " +
|
||
"url: 'https://example.com'; " +
|
||
"wifi: 'WIFI:T:WPA;S:MyNet;P:pass123;;'; " +
|
||
"vcard: 'BEGIN:VCARD\\nFN:John\\nTEL:+1234\\nEND:VCARD'; " +
|
||
"whatsapp: '+5511999998888'; " +
|
||
"email: 'user@example.com'; " +
|
||
"sms: '+5511999998888'; " +
|
||
"texto: any plain text."
|
||
},
|
||
format: {
|
||
type: "string",
|
||
enum: ["png", "webp", "svg"],
|
||
default: "png",
|
||
description: "Output image format. PNG is recommended for broadest compatibility."
|
||
},
|
||
size: {
|
||
type: "number",
|
||
default: 400,
|
||
description: "Image size in pixels (width = height). Range: 100–2000. Default: 400."
|
||
},
|
||
primaryColor: {
|
||
type: "string",
|
||
default: "#000000",
|
||
description: "QR module color as hex, e.g. '#1a1a1a'. Default: black (#000000)."
|
||
},
|
||
backgroundColor: {
|
||
type: "string",
|
||
default: "#FFFFFF",
|
||
description: "Background color as hex, e.g. '#FFFFFF'. Default: white (#FFFFFF)."
|
||
}
|
||
},
|
||
required: ["type", "content"]
|
||
},
|
||
outputSchema: QR_OUTPUT_SCHEMA
|
||
},
|
||
{
|
||
name: "generate_pix_qr",
|
||
description:
|
||
"Generate a Brazilian PIX payment QR code (BRCode/EMV standard). " +
|
||
"Builds the payload automatically from the PIX key, amount, recipient name, and city. " +
|
||
"Returns an inline base64 PNG ready to display or share.",
|
||
annotations: {
|
||
title: "Generate PIX QR Code",
|
||
readOnlyHint: false,
|
||
destructiveHint: false,
|
||
idempotentHint: true,
|
||
openWorldHint: true
|
||
},
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
pixKey: {
|
||
type: "string",
|
||
description:
|
||
"PIX key of the recipient. Accepted formats: " +
|
||
"CPF (digits only, e.g. '12345678901'), " +
|
||
"email (e.g. 'user@example.com'), " +
|
||
"phone with country code (e.g. '+5511987654321'), " +
|
||
"or random key (UUID, e.g. '123e4567-e89b-12d3-a456-426614174000')."
|
||
},
|
||
amount: {
|
||
type: "number",
|
||
description:
|
||
"Payment amount in BRL (e.g. 49.90). " +
|
||
"Omit or set to 0 to create an open-value QR code where the payer enters the amount."
|
||
},
|
||
merchantName: {
|
||
type: "string",
|
||
description:
|
||
"Recipient name as it appears in the payer's banking app. " +
|
||
"Max 25 characters. Accents are stripped automatically. Example: 'RICARDO CARNEIRO'."
|
||
},
|
||
merchantCity: {
|
||
type: "string",
|
||
description:
|
||
"Recipient city. Max 15 characters. Accents are stripped automatically. " +
|
||
"Example: 'SAO PAULO'."
|
||
},
|
||
txId: {
|
||
type: "string",
|
||
default: "***",
|
||
description:
|
||
"Optional transaction identifier for reconciliation. Max 25 characters. " +
|
||
"Use '***' (default) to omit. Example: 'PEDIDO123'."
|
||
},
|
||
size: {
|
||
type: "number",
|
||
default: 400,
|
||
description: "Image size in pixels (width = height). Range: 100–2000. Default: 400."
|
||
},
|
||
primaryColor: {
|
||
type: "string",
|
||
default: "#000000",
|
||
description: "QR module color as hex. Default: black (#000000)."
|
||
},
|
||
backgroundColor: {
|
||
type: "string",
|
||
default: "#FFFFFF",
|
||
description: "Background color as hex. Default: white (#FFFFFF)."
|
||
}
|
||
},
|
||
required: ["pixKey", "merchantName", "merchantCity"]
|
||
},
|
||
outputSchema: QR_OUTPUT_SCHEMA
|
||
}
|
||
]
|
||
}));
|
||
|
||
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 and merchantCity are required.");
|
||
let payload;
|
||
try { payload = buildPixPayload({ pixKey, amount, merchantName, merchantCity, txId }); }
|
||
catch (e) { return errorContent(`Failed to build PIX payload: ${e.message}`); }
|
||
let data;
|
||
try { data = await callApi(apiKey, { type: "texto", content: payload, size, primaryColor, backgroundColor }); }
|
||
catch (e) { return errorContent(`API request failed: ${e.message}`); }
|
||
if (!data.success) return errorContent(data.message || "Unknown error");
|
||
return {
|
||
content: [
|
||
{ type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" },
|
||
{
|
||
type: "text",
|
||
text: [
|
||
`PIX QR code generated successfully`,
|
||
`PIX key: ${pixKey}`,
|
||
`Amount: ${amount ? `BRL ${amount.toFixed(2)}` : "open (payer chooses)"}`,
|
||
`Recipient: ${merchantName} — ${merchantCity}`,
|
||
`Generation time: ${data.generationTimeMs}ms`
|
||
].join("\n")
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
if (name === "generate_qr") {
|
||
const { type, content, format = "png", size = 400,
|
||
primaryColor = "#000000", backgroundColor = "#FFFFFF" } = args;
|
||
if (!type || !content) return errorContent("'type' and 'content' are required.");
|
||
let data;
|
||
try { data = await callApi(apiKey, { type, content, outputFormat: format, size, primaryColor, backgroundColor }); }
|
||
catch (e) { return errorContent(`API request failed: ${e.message}`); }
|
||
if (!data.success) return errorContent(data.message || "Unknown error");
|
||
return {
|
||
content: [
|
||
{ type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" },
|
||
{
|
||
type: "text",
|
||
text: [
|
||
`QR code generated successfully`,
|
||
`Generation time: ${data.generationTimeMs}ms`,
|
||
`Cache: ${data.fromCache ? "hit" : "miss"}`,
|
||
data.monthlyQuotaLimit >= 0
|
||
? `Monthly quota: ${data.monthlyQuotaRemaining}/${data.monthlyQuotaLimit}`
|
||
: `Monthly quota: unlimited`
|
||
].join("\n")
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
return errorContent(`Unknown tool: ${name}`);
|
||
});
|
||
|
||
return server;
|
||
}
|
||
|
||
// ── Express app ───────────────────────────────────────────────────────────────
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
// Health check (no auth required)
|
||
app.get("/health", (_, res) => res.json({ status: "ok", service: "qrrapido-mcp", version: "1.0.3", 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] HTTP server on port ${PORT}`);
|
||
console.log(`[qrrapido-mcp-http] Base URL: ${BASE_URL}`);
|
||
console.log(`[qrrapido-mcp-http] MCP endpoint: http://localhost:${PORT}/mcp`);
|
||
});
|