QrRapido/mcp-server/server-http.mjs
Ricardo Carneiro 40cdc51e70
All checks were successful
Deploy QR Rapido / test (push) Successful in 1m4s
Deploy QR Rapido / build-and-push (push) Successful in 18m56s
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Successful in 3m28s
feat(mcp): upgrade tools to v1.0.3 with outputSchema, annotations and EN descriptions
- 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>
2026-05-08 22:58:33 -03:00

371 lines
14 KiB
JavaScript
Raw Permalink 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 — 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: 1002000. 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: 1002000. 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`);
});