local-model-suitability-mcp 1.1.18 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.20] - 2026-06-20
4
+ - feat: email notification on free tier gate hit
5
+
6
+ ## [1.1.19] - 2026-06-18
7
+ - feat: revoke API key on Stripe refund
8
+
3
9
  ## [1.1.18] - 2026-06-17
4
10
  - fix: Resend fetch now logs HTTP error responses; was silently swallowing non-2xx failures
5
11
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "local-model-suitability-mcp",
3
3
  "mcpName": "io.github.OjasKord/local-model-suitability-mcp",
4
- "version": "1.1.18",
4
+ "version": "1.1.20",
5
5
  "description": "AI model router for agents. Checks whether a local model can handle the task before calling cloud inference. LOCAL/CLOUD verdict saves cost on every call.",
6
6
  "main": "src/server.js",
7
7
  "type": "module",
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import { createHmac, timingSafeEqual } from 'crypto';
3
3
  import { readFileSync, writeFileSync } from 'fs';
4
4
  import Anthropic from '@anthropic-ai/sdk';
5
5
 
6
- const VERSION = '1.1.18';
6
+ const VERSION = '1.1.20';
7
7
  const PRO_UPGRADE_URL = 'https://buy.stripe.com/cNibJ08wd7zf6NS0h2ebu0p';
8
8
  const ENTERPRISE_UPGRADE_URL = 'https://buy.stripe.com/28E9AS27PbPvfkoe7Sebu0q';
9
9
  const ALLOWED_PAYMENT_LINK_IDS = ['plink_1TQzCBD6WvRe6sn3H1q5t2LF', 'plink_1TQzDSD6WvRe6sn3UM2G1EgX'];
@@ -105,6 +105,23 @@ function checkAccess(ip, apiKey) {
105
105
  return { allowed: true, tier: 'free', remaining, count };
106
106
  }
107
107
 
108
+ function truncateIp(ip) {
109
+ const parts = (ip || '').split('.');
110
+ return parts.length === 4 ? parts.slice(0, 3).join('.') + '.0' : ip;
111
+ }
112
+
113
+ function notifyGateHit(serverName, ip, toolName, totalCalls, stripeUrl) {
114
+ if (!process.env.RESEND_API_KEY) return;
115
+ const maskedIp = truncateIp(ip);
116
+ const html = '<p>Server: ' + serverName + '</p><p>IP: ' + maskedIp + '</p><p>Tool: ' + (toolName || 'unknown') + '</p><p>Calls this month: ' + totalCalls + '</p><p>Time: ' + new Date().toISOString() + '</p><p>Upgrade: ' + stripeUrl + '</p>';
117
+ fetch('https://api.resend.com/emails', {
118
+ method: 'POST',
119
+ headers: { 'Authorization': `Bearer ${process.env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ from: 'Kord Agencies <ojas@kordagencies.com>', to: 'ojas@kordagencies.com', subject: '[Gate Hit] ' + serverName + ' — ' + maskedIp + ' hit free tier limit', html })
121
+ }).then(r => { if (!r.ok) r.text().then(t => console.error('[GateNotify] failed: HTTP ' + r.status + ' ' + t)); })
122
+ .catch(e => console.error('[GateNotify] network error:', e.message));
123
+ }
124
+
108
125
  function logCall(tool, tier, ip) {
109
126
  stats.tool_usage[tool] = (stats.tool_usage[tool] || 0) + 1;
110
127
  stats.recent_calls.push({ tool, tier, time: nowISO(), ip });
@@ -161,6 +178,26 @@ async function redisKeys(pattern) {
161
178
  } catch(e) { return []; }
162
179
  }
163
180
 
181
+ async function redisDelete(key) {
182
+ try {
183
+ const res = await fetch(
184
+ `${UPSTASH_URL}/del/${encodeURIComponent(key)}`,
185
+ { method: 'POST', headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
186
+ );
187
+ const data = await res.json();
188
+ if (data.error) console.error('[Redis] redisDelete error:', data.error, 'key:', key);
189
+ } catch(e) { console.error('[Redis] redisDelete failed:', e); }
190
+ }
191
+
192
+ async function findCheckoutSessionEmail(paymentIntentId) {
193
+ const res = await fetch(
194
+ `https://api.stripe.com/v1/checkout/sessions?payment_intent=${encodeURIComponent(paymentIntentId)}`,
195
+ { headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` } }
196
+ );
197
+ const data = await res.json();
198
+ return data.data?.[0]?.customer_details?.email || data.data?.[0]?.customer_email || null;
199
+ }
200
+
164
201
  async function appendSessionLog(ip, tool) {
165
202
  try {
166
203
  const ipSafe = ip.replace(/:/g, '_').replace(/\s/g, '');
@@ -411,6 +448,41 @@ async function handleStripeWebhook(body, sig) {
411
448
  }
412
449
  }
413
450
 
451
+ if (event.type === 'charge.refunded') {
452
+ if (!process.env.STRIPE_SECRET_KEY) {
453
+ console.error('[lms] STRIPE_SECRET_KEY not set — cannot revoke key on refund');
454
+ return { received: true, ignored: true, status: 200 };
455
+ }
456
+ const paymentIntentId = event.data.object.payment_intent;
457
+ if (!paymentIntentId) {
458
+ console.log('[lms] charge.refunded missing payment_intent — ignoring.');
459
+ return { received: true, ignored: true, status: 200 };
460
+ }
461
+ try {
462
+ const email = await findCheckoutSessionEmail(paymentIntentId);
463
+ if (!email) {
464
+ console.log('[lms] No checkout session/email found for refunded payment_intent ' + paymentIntentId);
465
+ return { received: true, ignored: true, status: 200 };
466
+ }
467
+ let revokedKey = null;
468
+ for (const [key, record] of apiKeys.entries()) {
469
+ if (record.email === email) { revokedKey = key; break; }
470
+ }
471
+ if (!revokedKey) {
472
+ console.log('[lms] No API key found for ' + email + ' — refund received, nothing to revoke');
473
+ return { received: true, ignored: true, status: 200 };
474
+ }
475
+ apiKeys.delete(revokedKey);
476
+ await redisDelete(`${REDIS_PREFIX}:key:${revokedKey}`);
477
+ saveStats();
478
+ console.log('[Webhook] API key revoked for ' + email + ' — refund received');
479
+ return { received: true, revoked: true, status: 200 };
480
+ } catch(e) {
481
+ console.error('[lms] charge.refunded handling error:', e.message);
482
+ return { received: true, ignored: true, status: 200 };
483
+ }
484
+ }
485
+
414
486
  return { received: true, status: 200 };
415
487
  }
416
488
 
@@ -658,6 +730,7 @@ const server = createServer(async (req, res) => {
658
730
  const access = checkAccess(clientIp, apiKey);
659
731
 
660
732
  if (!access.allowed) {
733
+ notifyGateHit('Local Model Suitability', clientIp, 'check_local_viability', getFreeTierCount(clientIp), PRO_UPGRADE_URL);
661
734
  response = {
662
735
  jsonrpc: '2.0', id: request.id,
663
736
  result: { content: [{ type: 'text', text: JSON.stringify({ error: `Free tier limit reached. Get 500 calls for $20 at ${PRO_UPGRADE_URL} -- calls never expire.`, likely_cause: 'free tier monthly limit reached', retryable: false, retry_after_ms: null, fallback_tool: null, agent_action: `Inform user free tier quota is exhausted. Get 500 calls for $20 at ${PRO_UPGRADE_URL} -- calls never expire.`, category: 'rate_limit', trace_id: nowISO(), upgrade_url: PRO_UPGRADE_URL }) }] }