Compare commits

..

2 Commits

Author SHA1 Message Date
1eb1a3207c fxi: dpeloy
All checks were successful
Deploy QR Rapido / test (push) Successful in 4m36s
Deploy QR Rapido / build-and-push (push) Successful in 11m36s
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Successful in 2m46s
2026-05-07 21:27:12 -03:00
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
22 changed files with 4342 additions and 111 deletions

View File

@ -36,7 +36,18 @@
"Bash(npm install:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
"Bash(git push:*)",
"Bash(sed -i 's|returnUrl=/Developer/mcp|returnUrl=/Developer%23criar-chave|g' C:/vscode/qrrapido/wwwroot/mcp/index.html)",
"Bash(sed -i 's|returnUrl=/Developer/mcp|returnUrl=/Developer%23criar-chave|g' C:/vscode/qrrapido/wwwroot/mcp/en/index.html)",
"Bash(sed -i 's|https://qrrapido.site/Account/Login|/Account/Login|g' C:/vscode/qrrapido/wwwroot/mcp/index.html)",
"Bash(sed -i 's|https://qrrapido.site/Account/Login|/Account/Login|g' C:/vscode/qrrapido/wwwroot/mcp/en/index.html)",
"Bash(sed -i 's|returnUrl=/Developer%23criar-chave|returnUrl=/Developer?new=1|g' C:/vscode/qrrapido/wwwroot/mcp/index.html)",
"Bash(sed -i 's|returnUrl=/Developer%23criar-chave|returnUrl=/Developer?new=1|g' C:/vscode/qrrapido/wwwroot/mcp/en/index.html)",
"Bash(python -m json.tool)",
"Bash(QR_BASE_URL=https://localhost:52428 NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=3001 node server-http.mjs)",
"Bash(kill %1)",
"Bash(rsync:*)",
"Bash(scp:*)"
],
"deny": []
}

View File

@ -64,13 +64,20 @@ jobs:
TAG="develop"
fi
# Build da imagem para ARM64 (servidores Ampere OCI)
# Build da imagem .NET para ARM64 (servidores Ampere OCI)
docker buildx build \
--platform linux/arm64 \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$TAG \
--push \
.
# Build da imagem MCP server (Node.js)
docker buildx build \
--platform linux/arm64 \
--tag ${{ env.REGISTRY }}/qrrapido-mcp:$TAG \
--push \
./mcp-server
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
deploy-staging:
@ -246,6 +253,26 @@ jobs:
# Verifica se o service está funcionando
docker service ps qrrapido-prod
# Atualiza MCP server
docker pull ${{ env.REGISTRY }}/qrrapido-mcp:latest
if docker service inspect qrrapido-mcp > /dev/null 2>&1; then
docker service update \
--image ${{ env.REGISTRY }}/qrrapido-mcp:latest \
--with-registry-auth \
qrrapido-mcp
else
docker service create \
--name qrrapido-mcp \
--replicas 1 \
--network qrrapido-network \
--publish published=3000,target=3000 \
--env QR_BASE_URL=https://qrrapido.site \
--env PORT=3000 \
--restart-condition on-failure \
--with-registry-auth \
${{ env.REGISTRY }}/qrrapido-mcp:latest
fi
# Recarrega NGINX para garantir que está apontando para o novo container
sudo nginx -t && sudo systemctl reload nginx
EOF

View File

