- 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>
378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* QR Rápido MCP Server (stdio transport)
|
||
*
|
||
* Environment variables:
|
||
* QR_API_KEY — your API key (e.g. qr_xxxx). Get one free at https://qrrapido.site/Developer
|
||
* QR_BASE_URL — API base URL (default: https://qrrapido.site)
|
||
* NODE_TLS_REJECT_UNAUTHORIZED=0 — set only for local dev with self-signed cert
|
||
*/
|
||
|
||
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] WARNING: QR_API_KEY not set\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: `Error: ${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] Could not open image: ${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 generated successfully`,
|
||
file ? `Opened at: ${file}` : "",
|
||
`Generation time: ${data.generationTimeMs}ms`,
|
||
`Cache: ${data.fromCache ? "hit" : "miss"}`,
|
||
data.monthlyQuotaLimit >= 0
|
||
? `Monthly quota: ${data.monthlyQuotaRemaining}/${data.monthlyQuotaLimit}`
|
||
: `Monthly quota: unlimited`
|
||
].filter(Boolean).join("\n")
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
// ── Server ────────────────────────────────────────────────────────────────────
|
||
|
||
const server = new Server(
|
||
{
|
||
name: "qrrapido",
|
||
version: "1.0.3",
|
||
},
|
||
{ capabilities: { tools: {} } }
|
||
);
|
||
|
||
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"]
|
||
};
|
||
|
||
// tools/list
|
||
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
|
||
}
|
||
]
|
||
}));
|
||
|
||
// 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 and merchantCity are required.");
|
||
|
||
let payload;
|
||
try {
|
||
payload = buildPixPayload({ pixKey, amount, merchantName, merchantCity, txId });
|
||
} catch (err) {
|
||
return errorContent(`Failed to build PIX payload: ${err.message}`);
|
||
}
|
||
|
||
let data;
|
||
try {
|
||
data = await callApi({ type: "texto", content: payload, size, primaryColor, backgroundColor });
|
||
} catch (err) {
|
||
return errorContent(`API request failed: ${err.message}`);
|
||
}
|
||
|
||
if (!data.success) return errorContent(data.message || data.error || "Unknown error");
|
||
|
||
const pixFile = openImage(data.qrCodeBase64, "pix");
|
||
return {
|
||
content: [
|
||
{ type: "image", data: data.qrCodeBase64, mimeType: data.mimeType || "image/png" },
|
||
{
|
||
type: "text",
|
||
text: [
|
||
`PIX QR code generated successfully`,
|
||
pixFile ? `Opened at: ${pixFile}` : "",
|
||
`PIX key: ${pixKey}`,
|
||
`Amount: ${amount ? `BRL ${amount.toFixed(2)}` : "open (payer chooses)"}`,
|
||
`Recipient: ${merchantName} — ${merchantCity}`,
|
||
`Generation time: ${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' and 'content' are required.");
|
||
|
||
let data;
|
||
try {
|
||
data = await callApi({ type, content, outputFormat: format, size, primaryColor, backgroundColor });
|
||
} catch (err) {
|
||
return errorContent(`API request failed: ${err.message}`);
|
||
}
|
||
|
||
if (!data.success) return errorContent(data.message || data.error || "Unknown error");
|
||
return successContent(data);
|
||
}
|
||
|
||
return errorContent(`Unknown tool: ${name}`);
|
||
});
|
||
|
||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|
||
process.stderr.write(`[qrrapido-mcp] Server started. Base URL: ${BASE_URL}\n`);
|