local-model-suitability-mcp 1.1.24 → 1.1.26

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.26] - 2026-06-28
4
+ - fix: gate email dedup — notifyGateHit now writes lms:gate_email:{ip} to Redis with 1-hour TTL; retries within the hour suppressed
5
+ - fix: 402 gate response agent_action changed to HALT_WORKFLOW; added retryable: false, retry_after_ms: null
6
+ - fix: trial_extension structured field already present; agent_action now actionable for agents
7
+
8
+ ## [1.1.25] - 2026-06-28
9
+ - feat: owner key bypass (OWNER_KEY env var) — fleet owner bypasses free tier and paid-only gates
10
+
3
11
  ## [1.1.24] - 2026-06-26
4
12
  - fix: trial extension requests now written to Redis (lms:trial:{email}) on grant -- permanent audit trail that survives redeploys; previously in-memory only
5
13
 
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.24",
4
+ "version": "1.1.26",
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.24';
6
+ const VERSION = '1.1.26';
7
7
  const FIRST_DEPLOYED = '2026-04-13T06:41:38Z';
8
8
  const LIFETIME_CALLS_REDIS_KEY = 'lms:lifetime_calls';
9
9
  const UPTIME_HEARTBEAT_KEY = 'lms:uptime:heartbeat_count';
@@ -24,7 +24,7 @@ function nowISO() { return new Date().toISOString(); }
24
24
  const cors = {
25
25
  'Access-Control-Allow-Origin': '*',
26
26
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, HEAD',
27
- 'Access-Control-Allow-Headers': 'Content-Type, x-api-key, x-stats-key'
27
+ 'Access-Control-Allow-Headers': 'Content-Type, x-api-key, x-stats-key, x-owner-key'
28
28
  };
29
29
 
30
30
  // ── Stats persistence ─────────────────────────────────────────────────────────
@@ -121,14 +121,21 @@ function truncateIp(ip) {
121
121
  return parts.length === 4 ? parts.slice(0, 3).join('.') + '.0' : ip;
122
122
  }
123
123
 
124
- function notifyGateHit(serverName, ip, toolName, totalCalls, stripeUrl) {
124
+ async function notifyGateHit(serverName, ip, toolName, totalCalls, stripeUrl) {
125
+ const ip24 = truncateIp(ip);
126
+ const dedupKey = REDIS_PREFIX + ':gate_email:' + ip24;
127
+ try {
128
+ const recent = await redisGet(dedupKey);
129
+ if (recent) { console.log('[GateNotify] suppressed duplicate for ' + ip24); return; }
130
+ await redisSet(dedupKey, new Date().toISOString());
131
+ await redisExpire(dedupKey, 3600);
132
+ } catch(e) { /* Redis unavailable — fall through and send */ }
125
133
  if (!process.env.RESEND_API_KEY) return;
126
- const maskedIp = truncateIp(ip);
127
- 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>';
134
+ const html = '<p>Server: ' + serverName + '</p><p>IP: ' + ip24 + '</p><p>Tool: ' + (toolName || 'unknown') + '</p><p>Calls this month: ' + totalCalls + '</p><p>Time: ' + new Date().toISOString() + '</p><p>Upgrade: ' + stripeUrl + '</p>';
128
135
  fetch('https://api.resend.com/emails', {
129
136
  method: 'POST',
130
137
  headers: { 'Authorization': `Bearer ${process.env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
131
- body: JSON.stringify({ from: 'Kord Agencies <ojas@kordagencies.com>', to: 'ojas@kordagencies.com', subject: '[Gate Hit] ' + serverName + ' — ' + maskedIp + ' hit free tier limit', html })
138
+ body: JSON.stringify({ from: 'Kord Agencies <ojas@kordagencies.com>', to: 'ojas@kordagencies.com', subject: '[Gate Hit] ' + serverName + ' — ' + ip24 + ' hit free tier limit', html })
132
139
  }).then(r => { if (!r.ok) r.text().then(t => console.error('[GateNotify] failed: HTTP ' + r.status + ' ' + t)); })
133
140
  .catch(e => console.error('[GateNotify] network error:', e.message));
134
141
  }
@@ -312,6 +319,7 @@ async function saveFreeTierToRedis() {
312
319
 
313
320
  // ── Anthropic client ──────────────────────────────────────────────────────────
314
321
  const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
322
+ const OWNER_KEY = process.env.OWNER_KEY || '';
315
323
 
316
324
  // ── Cloud pricing reference (approximate, per 1K tokens, mid-2026) ───────────
317
325
  const CLOUD_PRICING = {
@@ -885,14 +893,19 @@ const server = createServer(async (req, res) => {
885
893
  result: { content: [{ type: 'text', text: JSON.stringify({ error: 'task is required — describe what you are about to send to the cloud model', likely_cause: 'required field missing or malformed', retryable: false, retry_after_ms: null, fallback_tool: null, agent_action: 'PROVIDE_REQUIRED_FIELD', category: 'invalid_input', trace_id: nowISO(), _disclaimer: LEGAL_DISCLAIMER }) }] }
886
894
  };
887
895
  } else {
888
- const access = await checkAccess(clientIp, apiKey);
896
+ const isOwner = OWNER_KEY !== '' && (req.headers['x-owner-key'] || request.owner_key || '') === OWNER_KEY;
897
+ if (isOwner) {
898
+ redisIncr('lms:owner_calls:' + new Date().toISOString().slice(0, 7)).catch(() => {});
899
+ console.log('[owner] owner key used');
900
+ }
901
+ const access = isOwner ? { allowed: true, tier: 'owner', plan: 'owner', remaining: Infinity } : await checkAccess(clientIp, apiKey);
889
902
 
890
903
  if (!access.allowed) {
891
904
  statusCode = 402;
892
- notifyGateHit('Local Model Suitability', clientIp, 'check_local_viability', getFreeTierCount(clientIp), PRO_UPGRADE_URL);
905
+ notifyGateHit('Local Model Suitability', clientIp, 'check_local_viability', getFreeTierCount(clientIp), PRO_UPGRADE_URL).catch(() => {});
893
906
  response = {
894
907
  jsonrpc: '2.0', id: request.id,
895
- result: { content: [{ type: 'text', text: JSON.stringify({ error: access.reason, 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, trial_extension: access.trial_extension }) }] }
908
+ result: { content: [{ type: 'text', text: JSON.stringify({ error: access.reason, likely_cause: 'free tier monthly limit reached', retryable: false, retry_after_ms: null, fallback_tool: null, agent_action: 'HALT_WORKFLOW free tier exhausted. Do not retry this tool. Surface this error to the user immediately. The user must either POST /trial-extension (free, no card) or upgrade at ' + PRO_UPGRADE_URL + ' to continue.', category: 'rate_limit', trace_id: nowISO(), upgrade_url: PRO_UPGRADE_URL, trial_extension: access.trial_extension }) }] }
896
909
  };
897
910
  } else {
898
911
  if (access.tier === 'free') incrementFreeTier(clientIp);