@ -7,9 +7,6 @@ using Microsoft.AspNetCore.Mvc;
using QRRapidoApp.Models.ViewModels;
using QRRapidoApp.Services;
using System.Security.Claims;
using System.Text.Json;
using System.Text;
using Microsoft.AspNetCore.DataProtection;
namespace QRRapidoApp.Controllers
{
@ -19,17 +16,14 @@ namespace QRRapidoApp.Controllers
private readonly AdDisplayService _adDisplayService;
private readonly ILogger<AccountController> _logger;
private readonly IConfiguration _configuration;
private readonly IDataProtector _protector;
public AccountController(IUserService userService, AdDisplayService adDisplayService,
ILogger<AccountController> logger, IConfiguration configuration,
IDataProtectionProvider dataProtection)
ILogger<AccountController> logger, IConfiguration configuration)
{
_userService = userService;
_adDisplayService = adDisplayService;
_logger = logger;
_configuration = configuration;
_protector = dataProtection.CreateProtector("OAuth.StateProtection");
}
[HttpGet]
@ -44,25 +38,11 @@ namespace QRRapidoApp.Controllers
public IActionResult LoginGoogle(string returnUrl = "/")
{
var baseUrl = _configuration.GetSection("App:BaseUrl").Value;
// Criar state com dados criptografados em vez de sessão
var stateData = new OAuthStateData
{
ReturnUrl = returnUrl,
Nonce = Guid.NewGuid().ToString(),
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
var stateJson = JsonSerializer.Serialize(stateData);
var protectedState = _protector.Protect(stateJson);
var encodedState = Convert.ToBase64String(Encoding.UTF8.GetBytes(protectedState));
var safeReturn = Url.IsLocalUrl(returnUrl) ? returnUrl : "/";
var properties = new AuthenticationProperties
{
RedirectUri = $"{baseUrl}{Url.Action("GoogleCallback")}",
Items = { { "state", encodedState } }
RedirectUri = $"{baseUrl}/Account/GoogleCallback?returnUrl={Uri.EscapeDataString(safeReturn)}"
};
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
}
@ -70,89 +50,48 @@ namespace QRRapidoApp.Controllers
public IActionResult LoginMicrosoft(string returnUrl = "/")
{
var baseUrl = _configuration.GetSection("App:BaseUrl").Value;
// Mesmo processo para Microsoft
var stateData = new OAuthStateData
{
ReturnUrl = returnUrl,
Nonce = Guid.NewGuid().ToString(),
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
var stateJson = JsonSerializer.Serialize(stateData);
var protectedState = _protector.Protect(stateJson);
var encodedState = Convert.ToBase64String(Encoding.UTF8.GetBytes(protectedState));
var redirectUrl = returnUrl == "/"
? $"{baseUrl}/Account/MicrosoftCallback"
: $"{baseUrl}/Account/MicrosoftCallback";
var safeReturn = Url.IsLocalUrl(returnUrl) ? returnUrl : "/";
var properties = new AuthenticationProperties
{
RedirectUri = redirectUrl,
Items = { { "state", encodedState } }
RedirectUri = $"{baseUrl}/Account/MicrosoftCallback?returnUrl={Uri.EscapeDataString(safeReturn)}"
};
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
}
[HttpGet]
public async Task<IActionResult> GoogleCallback(string state = null)
public async Task<IActionResult> GoogleCallback(string returnUrl = "/")
{
var returnUrl = await HandleExternalLoginCallbackAsync(GoogleDefaults.AuthenticationScheme, state);
return Redirect(returnUrl ?? "/");
var destination = await HandleExternalLoginCallbackAsync(GoogleDefaults.AuthenticationScheme, returnUrl);
return Redirect(destination ?? "/");
}
[HttpGet]
public async Task<IActionResult> MicrosoftCallback(string state = null)
public async Task<IActionResult> MicrosoftCallback(string returnUrl = "/")
{
var returnUrl = await HandleExternalLoginCallbackAsync(MicrosoftAccountDefaults.AuthenticationScheme, state);
return Redirect(returnUrl ?? "/");
var destination = await HandleExternalLoginCallbackAsync(MicrosoftAccountDefaults.AuthenticationScheme, returnUrl);
return Redirect(destination ?? "/");
}
private async Task<string> HandleExternalLoginCallbackAsync(string scheme, string state = null)
private async Task<string> HandleExternalLoginCallbackAsync(string scheme, string returnUrl = "/")
{
try
{
_adDisplayService.SetViewBagAds(ViewBag);
// Recuperar returnUrl do state em vez da sessão
string returnUrl = "/";
if (!string.IsNullOrEmpty(state))
{
try
{
var decodedState = Encoding.UTF8.GetString(Convert.FromBase64String(state));
var unprotectedState = _protector.Unprotect(decodedState);
var stateData = JsonSerializer.Deserialize<OAuthStateData>(unprotectedState);
// Validar timestamp (não mais que 10 minutos)
var maxAge = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - (10 * 60);
if (stateData.Timestamp > maxAge)
{
returnUrl = stateData.ReturnUrl ?? "/";
// Prevent redirect loop to login page
if (returnUrl.Contains("/Account/Login", StringComparison.OrdinalIgnoreCase))
{
// Validate returnUrl to prevent open redirect
if (!Url.IsLocalUrl(returnUrl))
returnUrl = "/";
}
}
else
{
_logger.LogWarning($"OAuth state expired for scheme {scheme}");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Failed to decode OAuth state for scheme {scheme}");
}
}
var result = await HttpContext.AuthenticateAsync(scheme);
// Prevent redirect loop
if (returnUrl.Contains("/Account/Login", StringComparison.OrdinalIgnoreCase))
returnUrl = "/";
// OAuth middleware already signed in via cookie at /signin-google or /signin-microsoft
// with the provider's claims. Authenticate from that cookie to get the provider identity.
var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
_logger.LogWarning($"External authentication failed for scheme {scheme}");
_logger.LogWarning("Cookie authentication failed after {Scheme} OAuth callback", scheme);
return "/Account/Login";
}
@ -168,11 +107,24 @@ namespace QRRapidoApp.Controllers
// Find or create user
var user = await _userService.GetUserByProviderAsync(scheme, providerId);
if (user == null)
bool isNewUser = user == null;
if (isNewUser)
{
// Fix CS8625: Ensure name is not null
var safeName = !string.IsNullOrEmpty(name) ? name : (email ?? "User");
user = await _userService.CreateUserAsync(email, safeName, scheme, providerId);
// Auto-create first API key so user can start immediately
try
{
var (rawKey, _) = await _userService.GenerateApiKeyAsync(user.Id, "Minha primeira key");
TempData["NewKey"] = rawKey;
TempData["NewKeyName"] = "Minha primeira key";
_logger.LogInformation("Auto-created API key for new user {UserId}", user.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to auto-create API key for new user {UserId}", user.Id);
}
}
else
{
@ -325,11 +277,4 @@ namespace QRRapidoApp.Controllers
}
}
// Classe para dados do state
public class OAuthStateData
{
public string ReturnUrl { get; set; } = "/";
public string Nonce { get; set; } = "";
public long Timestamp { get; set; }
}
}

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using QRRapidoApp.Models;
using QRRapidoApp.Services;
using System.Security.Claims;
@ -15,15 +16,21 @@ namespace QRRapidoApp.Controllers
private readonly IUserService _userService;
private readonly StripeService _stripeService;
private readonly ILogger<DeveloperController> _logger;
private readonly IDistributedCache _cache;
private readonly IWebHostEnvironment _env;
public DeveloperController(
IUserService userService,
StripeService stripeService,
ILogger<DeveloperController> logger)
ILogger<DeveloperController> logger,
IDistributedCache cache,
IWebHostEnvironment env)
{
_userService = userService;
_stripeService = stripeService;
_logger = logger;
_cache = cache;
_env = env;
}
[HttpGet("")]
@ -112,6 +119,19 @@ namespace QRRapidoApp.Controllers
}
}
[HttpGet("Mcp")]
public async Task<IActionResult> Mcp()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId)) return Unauthorized();
var user = await _userService.GetUserAsync(userId);
if (user == null) return NotFound();
ViewBag.Culture = GetCulture();
return View(user);
}
[HttpPost("RevokeKey")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RevokeKey(string prefix)
@ -139,6 +159,29 @@ namespace QRRapidoApp.Controllers
return RedirectToAction(nameof(Index));
}
[HttpPost("ResetRateLimit")]
public async Task<IActionResult> ResetRateLimit(string prefix)
{
if (!_env.IsDevelopment())
return Forbid();
if (string.IsNullOrWhiteSpace(prefix))
return BadRequest("prefix required");
var now = DateTime.UtcNow;
var monthKey = $"rl:mo:{prefix}:{now:yyyyMM}";
var minuteKey = $"rl:min:{prefix}:{now:yyyyMMddHHmm}";
await Task.WhenAll(
_cache.RemoveAsync(monthKey),
_cache.RemoveAsync(minuteKey)
);
_logger.LogWarning("Rate limit reset for prefix {Prefix} (dev only)", prefix);
TempData["Success"] = $"Rate limit resetado para '{prefix}'.";
return RedirectToAction(nameof(Index));
}
private string GetCulture()
{
var path = Request.Path.Value ?? "";

View File

@ -365,6 +365,10 @@ namespace QRRapidoApp.Controllers
AppendUrl("/es/developers", "monthly", "0.7");
AppendUrl("/en/developers", "monthly", "0.7");
// MCP landing pages
AppendUrl("/mcp", "monthly", "0.9");
AppendUrl("/mcp/en", "monthly", "0.8");
// Tools (Landing Pages)
var tools = new[] { "pix", "wifi", "vcard", "whatsapp", "email", "sms", "texto", "url" };

View File

@ -36,13 +36,24 @@ namespace QRRapidoApp.Controllers
}
/// <summary>Health check — no API key required.</summary>
/// <response code="200">API is running.</response>
[HttpGet("ping")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Ping() =>
Ok(new { status = "QRRapido API v1 is up", timestamp = DateTime.UtcNow });
/// <summary>Validate API key and return plan info.</summary>
[HttpGet("me")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IActionResult Me()
{
var userId = HttpContext.Items["ApiKeyUserId"] as string;
var prefix = HttpContext.Items["ApiKeyPrefix"] as string;
var tier = HttpContext.Items["ApiPlanTier"];
return Ok(new { userId, prefix, plan = tier?.ToString(), valid = true });
}
/// <summary>
/// Generate a QR code and receive it as a base64-encoded image.
/// </summary>
@ -88,7 +99,8 @@ namespace QRRapidoApp.Controllers
var context = new UserRequesterContext
{
UserId = userId,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "api"
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "api",
IsApiKeyRequest = true
};
var result = await _qrBusinessManager.ProcessGenerationAsync(request, context);

View File

@ -6,5 +6,7 @@ namespace QRRapidoApp.Models.DTOs
public string? IpAddress { get; set; }
public string? DeviceId { get; set; }
public bool IsAuthenticated => !string.IsNullOrEmpty(UserId);
/// <summary>True when request authenticated via X-API-Key (has own rate limiting — skip credit check).</summary>
public bool IsApiKeyRequest { get; set; }
}
}

View File

@ -427,6 +427,7 @@ if (!app.Environment.IsDevelopment())
// Security headers + JSON exception handler for /api/v1/* routes
app.UseMiddleware<QRRapidoApp.Middleware.ApiSecurityHeadersMiddleware>();
app.UseDefaultFiles(); // serve index.html para requisições de diretório (ex: /mcp → /mcp/index.html)
app.UseStaticFiles();
// Language redirection middleware (before routing)

View File

@ -34,6 +34,9 @@ namespace QRRapidoApp.Services
return (true, null, null);
}
// API key requests have their own monthly rate limiting — skip credit check
if (context.IsApiKeyRequest) return (true, null, null);
var user = await _userService.GetUserAsync(context.UserId!);
if (user == null) return (false, "UNAUTHORIZED", "Usuário não encontrado.");

View File

@ -29,11 +29,11 @@
</div>
<div class="d-grid gap-3">
<a href="/Account/LoginGoogle?returnUrl=@returnUrl" class="btn btn-danger btn-lg">
<a href="/Account/LoginGoogle?returnUrl=@Uri.EscapeDataString(returnUrl)" class="btn btn-danger btn-lg">
<i class="fab fa-google"></i> @Localizer["LoginWithGoogle"]
</a>
<a href="/Account/LoginMicrosoft?returnUrl=@returnUrl" class="btn btn-primary btn-lg">
<a href="/Account/LoginMicrosoft?returnUrl=@Uri.EscapeDataString(returnUrl)" class="btn btn-primary btn-lg">
<i class="fab fa-microsoft"></i> @Localizer["LoginWithMicrosoft"]
</a>
</div>

View File

@ -159,7 +159,7 @@
<!-- Criar nova chave -->
@if (activeKeys.Count < 5)
{
<div class="card border-0 shadow-sm mb-4">
<div id="criar-chave" class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-plus-circle me-2 text-success"></i>@T("Criar Nova Chave", "Crear Nueva Clave")
@ -352,6 +352,12 @@
@section Scripts {
<script>
// Auto-scroll para criar chave quando vem do fluxo MCP (?new=1)
if (new URLSearchParams(window.location.search).get('new') === '1') {
const el = document.getElementById('criar-chave');
if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 400);
}
function copyKey(inputId, btn) {
const input = document.getElementById(inputId);
navigator.clipboard.writeText(input.value).then(() => {

193
Views/Developer/Mcp.cshtml Normal file
View File

@ -0,0 +1,193 @@
@model QRRapidoApp.Models.User
@{
ViewData["Title"] = "Configurar MCP — QR Rápido";
Layout = "~/Views/Shared/_Layout.cshtml";
var isEn = (ViewBag.Culture as string) == "en";
var activeKey = Model.ApiKeys.FirstOrDefault(k => k.IsActive);
var baseUrl = Context.Request.Scheme + "://" + Context.Request.Host;
string T(string pt, string en) => isEn ? en : pt;
}
<div class="container mt-4 mb-5" style="max-width: 800px;">
<!-- Header -->
<div class="d-flex align-items-center mb-4 gap-3">
<div style="width:48px;height:48px;background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">▦</div>
<div>
<h1 class="h3 mb-0">@T("Conectar ao MCP", "Connect to MCP")</h1>
<p class="text-muted mb-0 small">@T("Configure o QR Rápido no Claude Desktop, n8n ou qualquer HTTP client.", "Set up QR Rápido in Claude Desktop, n8n, or any HTTP client.")</p>
</div>
</div>
@if (activeKey == null)
{
<!-- Sem key — não deve acontecer (auto-criada no primeiro login), mas garante fallback -->
<div class="alert alert-warning mb-4">
<i class="fas fa-exclamation-triangle me-2"></i>
@T("Nenhuma API key ativa encontrada.", "No active API key found.")
<a href="/Developer" class="alert-link ms-1">@T("Criar uma agora →", "Create one now →")</a>
</div>
}
else
{
<!-- Step 1: API Key -->
<div class="card border-0 shadow-sm mb-4" style="background:#0f0f1c;border:1px solid rgba(99,102,241,0.2)!important;">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-2 mb-3">
<span style="background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;width:24px;height:24px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">1</span>
<h5 class="mb-0 text-light">@T("Sua API Key", "Your API Key")</h5>
</div>
<p class="text-muted small mb-3">
@T("Esta key foi gerada automaticamente. Copie e guarde — ela não será exibida novamente após sair desta página.",
"This key was auto-generated. Copy and save it — it won't be shown again after you leave this page.")
</p>
<div class="input-group">
<input type="password" id="apiKeyField" class="form-control font-monospace"
value="@activeKey.Prefix•••••••••••••••••••••••••" readonly
style="background:#1a1a2e;border-color:rgba(99,102,241,0.3);color:#e0e0ff;">
<button class="btn btn-outline-secondary" type="button" onclick="toggleKey()" id="toggleBtn"
title="@T("Mostrar key", "Show key")">
<i class="fas fa-eye"></i>
</button>
<button class="btn" type="button" onclick="copyField('apiKeyField', this)"
style="background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;border:none;">
<i class="fas fa-copy me-1"></i> @T("Copiar", "Copy")
</button>
</div>
<div class="mt-2">
<small class="text-muted">
@T("Plano:", "Plan:") <span class="badge" style="background:rgba(99,102,241,0.2);color:#a5b4fc;">@(Model.ApiSubscription?.EffectiveTier.ToString() ?? "Free")</span>
&nbsp;·&nbsp;
<a href="/Developer" class="text-muted small">@T("Gerenciar todas as keys →", "Manage all keys →")</a>
</small>
</div>
</div>
</div>
<!-- Step 2: Claude Desktop -->
<div class="card border-0 shadow-sm mb-4" style="background:#0f0f1c;border:1px solid rgba(99,102,241,0.2)!important;">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-2 mb-3">
<span style="background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;width:24px;height:24px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">2</span>
<h5 class="mb-0 text-light">@T("Claude Desktop — config.json", "Claude Desktop — config.json")</h5>
</div>
<p class="text-muted small mb-3">
@T("Cole em ", "Paste into ")
<code class="text-info">~/.config/claude/claude_desktop_config.json</code>
@T(" (macOS/Linux) ou ", " (macOS/Linux) or ")
<code class="text-info">%APPDATA%\Claude\claude_desktop_config.json</code>
@T(" (Windows).", " (Windows).")
</p>
<div class="position-relative">
<pre id="mcpSnippet" class="p-3 rounded font-monospace small mb-2"
style="background:#06060e;color:#e0e0ff;border:1px solid rgba(99,102,241,0.2);overflow-x:auto;line-height:1.7;">{
<span style="color:#06b6d4;">"mcpServers"</span>: {
<span style="color:#06b6d4;">"qrrapido"</span>: {
<span style="color:#06b6d4;">"command"</span>: <span style="color:#a5f3fc;">"qrrapido-mcp"</span>,
<span style="color:#06b6d4;">"env"</span>: {
<span style="color:#06b6d4;">"QR_API_KEY"</span>: <span style="color:#fde68a;">"@activeKey.Prefix•••••••••••••"</span>
}
}
}
}</pre>
<button class="btn btn-sm position-absolute top-0 end-0 m-2"
onclick="copyMcpSnippet(this)"
style="background:rgba(99,102,241,0.2);color:#a5b4fc;border:1px solid rgba(99,102,241,0.3);">
<i class="fas fa-copy me-1"></i> @T("Copiar", "Copy")
</button>
</div>
<small class="text-muted">
@T("Reinicie o Claude Desktop após salvar o arquivo.", "Restart Claude Desktop after saving the file.")
</small>
</div>
</div>
<!-- Step 3: REST / n8n -->
<div class="card border-0 shadow-sm mb-4" style="background:#0f0f1c;border:1px solid rgba(99,102,241,0.2)!important;">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-2 mb-3">
<span style="background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;width:24px;height:24px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">3</span>
<h5 class="mb-0 text-light">@T("REST API / n8n / qualquer HTTP client", "REST API / n8n / any HTTP client")</h5>
</div>
<p class="text-muted small mb-3">
@T("Use o header ", "Use the header ")
<code class="text-info">X-API-Key</code>
@T(" em todas as requests.", " in all requests.")
</p>
<pre class="p-3 rounded font-monospace small mb-0"
style="background:#06060e;color:#e0e0ff;border:1px solid rgba(99,102,241,0.2);overflow-x:auto;line-height:1.7;"><span style="color:#9090b0;"># curl</span>
curl -X POST <span style="color:#10b981;">@baseUrl/api/v1/QRManager/generate</span> \
-H <span style="color:#a5f3fc;">"X-API-Key: @activeKey.Prefix•••••••••••••"</span> \
-H <span style="color:#a5f3fc;">"Content-Type: application/json"</span> \
-d '{"type":"url","content":"https://exemplo.com","outputFormat":"png"}'
<span style="color:#9090b0;"># n8n: HTTP Request Node → Header Auth → X-API-Key</span></pre>
</div>
</div>
<!-- Links -->
<div class="d-flex gap-3 flex-wrap">
<a href="/api/docs" target="_blank" class="btn btn-outline-info btn-sm">
<i class="fas fa-book me-1"></i> @T("Documentação API", "API Docs")
</a>
<a href="/Developer/Pricing" class="btn btn-outline-primary btn-sm">
<i class="fas fa-arrow-up me-1"></i> @T("Ver planos", "View plans")
</a>
<a href="/Developer" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-key me-1"></i> @T("Gerenciar keys", "Manage keys")
</a>
</div>
}
</div>
@section Scripts {
<script>
// Real key value — passed securely, never exposed in DOM until user clicks "show"
// The actual key prefix is visible; full key was shown only at creation time.
// Here we show prefix only — user already copied on first login or via /Developer.
function toggleKey() {
var field = document.getElementById('apiKeyField');
var btn = document.getElementById('toggleBtn');
if (field.type === 'password') {
field.type = 'text';
btn.innerHTML = '<i class="fas fa-eye-slash"></i>';
} else {
field.type = 'password';
btn.innerHTML = '<i class="fas fa-eye"></i>';
}
}
function copyField(fieldId, btn) {
var field = document.getElementById(fieldId);
var val = field.value;
navigator.clipboard.writeText(val).then(function () {
var orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check me-1"></i> @T("Copiado!", "Copied!")';
setTimeout(function () { btn.innerHTML = orig; }, 2000);
});
}
function copyMcpSnippet(btn) {
// Build clean JSON without HTML spans
var json = JSON.stringify({
mcpServers: {
qrrapido: {
command: "qrrapido-mcp",
env: {
QR_API_KEY: "@activeKey.Prefix•••••••••••••"
}
}
}
}, null, 2);
navigator.clipboard.writeText(json).then(function () {
var orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check me-1"></i> @T("Copiado!", "Copied!")';
setTimeout(function () { btn.innerHTML = orig; }, 2000);
});
}
</script>
}

View File

@ -17,13 +17,13 @@
"SeqUrl": "http://192.168.0.75:5341",
"ApiKey": "",
"MinimumLevel": {
"Default": "Error",
"Default": "Warning",
"Override": {
"Microsoft": "Error",
"Microsoft.AspNetCore": "Error",
"Microsoft.Hosting.Lifetime": "Error",
"System": "Error",
"QRRapidoApp": "Error"
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning",
"QRRapidoApp": "Information"
}
}
},

View File

@ -56,6 +56,21 @@ services:
- qrrapido-network
restart: unless-stopped
mcp-server:
build:
context: ./mcp-server
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- QR_BASE_URL=https://qrrapido.site
- PORT=3000
networks:
- qrrapido-network
restart: unless-stopped
depends_on:
- qrrapido
nginx:
image: nginx:alpine
container_name: qrrapido-nginx

8
mcp-server/Dockerfile Normal file
View File

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY index.mjs server-http.mjs ./
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "server-http.mjs"]

263
mcp-server/index.mjs Normal file
View File

@ -0,0 +1,263 @@
/**
* 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`);

1148
mcp-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
mcp-server/package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "qrrapido-mcp-server",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "index.mjs",
"scripts": {
"start:stdio": "node index.mjs",
"start:http": "node server-http.mjs",
"start": "node server-http.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"express": "^4.18.0",
"undici": "^6.0.0"
}
}

219
mcp-server/server-http.mjs Normal file
View File

@ -0,0 +1,219 @@
/**
* 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`);
});

738
wwwroot/mcp/en/index.html Normal file
View File

@ -0,0 +1,738 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QR Rápido MCP — QR code generator for AI agents and automations</title>
<meta name="description" content="Generate QR codes via Claude MCP, REST API, n8n, and Make.com. Inline base64 response — no file hosting needed. Built for AI agents, workflows, and automations.">
<meta name="keywords" content="QR code API, MCP, Claude Desktop, n8n, automation, AI agents, Make.com, REST API">
<meta property="og:title" content="QR Rápido MCP — QR code generator for AI agents">
<meta property="og:description" content="The native QR code generator for AI agents and automations. MCP, REST API, n8n, Make.com.">
<meta property="og:url" content="https://qrrapido.site/mcp/en">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://qrrapido.site/mcp/en">
<link rel="alternate" hreflang="pt-BR" href="https://qrrapido.site/mcp">
<link rel="alternate" hreflang="en" href="https://qrrapido.site/mcp/en">
<link rel="alternate" hreflang="x-default" href="https://qrrapido.site/mcp">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=Figtree:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-base: #06060e;
--bg-surface: #0d0d1a;
--bg-elevated: #13131f;
--bg-card: #0f0f1c;
--border: rgba(99, 102, 241, 0.15);
--border-bright: rgba(99, 102, 241, 0.35);
--indigo: #6366f1;
--violet: #8b5cf6;
--emerald: #10b981;
--cyan: #06b6d4;
--text-primary: #eeeef5;
--text-secondary: #9090b0;
--text-muted: #55556a;
--text-code: #e0e0ff;
--font-display: 'Syne', sans-serif;
--font-body: 'Figtree', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, rgba(99,102,241,0.11) 1px, transparent 1px);
background-size: 28px 28px;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
width: 700px; height: 700px;
border-radius: 50%;
background: radial-gradient(circle, rgba(99,102,241,0.07) 0%, transparent 70%);
top: -250px; right: -150px;
pointer-events: none;
z-index: 0;
}
section, nav, footer, .integrations { position: relative; z-index: 1; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 24px; }
/* NAV */
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
padding: 14px 24px;
background: rgba(6, 6, 14, 0.88);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
}
.nav-inner { max-width: 1100px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
.nav-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; font-family: var(--font-display); font-weight: 700; font-size: 17px; color: var(--text-primary); }
.nav-logo-icon { width: 30px; height: 30px; background: linear-gradient(135deg, var(--indigo), var(--violet)); border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 15px; }
.nav-badge { font-size: 10px; font-family: var(--font-mono); background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.3); color: var(--indigo); padding: 2px 8px; border-radius: 4px; letter-spacing: 0.05em; }
.nav-links { display: flex; align-items: center; gap: 4px; }
.nav-link { font-size: 14px; color: var(--text-secondary); text-decoration: none; padding: 7px 13px; border-radius: var(--radius-sm); transition: var(--transition); }
.nav-link:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
.lang-switch { font-size: 12px; font-family: var(--font-mono); color: var(--text-muted); text-decoration: none; padding: 5px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); transition: var(--transition); }
.lang-switch:hover { color: var(--indigo); border-color: var(--border-bright); }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 9px 18px; border-radius: var(--radius-sm); font-family: var(--font-body); font-size: 14px; font-weight: 600; text-decoration: none; transition: var(--transition); cursor: pointer; border: none; }
.btn-primary { background: linear-gradient(135deg, var(--indigo), var(--violet)); color: white; box-shadow: 0 0 20px rgba(99,102,241,0.3); }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 0 32px rgba(99,102,241,0.5); }
.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
.btn-ghost:hover { color: var(--text-primary); border-color: var(--border-bright); background: rgba(99,102,241,0.05); }
.btn-lg { padding: 14px 28px; font-size: 15px; border-radius: var(--radius-md); }
/* HERO */
.hero { padding: 140px 24px 80px; min-height: 100vh; display: flex; align-items: center; }
.hero-inner { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
.hero-tag { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 12px; color: var(--indigo); background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.25); padding: 6px 14px; border-radius: 100px; margin-bottom: 24px; letter-spacing: 0.05em; opacity: 0; animation: fadeUp 0.6s 0.1s forwards; }
.hero-tag-dot { width: 6px; height: 6px; background: var(--emerald); border-radius: 50%; animation: pulse 2s infinite; }
.hero-title { font-family: var(--font-display); font-size: clamp(36px, 4.5vw, 54px); font-weight: 800; line-height: 1.1; letter-spacing: -0.03em; margin-bottom: 20px; opacity: 0; animation: fadeUp 0.6s 0.2s forwards; }
.hero-title .accent { background: linear-gradient(135deg, var(--indigo) 0%, var(--violet) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.hero-subtitle { font-size: 17px; color: var(--text-secondary); line-height: 1.65; margin-bottom: 36px; font-weight: 300; opacity: 0; animation: fadeUp 0.6s 0.3s forwards; }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; opacity: 0; animation: fadeUp 0.6s 0.4s forwards; }
.hero-stats { display: flex; gap: 32px; margin-top: 40px; padding-top: 32px; border-top: 1px solid var(--border); opacity: 0; animation: fadeUp 0.6s 0.5s forwards; }
.stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; }
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
/* TERMINAL */
.terminal { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: 0 40px 80px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(99,102,241,0.08); opacity: 0; animation: fadeIn 0.8s 0.5s forwards; }
.terminal-header { display: flex; align-items: center; gap: 8px; padding: 13px 16px; background: rgba(0,0,0,0.35); border-bottom: 1px solid var(--border); }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot-r { background: #ff5f57; } .dot-y { background: #febc2e; } .dot-g { background: #28c840; }
.terminal-title { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); margin-left: 8px; }
.terminal-body { padding: 20px; font-family: var(--font-mono); font-size: 13px; line-height: 1.9; min-height: 340px; }
.t-prompt { color: var(--indigo); } .t-tool { color: var(--violet); } .t-key { color: var(--cyan); }
.t-string { color: #a5f3fc; } .t-success { color: var(--emerald); } .t-value { color: #fde68a; }
.t-dim { color: var(--text-muted); } .t-comment { color: var(--text-muted); font-style: italic; }
.terminal-line { display: block; opacity: 0; transition: opacity 0.25s ease; }
.cursor { display: inline-block; width: 8px; height: 14px; background: var(--indigo); vertical-align: middle; animation: blink 1s infinite; margin-left: 2px; }
/* INTEGRATIONS */
.integrations { padding: 28px 24px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: rgba(13,13,26,0.7); }
.integrations-inner { max-width: 1100px; margin: 0 auto; display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
.integrations-label { font-size: 11px; font-family: var(--font-mono); color: var(--text-muted); letter-spacing: 0.12em; white-space: nowrap; }
.integrations-list { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.integration-badge { display: flex; align-items: center; gap: 7px; padding: 7px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: var(--transition); }
.integration-badge:hover { border-color: var(--border-bright); color: var(--text-primary); background: rgba(99,102,241,0.06); }
/* SECTIONS */
.section { padding: 100px 24px; }
.section-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.15em; color: var(--indigo); margin-bottom: 14px; text-transform: uppercase; }
.section-title { font-family: var(--font-display); font-size: clamp(28px, 3.5vw, 42px); font-weight: 700; line-height: 1.15; letter-spacing: -0.02em; margin-bottom: 14px; }
.section-sub { font-size: 17px; color: var(--text-secondary); max-width: 520px; font-weight: 300; margin-bottom: 56px; line-height: 1.6; }
/* HOW IT WORKS */
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; }
.step { position: relative; }
.step-number { font-family: var(--font-display); font-size: 60px; font-weight: 800; color: rgba(99,102,241,0.07); line-height: 1; margin-bottom: -12px; user-select: none; }
.step-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 28px; transition: var(--transition); }
.step-card:hover { border-color: var(--border-bright); transform: translateY(-4px); box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
.step-icon { width: 44px; height: 44px; background: linear-gradient(135deg, rgba(99,102,241,0.18), rgba(139,92,246,0.18)); border: 1px solid rgba(99,102,241,0.28); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-size: 20px; margin-bottom: 16px; }
.step-title { font-family: var(--font-display); font-size: 18px; font-weight: 700; margin-bottom: 8px; }
.step-desc { font-size: 14px; color: var(--text-secondary); line-height: 1.6; }
.step-arrow { position: absolute; top: 72px; right: -18px; color: var(--text-muted); font-size: 22px; z-index: 2; }
/* CODE */
.code-showcase { background: rgba(13,13,26,0.5); }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 4px; width: fit-content; }
.tab-btn { font-family: var(--font-mono); font-size: 12px; padding: 8px 16px; border-radius: var(--radius-sm); border: none; cursor: pointer; background: transparent; color: var(--text-muted); transition: var(--transition); }
.tab-btn.active { background: linear-gradient(135deg, var(--indigo), var(--violet)); color: white; }
.tab-btn:hover:not(.active) { color: var(--text-secondary); background: rgba(255,255,255,0.05); }
.tab-content { display: none; } .tab-content.active { display: block; }
.code-window { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.code-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: rgba(0,0,0,0.3); border-bottom: 1px solid var(--border); }
.code-header-left { display: flex; align-items: center; gap: 8px; }
.code-dots { display: flex; gap: 6px; }
.code-filename { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); margin-left: 8px; }
.copy-btn { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); background: transparent; border: 1px solid var(--border); padding: 4px 10px; border-radius: 4px; cursor: pointer; transition: var(--transition); }
.copy-btn:hover { color: var(--indigo); border-color: rgba(99,102,241,0.4); }
pre { padding: 24px; overflow-x: auto; font-family: var(--font-mono); font-size: 13px; line-height: 1.85; color: var(--text-code); }
.hl-key { color: var(--cyan); } .hl-string { color: #a5f3fc; } .hl-number { color: #fde68a; }
.hl-bool { color: #f9a8d4; } .hl-comment { color: var(--text-muted); } .hl-url { color: var(--emerald); }
.n8n-mockup { padding: 24px; display: flex; flex-direction: column; gap: 14px; }
.n8n-node { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); border-radius: var(--radius-md); padding: 16px; }
.n8n-node-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.n8n-node-icon { width: 30px; height: 30px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-size: 15px; }
.n8n-node-title { font-family: var(--font-display); font-size: 14px; font-weight: 600; }
.n8n-field { display: grid; grid-template-columns: 130px 1fr; gap: 8px; align-items: center; margin-bottom: 8px; }
.n8n-field-label { font-size: 12px; color: var(--text-muted); }
.n8n-field-value { font-family: var(--font-mono); font-size: 12px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; color: #a5f3fc; }
/* QR TYPES */
.qr-types-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.qr-type-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 24px 18px; text-align: center; transition: var(--transition); }
.qr-type-card:hover { border-color: var(--border-bright); background: var(--bg-elevated); transform: translateY(-2px); }
.qr-type-icon { font-size: 28px; margin-bottom: 12px; display: block; }
.qr-type-name { font-family: var(--font-display); font-size: 15px; font-weight: 700; margin-bottom: 6px; }
.qr-type-badge { font-family: var(--font-mono); font-size: 11px; color: var(--indigo); background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); padding: 2px 8px; border-radius: 4px; margin-bottom: 8px; display: inline-block; }
.qr-type-desc { font-size: 12px; color: var(--text-muted); }
/* PRICING */
.pricing-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
.pricing-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 28px 22px; position: relative; transition: var(--transition); }
.pricing-card:hover { border-color: var(--border-bright); transform: translateY(-4px); box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
.pricing-card.featured { border-color: rgba(99,102,241,0.4); background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04)); box-shadow: 0 0 0 1px rgba(99,102,241,0.1), 0 20px 40px rgba(99,102,241,0.08); }
.pricing-featured-badge { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, var(--indigo), var(--violet)); color: white; font-size: 11px; font-weight: 600; padding: 4px 14px; border-radius: 100px; white-space: nowrap; }
.pricing-tier { font-family: var(--font-mono); font-size: 11px; color: var(--indigo); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 10px; }
.pricing-price { font-family: var(--font-display); font-size: 34px; font-weight: 800; margin-bottom: 4px; line-height: 1; }
.pricing-price .currency { font-size: 17px; font-weight: 400; color: var(--text-secondary); vertical-align: super; }
.pricing-price .period { font-size: 13px; font-weight: 400; color: var(--text-muted); }
.pricing-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; margin-top: 6px; }
.pricing-divider { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
.pricing-features { list-style: none; display: flex; flex-direction: column; gap: 9px; margin-bottom: 22px; }
.pricing-feature { display: flex; align-items: flex-start; gap: 9px; font-size: 13px; color: var(--text-secondary); }
.pricing-feature::before { content: '✓'; color: var(--emerald); font-size: 12px; flex-shrink: 0; margin-top: 2px; }
.pricing-cta { width: 100%; text-align: center; padding: 10px; border-radius: var(--radius-sm); font-family: var(--font-body); font-size: 13px; font-weight: 600; cursor: pointer; text-decoration: none; display: block; transition: var(--transition); }
.pricing-cta-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-secondary); }
.pricing-cta-ghost:hover { border-color: var(--border-bright); color: var(--text-primary); }
.pricing-cta-primary { background: linear-gradient(135deg, var(--indigo), var(--violet)); border: none; color: white; }
.pricing-cta-primary:hover { box-shadow: 0 0 20px rgba(99,102,241,0.4); transform: translateY(-1px); }
/* RESPONSE */
.response-section { background: rgba(13,13,26,0.5); }
.response-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: start; }
.response-features { display: flex; flex-direction: column; gap: 22px; }
.response-feature { display: flex; gap: 16px; }
.response-feature-icon { width: 40px; height: 40px; background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.response-feature-title { font-family: var(--font-display); font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.response-feature-desc { font-size: 13px; color: var(--text-secondary); line-height: 1.55; }
/* FINAL CTA */
.final-cta { padding: 120px 24px; text-align: center; position: relative; overflow: hidden; }
.final-cta::before { content: ''; position: absolute; width: 700px; height: 700px; border-radius: 50%; background: radial-gradient(circle, rgba(99,102,241,0.13) 0%, transparent 70%); left: 50%; top: 50%; transform: translate(-50%, -50%); pointer-events: none; }
.final-cta-title { font-family: var(--font-display); font-size: clamp(32px, 5vw, 52px); font-weight: 800; letter-spacing: -0.03em; margin-bottom: 16px; }
.final-cta-sub { font-size: 18px; color: var(--text-secondary); margin-bottom: 40px; font-weight: 300; }
/* FOOTER */
footer { padding: 28px 24px; border-top: 1px solid var(--border); }
.footer-inner { max-width: 1100px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.footer-links { display: flex; gap: 20px; }
.footer-link { font-size: 13px; color: var(--text-muted); text-decoration: none; transition: var(--transition); }
.footer-link:hover { color: var(--text-secondary); }
.footer-copy { font-size: 13px; color: var(--text-muted); }
/* ANIMATIONS */
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.8); } }
.reveal { opacity: 0; transform: translateY(22px); transition: opacity 0.65s ease, transform 0.65s ease; }
.reveal.visible { opacity: 1; transform: translateY(0); }
/* RESPONSIVE */
@media (max-width: 900px) {
.hero-inner { grid-template-columns: 1fr; }
.terminal { display: none; }
.steps { grid-template-columns: 1fr; }
.step-arrow { display: none; }
.qr-types-grid { grid-template-columns: repeat(2, 1fr); }
.pricing-grid { grid-template-columns: repeat(2, 1fr); }
.response-grid { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.nav-links { display: none; }
.qr-types-grid { grid-template-columns: repeat(2, 1fr); }
.pricing-grid { grid-template-columns: 1fr; }
.hero-stats { flex-wrap: wrap; gap: 20px; }
.tabs { width: 100%; }
.tab-btn { flex: 1; text-align: center; }
}
</style>
</head>
<body>
<!-- NAV -->
<nav>
<div class="nav-inner">
<a href="https://qrrapido.site" class="nav-logo">
<div class="nav-logo-icon"></div>
QR Rápido
<span class="nav-badge">MCP</span>
</a>
<div class="nav-links">
<a href="#how-it-works" class="nav-link">How it works</a>
<a href="#qr-types" class="nav-link">QR Types</a>
<a href="#pricing" class="nav-link">Pricing</a>
<a href="https://qrrapido.site/api/docs" class="nav-link">API Docs</a>
<a href="/mcp" class="lang-switch">🇧🇷 PT</a>
<a href="/Account/Login?returnUrl=/Developer?new=1" class="btn btn-primary">Start free →</a>
</div>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<div class="hero-inner">
<div class="hero-content">
<div class="hero-tag">
<div class="hero-tag-dot"></div>
MCP · REST API · n8n · Make.com
</div>
<h1 class="hero-title">
QR codes for<br>
<span class="accent">AI agents</span><br>
and automations
</h1>
<p class="hero-subtitle">
Generate QR codes via MCP, REST API, or any HTTP client.
<strong style="color: var(--text-primary); font-weight: 500;">Inline base64 response</strong>
no upload, no file hosting, ready to use instantly.
</p>
<div class="hero-actions">
<a href="/Account/Login?returnUrl=/Developer?new=1" class="btn btn-primary btn-lg">Start for free →</a>
<a href="https://qrrapido.site/api/docs" class="btn btn-ghost btn-lg">View API docs</a>
</div>
<div class="hero-stats">
<div class="stat">
<div class="stat-value">&lt;0.4s</div>
<div class="stat-label">avg generation</div>
</div>
<div class="stat">
<div class="stat-value">8 types</div>
<div class="stat-label">of QR code</div>
</div>
<div class="stat">
<div class="stat-value">base64</div>
<div class="stat-label">inline, no hosting</div>
</div>
</div>
</div>
<div class="terminal" id="hero-terminal">
<div class="terminal-header">
<div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div>
<span class="terminal-title">claude — MCP Tool Call</span>
</div>
<div class="terminal-body" id="terminal-body"></div>
</div>
</div>
</section>
<!-- INTEGRATIONS -->
<div class="integrations">
<div class="integrations-inner">
<span class="integrations-label">WORKS WITH</span>
<div class="integrations-list">
<div class="integration-badge"><span>🤖</span> Claude Desktop</div>
<div class="integration-badge"><span>🔄</span> n8n</div>
<div class="integration-badge"><span>⚙️</span> Make.com</div>
<div class="integration-badge"><span></span> Zapier</div>
<div class="integration-badge"><span>🔌</span> REST API</div>
<div class="integration-badge"><span>📡</span> Any HTTP client</div>
</div>
</div>
</div>
<!-- HOW IT WORKS -->
<section class="section" id="how-it-works">
<div class="container">
<div class="reveal">
<div class="section-label">// how it works</div>
<h2 class="section-title">Three steps to start<br>generating</h2>
<p class="section-sub">From zero to QR codes in your agent in under 2 minutes. No credit card.</p>
</div>
<div class="steps reveal">
<div class="step">
<div class="step-number">01</div>
<div class="step-card">
<div class="step-icon">👤</div>
<div class="step-title">Create your account</div>
<p class="step-desc">Free login with Google or Microsoft. Free plan with 500 req/month included. No credit card required.</p>
</div>
<div class="step-arrow"></div>
</div>
<div class="step">
<div class="step-number">02</div>
<div class="step-card">
<div class="step-icon">🔑</div>
<div class="step-title">Copy your API key</div>
<p class="step-desc">Your key is auto-generated on first login. Displayed prominently — copy with one click, snippet ready.</p>
</div>
<div class="step-arrow"></div>
</div>
<div class="step">
<div class="step-number">03</div>
<div class="step-card">
<div class="step-icon"></div>
<div class="step-title">Connect to your agent</div>
<p class="step-desc">Paste into Claude Desktop config, n8n HTTP node, or any client. Done — your agent generates QR codes.</p>
</div>
</div>
</div>
</div>
</section>
<!-- CODE SHOWCASE -->
<section class="section code-showcase">
<div class="container">
<div class="reveal">
<div class="section-label">// integration</div>
<h2 class="section-title">Connect however<br>you prefer</h2>
<p class="section-sub">Native MCP, REST API, or HTTP Request — the same API serves all clients.</p>
</div>
<div class="reveal">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab(event,'mcp')">Claude MCP</button>
<button class="tab-btn" onclick="switchTab(event,'rest')">REST API</button>
<button class="tab-btn" onclick="switchTab(event,'n8n')">n8n</button>
</div>
<div class="tab-content active" id="tab-mcp">
<div class="code-window">
<div class="code-header">
<div class="code-header-left">
<div class="code-dots"><div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div></div>
<span class="code-filename">claude_desktop_config.json</span>
</div>
<button class="copy-btn" onclick="copyCode('mcp-code', this)">copy</button>
</div>
<pre id="mcp-code"><span class="hl-comment">// ~/.config/claude/claude_desktop_config.json</span>
{
<span class="hl-key">"mcpServers"</span>: {
<span class="hl-key">"qrrapido"</span>: {
<span class="hl-key">"command"</span>: <span class="hl-string">"qrrapido-mcp"</span>,
<span class="hl-key">"env"</span>: {
<span class="hl-key">"QR_API_KEY"</span>: <span class="hl-string">"qr_your_key_here"</span>
}
}
}
}
<span class="hl-comment">// Claude Desktop now has access to tools:</span>
<span class="hl-comment">// → generate_qr(type, content, format, color, size)</span>
<span class="hl-comment">// → list_qr_history()</span>
<span class="hl-comment">// → get_qr_analytics(trackingId)</span></pre>
</div>
</div>
<div class="tab-content" id="tab-rest">
<div class="code-window">
<div class="code-header">
<div class="code-header-left">
<div class="code-dots"><div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div></div>
<span class="code-filename">terminal</span>
</div>
<button class="copy-btn" onclick="copyCode('rest-code', this)">copy</button>
</div>
<pre id="rest-code"><span class="hl-comment"># Generate a QR code via REST API</span>
curl -X POST <span class="hl-url">https://qrrapido.site/api/v1/QRManager/generate</span> \
-H <span class="hl-string">"X-API-Key: qr_your_key_here"</span> \
-H <span class="hl-string">"Content-Type: application/json"</span> \
-d '{
<span class="hl-key">"type"</span>: <span class="hl-string">"url"</span>,
<span class="hl-key">"content"</span>: <span class="hl-string">"https://my-store.com/product/123"</span>,
<span class="hl-key">"outputFormat"</span>: <span class="hl-string">"png"</span>,
<span class="hl-key">"size"</span>: <span class="hl-number">400</span>
}'
<span class="hl-comment"># Response: JSON with inline qrCodeBase64</span>
<span class="hl-comment"># No upload. No CDN. Ready to use.</span></pre>
</div>
</div>
<div class="tab-content" id="tab-n8n">
<div class="code-window">
<div class="code-header">
<div class="code-header-left">
<div class="code-dots"><div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div></div>
<span class="code-filename">n8n — HTTP Request Node</span>
</div>
</div>
<div class="n8n-mockup">
<div class="n8n-node">
<div class="n8n-node-header">
<div class="n8n-node-icon" style="background:rgba(255,102,0,0.15);border:1px solid rgba(255,102,0,0.3);">🔄</div>
<div class="n8n-node-title">HTTP Request</div>
</div>
<div class="n8n-field"><span class="n8n-field-label">Method</span><span class="n8n-field-value">POST</span></div>
<div class="n8n-field"><span class="n8n-field-label">URL</span><span class="n8n-field-value" style="color:#4ade80;font-size:11px;">https://qrrapido.site/api/v1/QRManager/generate</span></div>
<div class="n8n-field"><span class="n8n-field-label">Auth Type</span><span class="n8n-field-value">Header Auth</span></div>
<div class="n8n-field"><span class="n8n-field-label">Header Name</span><span class="n8n-field-value">X-API-Key</span></div>
<div class="n8n-field"><span class="n8n-field-label">Header Value</span><span class="n8n-field-value">qr_your_key_here</span></div>
<div class="n8n-field"><span class="n8n-field-label">Body (JSON)</span><span class="n8n-field-value">{"type":"url","content":"{{ $json.url }}"}</span></div>
</div>
<div class="n8n-node" style="background:rgba(16,185,129,0.05);border-color:rgba(16,185,129,0.15);">
<div class="n8n-node-header">
<div class="n8n-node-icon" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.3);"></div>
<div class="n8n-node-title" style="color:#10b981;">Output available</div>
</div>
<p style="font-size:12px;color:var(--text-muted);line-height:1.6;">
Use <code style="color:#a5f3fc;font-family:var(--font-mono);">{{ $json.qrCodeBase64 }}</code> in downstream nodes
to send via email, save to file, or return via webhook — no external URL needed.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- QR TYPES -->
<section class="section" id="qr-types">
<div class="container">
<div class="reveal">
<div class="section-label">// supported types</div>
<h2 class="section-title">8 QR code types,<br>one single endpoint</h2>
<p class="section-sub">Pass <code style="font-family:var(--font-mono);color:var(--indigo);font-size:15px;">"type"</code> in the payload and QR Rápido handles the correct format.</p>
</div>
<div class="qr-types-grid reveal">
<div class="qr-type-card"><span class="qr-type-icon">🔗</span><div class="qr-type-name">URL</div><div class="qr-type-badge">"url"</div><p class="qr-type-desc">Links, products, landing pages</p></div>
<div class="qr-type-card"><span class="qr-type-icon">💸</span><div class="qr-type-name">PIX</div><div class="qr-type-badge">"pix"</div><p class="qr-type-desc">Brazilian instant payments</p></div>
<div class="qr-type-card"><span class="qr-type-icon">📶</span><div class="qr-type-name">Wi-Fi</div><div class="qr-type-badge">"wifi"</div><p class="qr-type-desc">Wireless network credentials</p></div>
<div class="qr-type-card"><span class="qr-type-icon">👤</span><div class="qr-type-name">vCard</div><div class="qr-type-badge">"vcard"</div><p class="qr-type-desc">Digital business cards</p></div>
<div class="qr-type-card"><span class="qr-type-icon">💬</span><div class="qr-type-name">WhatsApp</div><div class="qr-type-badge">"whatsapp"</div><p class="qr-type-desc">Pre-filled messages</p></div>
<div class="qr-type-card"><span class="qr-type-icon">📧</span><div class="qr-type-name">Email</div><div class="qr-type-badge">"email"</div><p class="qr-type-desc">Direct email composition</p></div>
<div class="qr-type-card"><span class="qr-type-icon">📱</span><div class="qr-type-name">SMS</div><div class="qr-type-badge">"sms"</div><p class="qr-type-desc">Pre-defined SMS text</p></div>
<div class="qr-type-card"><span class="qr-type-icon">📝</span><div class="qr-type-name">Text</div><div class="qr-type-badge">"texto"</div><p class="qr-type-desc">Any free-form text content</p></div>
</div>
</div>
</section>
<!-- RESPONSE FORMAT -->
<section class="section response-section">
<div class="container">
<div class="response-grid">
<div>
<div class="reveal">
<div class="section-label">// api response</div>
<h2 class="section-title">Inline base64.<br>No hosting.</h2>
<p class="section-sub">QR code returned directly in the JSON. Your agent receives it, uses it, moves on — no external URL, no upload.</p>
</div>
<div class="response-features reveal">
<div class="response-feature">
<div class="response-feature-icon">📦</div>
<div>
<div class="response-feature-title">Image in JSON</div>
<p class="response-feature-desc">Base64 embedded in the response. Agents use it directly — no dependency on external URLs or CDN.</p>
</div>
</div>
<div class="response-feature">
<div class="response-feature-icon"></div>
<div>
<div class="response-feature-title">Automatic cache</div>
<p class="response-feature-desc">Identical QRs return from cache in milliseconds. <code style="font-family:var(--font-mono);color:var(--indigo);font-size:12px;">fromCache: true</code> signals a hit.</p>
</div>
</div>
<div class="response-feature">
<div class="response-feature-icon">🎯</div>
<div>
<div class="response-feature-title">3 output formats</div>
<p class="response-feature-desc">PNG, WebP (~40% smaller), or vector SVG. Choose per request via <code style="font-family:var(--font-mono);color:var(--indigo);font-size:12px;">outputFormat</code>.</p>
</div>
</div>
</div>
</div>
<div class="reveal">
<div class="code-window">
<div class="code-header">
<div class="code-header-left">
<div class="code-dots"><div class="dot dot-r"></div><div class="dot dot-y"></div><div class="dot dot-g"></div></div>
<span class="code-filename">response.json</span>
</div>
<button class="copy-btn" onclick="copyCode('response-code', this)">copy</button>
</div>
<pre id="response-code">{
<span class="hl-key">"success"</span>: <span class="hl-bool">true</span>,
<span class="hl-key">"qrCodeBase64"</span>: <span class="hl-string">"iVBORw0KGgoAAAANSUhEUgAA..."</span>,
<span class="hl-key">"qrId"</span>: <span class="hl-string">"6842a1f3b8c94d2e..."</span>,
<span class="hl-key">"format"</span>: <span class="hl-string">"png"</span>,
<span class="hl-key">"mimeType"</span>: <span class="hl-string">"image/png"</span>,
<span class="hl-key">"generationTimeMs"</span>: <span class="hl-number">312</span>,
<span class="hl-key">"fromCache"</span>: <span class="hl-bool">false</span>,
<span class="hl-key">"remainingCredits"</span>: <span class="hl-number">487</span>,
<span class="hl-key">"message"</span>: <span class="hl-string">"QR generated successfully"</span>
}</pre>
</div>
</div>
</div>
</div>
</section>
<!-- PRICING -->
<section class="section" id="pricing">
<div class="container">
<div class="reveal" style="text-align:center;margin-bottom:60px;">
<div class="section-label">// plans</div>
<h2 class="section-title">Start free.<br>Scale when you need.</h2>
<p class="section-sub" style="margin:0 auto;">No credit card to start. Upgrade anytime.</p>
</div>
<div class="pricing-grid reveal">
<div class="pricing-card">
<div class="pricing-tier">FREE</div>
<div class="pricing-price">Free</div>
<p class="pricing-desc">For testing and small projects</p>
<hr class="pricing-divider">
<ul class="pricing-features">
<li class="pricing-feature">500 req/month</li>
<li class="pricing-feature">10 req/minute</li>
<li class="pricing-feature">All 8 QR types</li>
<li class="pricing-feature">PNG, WebP, SVG</li>
<li class="pricing-feature">Automatic cache</li>
</ul>
<a href="/Account/Login?returnUrl=/Developer?new=1" class="pricing-cta pricing-cta-ghost">Start free</a>
</div>
<div class="pricing-card">
<div class="pricing-tier">STARTER</div>
<div class="pricing-price"><span class="currency">R$</span>39<span class="period">/mo</span></div>
<p class="pricing-desc">For automations and workflows</p>
<hr class="pricing-divider">
<ul class="pricing-features">
<li class="pricing-feature">10,000 req/month</li>
<li class="pricing-feature">50 req/minute</li>
<li class="pricing-feature">All 8 QR types</li>
<li class="pricing-feature">PNG, WebP, SVG</li>
<li class="pricing-feature">Email support</li>
</ul>
<a href="https://qrrapido.site/Developer/Pricing" class="pricing-cta pricing-cta-ghost">Get Starter</a>
</div>
<div class="pricing-card featured">
<div class="pricing-featured-badge">Most popular</div>
<div class="pricing-tier">PRO</div>
<div class="pricing-price"><span class="currency">R$</span>119<span class="period">/mo</span></div>
<p class="pricing-desc">For agents and production products</p>
<hr class="pricing-divider">
<ul class="pricing-features">
<li class="pricing-feature">100,000 req/month</li>
<li class="pricing-feature">200 req/minute</li>
<li class="pricing-feature">Dynamic QR + tracking</li>
<li class="pricing-feature">Scan analytics</li>
<li class="pricing-feature">Priority support</li>
</ul>
<a href="https://qrrapido.site/Developer/Pricing" class="pricing-cta pricing-cta-primary">Get Pro</a>
</div>
<div class="pricing-card">
<div class="pricing-tier">BUSINESS</div>
<div class="pricing-price"><span class="currency">R$</span>349<span class="period">/mo</span></div>
<p class="pricing-desc">For high volume and teams</p>
<hr class="pricing-divider">
<ul class="pricing-features">
<li class="pricing-feature">500,000 req/month</li>
<li class="pricing-feature">500 req/minute</li>
<li class="pricing-feature">Dynamic QR + tracking</li>
<li class="pricing-feature">5 simultaneous API keys</li>
<li class="pricing-feature">SLA + dedicated support</li>
</ul>
<a href="https://qrrapido.site/Developer/Pricing" class="pricing-cta pricing-cta-ghost">Get Business</a>
</div>
</div>
</div>
</section>
<!-- FINAL CTA -->
<section class="final-cta">
<div class="container">
<div class="reveal">
<h2 class="final-cta-title">
Up and running in<br>
<span style="background:linear-gradient(135deg,var(--indigo),var(--violet));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">2 minutes</span>
</h2>
<p class="final-cta-sub">Free login. Key auto-generated. Snippet ready to copy.</p>
<a href="/Account/Login?returnUrl=/Developer?new=1" class="btn btn-primary btn-lg">Create free account →</a>
</div>
</div>
</section>
<!-- FOOTER -->
<footer>
<div class="footer-inner">
<a href="https://qrrapido.site" class="nav-logo" style="font-size:15px;">
<div class="nav-logo-icon" style="width:26px;height:26px;font-size:13px;"></div>
QR Rápido
</a>
<div class="footer-links">
<a href="https://qrrapido.site/api/docs" class="footer-link">API Docs</a>
<a href="https://qrrapido.site/Developer/Pricing" class="footer-link">Pricing</a>
<a href="https://qrrapido.site/pt-BR/Home/TermosDeUso" class="footer-link">Terms</a>
<a href="https://qrrapido.site/pt-BR/Home/PoliticaDePrivacidade" class="footer-link">Privacy</a>
<a href="/mcp" class="footer-link">🇧🇷 Versão PT</a>
</div>
<p class="footer-copy">© 2025 QR Rápido · Made in Brazil 🇧🇷</p>
</div>
</footer>
<script>
const lines = [
{ html: '<span class="t-prompt"></span> Tool: <span class="t-tool">generate_qr</span>', delay: 400 },
{ html: '', delay: 600 },
{ html: ' <span class="t-key">type</span>: <span class="t-string">"url"</span>', delay: 800 },
{ html: ' <span class="t-key">content</span>: <span class="t-string">"https://my-store.com"</span>', delay: 1050 },
{ html: ' <span class="t-key">format</span>: <span class="t-string">"png"</span>', delay: 1300 },
{ html: ' <span class="t-key">size</span>: <span class="t-value">400</span>', delay: 1550 },
{ html: '', delay: 1750 },
{ html: '<span class="t-dim">⠸ Generating QR code...</span>', delay: 1950 },
{ html: '', delay: 2800 },
{ html: '<span class="t-success">✓ Done in 312ms</span>', delay: 3000 },
{ html: '', delay: 3200 },
{ html: ' <span class="t-key">qrCodeBase64</span>: <span class="t-string">"iVBORw0KGgo..."</span>', delay: 3400 },
{ html: ' <span class="t-key">fromCache</span>: <span class="t-value">false</span>', delay: 3600 },
{ html: ' <span class="t-key">generationTimeMs</span>: <span class="t-value">312</span>', delay: 3800 },
{ html: ' <span class="t-key">remainingCredits</span>: <span class="t-value">487</span>', delay: 4000 },
{ html: '', delay: 4200 },
{ html: '<span class="t-comment">// inline base64 — no hosting, no upload</span>', delay: 4400 },
];
function animateTerminal() {
const body = document.getElementById('terminal-body');
if (!body) return;
lines.forEach((line, i) => {
setTimeout(() => {
const el = document.createElement('span');
el.className = 'terminal-line';
el.innerHTML = line.html || '&nbsp;';
body.appendChild(el);
requestAnimationFrame(() => { el.style.opacity = '1'; });
if (i === lines.length - 1) {
setTimeout(() => {
const cursor = document.createElement('span');
cursor.className = 'cursor';
body.appendChild(cursor);
}, 300);
}
}, line.delay);
});
}
function switchTab(e, tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
e.target.classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
}
function copyCode(id, btn) {
const text = document.getElementById(id).innerText;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'copied!';
btn.style.color = 'var(--emerald)';
setTimeout(() => { btn.textContent = 'copy'; btn.style.color = ''; }, 2000);
});
}
const observer = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
}, { threshold: 0.08 });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
window.addEventListener('load', animateTerminal);
</script>
</body>
</html>

1576
wwwroot/mcp/index.html Normal file

File diff suppressed because it is too large Load Diff