url-safety-validator-mcp 1.2.24 → 1.2.25

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
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to URL Safety Validator MCP are documented here.
4
4
 
5
+ ## [1.2.25] — 2026-06-24
6
+ - feat: unauthenticated /public-stats endpoint -- first_deployed, lifetime tool calls, uptime %, version, for agent orchestrators evaluating server trustworthiness
7
+ - feat: /process-trial-followups endpoint + 24h follow-up record on trial-extension grant
8
+ - feat: gate response now self-contained (server + workflow impact + upgrade path in one sentence) and detects cross-server operators via shared fleet Redis, with cross-server trial-extension note
9
+ - feat: outputSchema added to check_url (additive, response format unchanged)
10
+ - fix: tool description and both initialize descriptions said "Returns BLOCK / FLAG_AND_PROCEED / ALLOW verdict" -- the real `verdict` field is SAFE/SUSPICIOUS/DANGEROUS; BLOCK/FLAG_AND_PROCEED/ALLOW is a separate derived `agent_action` field. Clarified both fields and their relationship everywhere this was stated.
11
+ - fix: glama.json and README claimed cross-checking against URLhaus and PhishTank -- neither is ever called in code, only Google Web Risk and Google Safe Browsing are. Removed the false claims, including a fabricated PhishTank citation in a README example response. Also fixed smithery.yaml claiming "2 focused tools" when this server has exactly 1 (check_url).
12
+ - fix: /deps health check previously treated HTTP 403 (key rejected) on Google Web Risk as ok:true via the `statusCode < 500` pattern -- now treats 403 on an authenticated API as ok:false with error:'auth_failed'
13
+
5
14
  ## [1.2.24] — 2026-06-23
6
15
  - fix: gate returns HTTP 402 (x402 standard for non-transient quota)
7
16
 
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  **Stop your agent from fetching a dangerous URL before it's too late.**
8
8
 
9
- Agents that process emails, scrape pages, or consume API responses encounter URLs from untrusted sources constantly. This server gives your agent a single call to gate every URL before it proceeds — returning a SAFE/SUSPICIOUS/DANGEROUS verdict backed by Google Web Risk, URLhaus, PhishTank, and AI analysis.
9
+ Agents that process emails, scrape pages, or consume API responses encounter URLs from untrusted sources constantly. This server gives your agent a single call to gate every URL before it proceeds — returning a SAFE/SUSPICIOUS/DANGEROUS verdict backed by Google Web Risk, Google Safe Browsing, and AI analysis.
10
10
 
11
11
  ---
12
12
 
@@ -20,7 +20,7 @@ One tool: `check_url`. One call returns:
20
20
  - **SSL status:** valid or not
21
21
  - **Domain age:** registration date and age in days
22
22
  - **Redirect chain flag:** detected from URL parameters
23
- - **Database signals:** raw results from Google Web Risk, URLhaus, PhishTank
23
+ - **Database signals:** raw results from Google Web Risk and Google Safe Browsing
24
24
  - **AI reasoning:** 2–3 sentence plain-English explanation
25
25
  - **AI confidence:** HIGH / MEDIUM / LOW
26
26
 
@@ -46,8 +46,7 @@ If the verdict is DANGEROUS — halt. If SUSPICIOUS — flag for review. If SAFE
46
46
  | Source | Type | Coverage |
47
47
  |---|---|---|
48
48
  | Google Web Risk | Commercial API | Malware, phishing, unwanted software |
49
- | URLhaus (abuse.ch) | Free | Active malware distribution URLs |
50
- | PhishTank | Free | Community-verified phishing URLs |
49
+ | Google Safe Browsing | Free | Malware, phishing, unwanted software (fallback when Web Risk key absent) |
51
50
  | RDAP | Free | Domain registration date |
52
51
  | Anthropic Claude | AI | Trust scoring and reasoning synthesis |
53
52
 
@@ -152,7 +151,7 @@ Same as LangChain above — langchain-mcp-adapters works with LangGraph natively
152
151
  "domain_age_days": 12,
153
152
  "redirect_chain_detected": false,
154
153
  "threat_categories": ["phishing", "newly_registered"],
155
- "reasoning": "Domain registered 12 days ago and confirmed in PhishTank as an active phishing site impersonating a financial institution. Google Web Risk flags this as SOCIAL_ENGINEERING.",
154
+ "reasoning": "Domain registered 12 days ago and impersonates a financial institution's login page. Google Web Risk flags this as SOCIAL_ENGINEERING.",
156
155
  "ai_confidence": "HIGH",
157
156
  "analysis_type": "AI-powered -- NOT a simple database lookup"
158
157
  }
package/glama.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "URL Safety Validator",
3
- "description": "AI-powered URL safety validator. Returns SAFE/SUSPICIOUS/DANGEROUS verdict with trust score, threat categories, domain age, and SSL status. Cross-checks Google Web Risk, URLhaus, and PhishTank.",
3
+ "description": "AI-powered URL safety validator. Returns SAFE/SUSPICIOUS/DANGEROUS verdict with trust score, threat categories, domain age, and SSL status. Cross-checks Google Web Risk and Google Safe Browsing.",
4
4
  "license": "UNLICENSED",
5
5
  "homepage": "https://kordagencies.com",
6
6
  "repository": "https://github.com/OjasKord/url-safety-validator-mcp"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "url-safety-validator-mcp",
3
3
  "mcpName": "io.github.OjasKord/url-safety-validator-mcp",
4
- "version": "1.2.24",
4
+ "version": "1.2.25",
5
5
  "description": "URL safety checker for AI agents. Detects phishing, malware, typosquatting before your agent visits any link. BLOCK/ALLOW verdict in one call.",
6
6
  "main": "src/server.js",
7
7
  "scripts": {
package/smithery.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  name: URL Safety Validator MCP
2
2
  agentRole: "URL Safety Specialist — equip before fetching, visiting, or following any untrusted URL"
3
- description: "Before your agent fetches, visits, or follows any URL from an untrusted source, call check_url. Returns BLOCK/ALLOW verdict with threat category and trust score. 2 focused tools. Compact schema. Minimal agent context overhead."
3
+ description: "Pre-condition: an agent has received a URL from an untrusted source -- email, document, scraped page, or API response -- and is about to fetch, visit, or follow it. Skip this and a payment executed on a phishing domain via an agentic payment rail has no recovery path, since the redirect itself is the attack vector. check_url returns verdict SAFE/SUSPICIOUS/DANGEROUS with a derived agent_action ALLOW/FLAG_AND_PROCEED/BLOCK, checked live against Google Web Risk and Google Safe Browsing. 1 tool, one verdict, no further reasoning required."
4
4
  categories:
5
5
  - Security
6
6
  - Web
package/src/server.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('fs');
5
5
  const crypto = require('crypto');
6
6
  const { Readable } = require('stream');
7
7
 
8
- const VERSION = '1.2.24';
8
+ const VERSION = '1.2.25';
9
9
  const PRO_UPGRADE_URL = 'https://buy.stripe.com/5kQeVc9Ah4n3c8c0h2ebu0t';
10
10
  const ENTERPRISE_UPGRADE_URL = 'https://buy.stripe.com/4gMdR88wddXDfko0h2ebu0u';
11
11
  const ALLOWED_PAYMENT_LINK_IDS = ['plink_1TQzIHD6WvRe6sn3820kFk07', 'plink_1TQzJdD6WvRe6sn3GN8mQkj9'];
@@ -50,6 +50,13 @@ const REDIS_PREFIX = 'url';
50
50
  const FREE_TIER_REDIS_KEY = 'url:free_tier_usage';
51
51
  const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
52
52
  const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
53
+ const FIRST_DEPLOYED = '2026-04-22T06:38:09Z';
54
+ const LIFETIME_CALLS_REDIS_KEY = 'url:lifetime_calls';
55
+ const UPTIME_HEARTBEAT_KEY = 'url:uptime:heartbeat_count';
56
+ const UPTIME_MONITORING_START_KEY = 'url:uptime:monitoring_started';
57
+ const UPTIME_HEARTBEAT_INTERVAL_MS = 60000;
58
+ const FLEET_IP24_TTL_SECONDS = 30 * 24 * 60 * 60;
59
+ const FLEET_CROSS_SERVER_THRESHOLD = 3;
53
60
 
54
61
  function loadStats() {
55
62
  try {
@@ -181,6 +188,56 @@ async function redisDelete(key) {
181
188
  } catch(e) { console.error('[Redis] redisDelete failed:', e); }
182
189
  }
183
190
 
191
+ async function redisIncr(key) {
192
+ try {
193
+ const res = await fetch(
194
+ `${UPSTASH_URL}/incr/${encodeURIComponent(key)}`,
195
+ { headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
196
+ );
197
+ const data = await res.json();
198
+ if (data.error) { console.error('[Redis] redisIncr error:', data.error, 'key:', key); return null; }
199
+ return data.result;
200
+ } catch(e) { console.error('[Redis] redisIncr failed:', e); return null; }
201
+ }
202
+
203
+ // ─── FLEET CROSS-SERVER OPERATOR DETECTION ─────────────────────────────────────
204
+ async function recordFleetGateHit(ip) {
205
+ try {
206
+ const ip24 = truncateIp(ip);
207
+ const key = `fleet:ip24:${ip24}:${REDIS_PREFIX}`;
208
+ await redisSet(key, new Date().toISOString());
209
+ await redisExpire(key, FLEET_IP24_TTL_SECONDS);
210
+ } catch(e) { console.error('[Fleet] recordFleetGateHit failed:', e); }
211
+ }
212
+
213
+ async function checkFleetCrossServer(ip) {
214
+ try {
215
+ const ip24 = truncateIp(ip);
216
+ const keys = await redisKeys(`fleet:ip24:${ip24}:*`);
217
+ return keys.length;
218
+ } catch(e) { return 0; }
219
+ }
220
+
221
+ async function buildCrossServerNote(ip) {
222
+ const serverCount = await checkFleetCrossServer(ip);
223
+ if (serverCount >= FLEET_CROSS_SERVER_THRESHOLD) {
224
+ return 'Cross-server trial extension available -- this operator is already using ' + serverCount + ' Kord Agencies MCP servers. POST /trial-extension on any one of those servers to extend the trial across all of them.';
225
+ }
226
+ return null;
227
+ }
228
+
229
+ // ─── UPTIME TRACKING (for /public-stats) ───────────────────────────────────────
230
+ async function initUptimeTracking() {
231
+ try {
232
+ let started = await redisGet(UPTIME_MONITORING_START_KEY);
233
+ if (!started) {
234
+ started = new Date().toISOString();
235
+ await redisSet(UPTIME_MONITORING_START_KEY, started);
236
+ }
237
+ setInterval(() => { redisIncr(UPTIME_HEARTBEAT_KEY).catch(() => {}); }, UPTIME_HEARTBEAT_INTERVAL_MS);
238
+ } catch(e) { console.error('[Uptime] initUptimeTracking failed:', e); }
239
+ }
240
+
184
241
  async function findCheckoutSessionEmail(paymentIntentId) {
185
242
  const res = await fetch(
186
243
  `https://api.stripe.com/v1/checkout/sessions?payment_intent=${encodeURIComponent(paymentIntentId)}`,
@@ -516,13 +573,40 @@ async function checkUrl(rawUrl) {
516
573
  const TOOL_DEFINITION = {
517
574
  name: 'check_url',
518
575
  idempotent: true,
519
- description: 'Checks a URL for phishing, malware, typosquatting, and redirect threats. Call this BEFORE your agent fetches, follows, or forwards any URL in an agentic commerce workflow -- at the moment a merchant site, supplier portal, or payment redirect URL is received and no navigation has occurred. Use this when your agent has received a URL from an external source — email, document, or API response — and is about to navigate to it or pass it downstream. Checks live against Google Web Risk (webrisk.googleapis.com) and Google Safe Browsing (safebrowsing.googleapis.com). Returns BLOCK / FLAG_AND_PROCEED / ALLOW verdict with trust score 0-100 and threat categories. A payment executed on a phishing domain via Stripe MPP, Alipay AI Pay, or Shopify UCP has no recovery path -- the redirect is the attack vector. A DANGEROUS verdict means halt immediately. Returns machine-ready verdict, no further analysis needed.',
576
+ description: 'Checks a URL for phishing, malware, typosquatting, and redirect threats. Call this BEFORE your agent fetches, follows, or forwards any URL in an agentic commerce workflow -- at the moment a merchant site, supplier portal, or payment redirect URL is received and no navigation has occurred. Use this when your agent has received a URL from an external source — email, document, or API response — and is about to navigate to it or pass it downstream. Checks live against Google Web Risk (webrisk.googleapis.com) and Google Safe Browsing (safebrowsing.googleapis.com). Returns verdict SAFE / SUSPICIOUS / DANGEROUS with a derived agent_action of ALLOW / FLAG_AND_PROCEED / BLOCK, trust score 0-100, and threat categories. A payment executed on a phishing domain via Stripe MPP, Alipay AI Pay, or Shopify UCP has no recovery path -- the redirect is the attack vector. A DANGEROUS verdict means halt immediately. Returns machine-ready verdict, no further analysis needed.',
520
577
  inputSchema: {
521
578
  type: 'object',
522
579
  properties: {
523
580
  url: { type: 'string', description: 'The URL to check. Full URL preferred (e.g. https://example.com/path). Bare domains also accepted.' }
524
581
  },
525
582
  required: ['url']
583
+ },
584
+ outputSchema: {
585
+ type: 'object',
586
+ properties: {
587
+ url: { type: 'string' },
588
+ hostname: { type: 'string' },
589
+ verdict: { type: 'string', enum: ['SAFE', 'SUSPICIOUS', 'DANGEROUS'] },
590
+ agent_action: { type: 'string', enum: ['ALLOW', 'FLAG_AND_PROCEED', 'BLOCK'], description: 'Derived directly from verdict' },
591
+ trust_score: { type: 'number', minimum: 0, maximum: 100 },
592
+ ssl_valid: { type: 'boolean' },
593
+ domain_age_days: { type: ['number', 'null'] },
594
+ domain_registered: { type: ['string', 'null'] },
595
+ redirect_chain_detected: { type: 'boolean' },
596
+ threat_categories: { type: 'array', items: { type: 'string' } },
597
+ reasoning: { type: 'string' },
598
+ ai_confidence: { type: ['string', 'null'] },
599
+ analysis_type: { type: 'string' },
600
+ database_signals: { type: 'object' },
601
+ checked_at: { type: 'string', format: 'date-time' },
602
+ source_url: { type: 'string' },
603
+ hold_reason: { type: 'string', description: 'Present only when verdict is SUSPICIOUS' },
604
+ retry_after: { type: ['number', 'null'] },
605
+ escalation_path: { type: 'string', description: 'Present only when verdict is SUSPICIOUS' },
606
+ _disclaimer: { type: 'string' }
607
+ },
608
+ required: ['url', 'hostname', 'verdict', 'agent_action', 'trust_score', 'checked_at', '_disclaimer'],
609
+ additionalProperties: true
526
610
  }
527
611
  };
528
612
 
@@ -561,7 +645,7 @@ function setupStdio() {
561
645
  const request = JSON.parse(line);
562
646
  let response;
563
647
  if (request.method === 'initialize') {
564
- response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable BLOCK / FLAG_AND_PROCEED / ALLOW verdict checked live against Google Web Risk and Google Safe Browsing, every call. Used before any agent URL fetch, page navigation, or redirect follow in payment and procurement workflows.' } } };
648
+ response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable verdict (SAFE/SUSPICIOUS/DANGEROUS) with a derived agent_action (ALLOW/FLAG_AND_PROCEED/BLOCK), checked live against Google Web Risk and Google Safe Browsing, every call. Used before any agent URL fetch, page navigation, or redirect follow in payment and procurement workflows.' } } };
565
649
  } else if (request.method === 'notifications/initialized') {
566
650
  continue;
567
651
  } else if (request.method === 'tools/list') {
@@ -616,7 +700,7 @@ const server = http.createServer(async (req, res) => {
616
700
  const depCheck = (hostname, path, extraHeaders) => new Promise((resolve) => {
617
701
  const r = https.request({ hostname, path, method: 'GET', headers: { 'User-Agent': 'MCP-HealthCheck/1.0', ...(extraHeaders||{}) } }, (res2) => {
618
702
  res2.resume();
619
- resolve({ ok: res2.statusCode < 500, status: res2.statusCode });
703
+ resolve({ ok: res2.statusCode !== 403 && res2.statusCode < 500, status: res2.statusCode, error: res2.statusCode === 403 ? 'auth_failed' : undefined });
620
704
  });
621
705
  r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
622
706
  r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
@@ -630,7 +714,7 @@ const server = http.createServer(async (req, res) => {
630
714
  ? (() => {
631
715
  const sbBody = JSON.stringify({ client: { clientId: 'kord-dep-check', clientVersion: '1.0' }, threatInfo: { threatTypes: ['MALWARE'], platformTypes: ['ANY_PLATFORM'], threatEntryTypes: ['URL'], threatEntries: [{ url: 'https://example.com' }] } });
632
716
  return new Promise((resolve) => {
633
- const r = https.request({ hostname: 'safebrowsing.googleapis.com', path: `/v4/threatMatches:find?key=${GOOGLE_SAFE_BROWSING_API_KEY}`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(sbBody) } }, (res2) => { res2.resume(); resolve({ ok: res2.statusCode < 500, status: res2.statusCode }); });
717
+ const r = https.request({ hostname: 'safebrowsing.googleapis.com', path: `/v4/threatMatches:find?key=${GOOGLE_SAFE_BROWSING_API_KEY}`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(sbBody) } }, (res2) => { res2.resume(); resolve({ ok: res2.statusCode !== 403 && res2.statusCode < 500, status: res2.statusCode, error: res2.statusCode === 403 ? 'auth_failed' : undefined }); });
634
718
  r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
635
719
  r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
636
720
  r.write(sbBody); r.end();
@@ -663,6 +747,33 @@ const server = http.createServer(async (req, res) => {
663
747
  return;
664
748
  }
665
749
 
750
+ // Unauthenticated machine-readable track record -- for agent orchestrators
751
+ // evaluating server trustworthiness, not for humans. No stats-key required.
752
+ if (req.url === '/public-stats' && req.method === 'GET') {
753
+ (async () => {
754
+ const [lifetimeCallsRaw, heartbeatCountRaw, monitoringStart] = await Promise.all([
755
+ redisGet(LIFETIME_CALLS_REDIS_KEY),
756
+ redisGet(UPTIME_HEARTBEAT_KEY),
757
+ redisGet(UPTIME_MONITORING_START_KEY)
758
+ ]);
759
+ const lifetimeCalls = lifetimeCallsRaw || 0;
760
+ const heartbeatCount = heartbeatCountRaw || 0;
761
+ const monitoringStartTime = monitoringStart ? new Date(monitoringStart).getTime() : Date.now();
762
+ const elapsedMs = Math.max(1, Date.now() - monitoringStartTime);
763
+ const uptimePct = Math.min(100, Math.round((heartbeatCount * UPTIME_HEARTBEAT_INTERVAL_MS / elapsedMs) * 1000) / 10);
764
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
765
+ res.end(JSON.stringify({
766
+ server: 'url-safety-validator-mcp',
767
+ version: VERSION,
768
+ first_deployed: FIRST_DEPLOYED,
769
+ total_lifetime_tool_calls: lifetimeCalls,
770
+ uptime_percentage: uptimePct,
771
+ uptime_monitoring_since: monitoringStart || new Date().toISOString()
772
+ }));
773
+ })();
774
+ return;
775
+ }
776
+
666
777
  if (req.url === '/session-log' && req.method === 'GET') {
667
778
  if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
668
779
  (async () => {
@@ -705,6 +816,8 @@ const server = http.createServer(async (req, res) => {
705
816
  stats.free_tier_calls_by_ip[clientIp][month] = Math.max(0, current - TRIAL_EXTENSION_CALLS);
706
817
  trialExtensions.set(emailKey, { name, email, use_case: use_case || '', ip: clientIp, granted_at: nowISO() });
707
818
  saveStats();
819
+ // 24h follow-up record -- processed by /process-trial-followups (fleet cron)
820
+ await redisSet(REDIS_PREFIX + ':followup:' + email.toLowerCase().trim(), { email, name, server: 'url-safety-validator-mcp', granted_at: nowISO(), sent: false });
708
821
  await sendEmail('ojas@kordagencies.com', 'URL Safety Validator MCP -- Trial Extension: ' + name,
709
822
  '<p><b>Name:</b> ' + name + '<br><b>Email:</b> ' + email + '<br><b>Use case:</b> ' + (use_case || 'Not provided') + '<br><b>IP:</b> ' + clientIp + '<br><b>Calls granted:</b> ' + TRIAL_EXTENSION_CALLS + '</p>');
710
823
  await sendEmail(email, TRIAL_EXTENSION_CALLS + ' extra free calls added -- URL Safety Validator MCP',
@@ -716,6 +829,39 @@ const server = http.createServer(async (req, res) => {
716
829
  return;
717
830
  }
718
831
 
832
+ // Fleet cron hits this hourly. Sends exactly one follow-up email per email
833
+ // address, 24h after a trial extension was granted, unless that email has
834
+ // since picked up a paid key on this server.
835
+ if (req.url === '/process-trial-followups' && req.method === 'POST') {
836
+ if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
837
+ (async () => {
838
+ const keys = await redisKeys(REDIS_PREFIX + ':followup:*');
839
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
840
+ let processed = 0, sent = 0, skippedPaid = 0;
841
+ for (const key of keys) {
842
+ const record = await redisGet(key);
843
+ if (!record || record.sent) continue;
844
+ if (Date.now() - new Date(record.granted_at).getTime() < TWENTY_FOUR_HOURS_MS) continue;
845
+ processed++;
846
+ const emailNorm = (record.email || '').toLowerCase().trim();
847
+ const hasPaidKey = Array.from(apiKeys.values()).some(r => (r.email || '').toLowerCase().trim() === emailNorm);
848
+ if (hasPaidKey) {
849
+ skippedPaid++;
850
+ } else {
851
+ await sendEmail(record.email, 'URL Safety Validator MCP -- URL safety screening will block your workflow again without an upgrade',
852
+ '<p>Hi ' + record.name + ',</p><p>Your trial extension on URL Safety Validator MCP was granted 24 hours ago. Once those extra calls run out, URL safety screening stops and any fetch/follow workflow that depends on it pauses until you upgrade.</p><p>Upgrade now -- 500 calls for $20/month: ' + PRO_UPGRADE_URL + '</p><p>Ojas<br>kordagencies.com</p>');
853
+ sent++;
854
+ }
855
+ record.sent = true;
856
+ record.sent_at = nowISO();
857
+ await redisSet(key, record);
858
+ }
859
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
860
+ res.end(JSON.stringify({ checked: keys.length, processed, emails_sent: sent, skipped_already_paid: skippedPaid }));
861
+ })();
862
+ return;
863
+ }
864
+
719
865
  if (req.url === '/webhook/stripe' && req.method === 'POST') {
720
866
  let rawBody = '';
721
867
  req.on('data', c => rawBody += c);
@@ -851,7 +997,7 @@ const server = http.createServer(async (req, res) => {
851
997
  let statusCode = 200;
852
998
 
853
999
  if (request.method === 'initialize') {
854
- response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable BLOCK / FLAG_AND_PROCEED / ALLOW verdict checked live against Google Web Risk and Google Safe Browsing, every call. Used before any agent URL fetch, page navigation, or redirect follow in payment and procurement workflows.' } } };
1000
+ response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable verdict (SAFE/SUSPICIOUS/DANGEROUS) with a derived agent_action (ALLOW/FLAG_AND_PROCEED/BLOCK), checked live against Google Web Risk and Google Safe Browsing, every call. Used before any agent URL fetch, page navigation, or redirect follow in payment and procurement workflows.' } } };
855
1001
  } else if (request.method === 'notifications/initialized') {
856
1002
  res.writeHead(204, cors); res.end(); return;
857
1003
  } else if (request.method === 'tools/list') {
@@ -879,7 +1025,9 @@ const server = http.createServer(async (req, res) => {
879
1025
  const _gateMonth = getMonthKey();
880
1026
  const _gateCalls = (stats.free_tier_calls_by_ip[clientIp] && stats.free_tier_calls_by_ip[clientIp][_gateMonth]) || 0;
881
1027
  notifyGateHit('URL Safety Validator', clientIp, 'check_url', _gateCalls, PRO_UPGRADE_URL);
882
- response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'An unchecked URL followed by your agent creates unrecoverable security exposure — stopping here leaves your workflow vulnerable. Free tier limit of 10 calls/month reached. To continue: (1) Trial extension — 10 free calls, no payment required: POST /trial-extension with {"name":"...","email":"...","use_case":"..."}. (2) Bundle 500 — $20, 500 calls, never expire: ' + PRO_UPGRADE_URL + '. (3) Bundle 2000 — $70: ' + ENTERPRISE_UPGRADE_URL + '.', likely_cause: 'free tier monthly limit reached', retryable: false, retry_after_ms: null, fallback_tool: null, agent_action: 'Inform user that free quota is exhausted.', category: 'rate_limit', trace_id: crypto.randomBytes(8).toString('hex'), upgrade_url: PRO_UPGRADE_URL, trial_extension: { endpoint: '/trial-extension', method: 'POST', body: { name: 'string', email: 'string', use_case: 'string' } }, _disclaimer: LEGAL_DISCLAIMER }) }] } };
1028
+ recordFleetGateHit(clientIp).catch(() => {});
1029
+ const crossServerNote = await buildCrossServerNote(clientIp);
1030
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'URL Safety Validator MCP free tier exhausted -- URL safety screening is now blocked, halting any workflow that depends on verifying a link before fetch or follow, until you extend via POST /trial-extension or upgrade at ' + PRO_UPGRADE_URL + '. An unchecked URL followed by your agent creates unrecoverable security exposure -- stopping here leaves your workflow vulnerable. Free tier limit of 10 calls/month reached. To continue: (1) Trial extension — 10 free calls, no payment required: POST /trial-extension with {"name":"...","email":"...","use_case":"..."}. (2) Bundle 500 — $20, 500 calls, never expire: ' + PRO_UPGRADE_URL + '. (3) Bundle 2000 — $70: ' + ENTERPRISE_UPGRADE_URL + '.' + (crossServerNote ? ' ' + crossServerNote : ''), likely_cause: 'free tier monthly limit reached', retryable: false, retry_after_ms: null, fallback_tool: null, agent_action: 'Inform user that free quota is exhausted.', category: 'rate_limit', trace_id: crypto.randomBytes(8).toString('hex'), upgrade_url: PRO_UPGRADE_URL, trial_extension: { endpoint: '/trial-extension', method: 'POST', body: { name: 'string', email: 'string', use_case: 'string' } }, _disclaimer: LEGAL_DISCLAIMER }) }] } };
883
1031
  } else {
884
1032
  recordCall(clientIp, apiKey);
885
1033
  saveFreeTierToRedis().catch(() => {});
@@ -887,6 +1035,7 @@ const server = http.createServer(async (req, res) => {
887
1035
  appendSessionLog(clientIp, 'check_url').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
888
1036
  usageLog.push({ tool: 'check_url', ip: clientIp, tier: tier.paid ? 'paid' : 'free', timestamp: nowISO() });
889
1037
  toolUsageCounts['check_url'] = (toolUsageCounts['check_url'] || 0) + 1;
1038
+ redisIncr(LIFETIME_CALLS_REDIS_KEY).catch(() => {});
890
1039
  if (tier.remaining <= 4 && !tier.paid) {
891
1040
  const effectiveLimit = getEffectiveLimit(clientIp);
892
1041
  result._notice = 'Warning: ' + (tier.remaining - 1) + ' free calls remaining this month (limit: ' + effectiveLimit + '). Get 500 calls for $20 at ' + PRO_UPGRADE_URL + ' -- calls never expire.';
@@ -916,6 +1065,7 @@ const server = http.createServer(async (req, res) => {
916
1065
  server.listen(PORT, async () => {
917
1066
  await loadApiKeysFromRedis();
918
1067
  await loadFreeTierFromRedis();
1068
+ await initUptimeTracking();
919
1069
  console.log(`URL Safety Validator MCP v${VERSION} running on port ${PORT}`);
920
1070
  console.log(`Google Web Risk: ${GOOGLE_WEB_RISK_API_KEY ? 'configured' : 'NOT SET -- set GOOGLE_WEB_RISK_API_KEY'}`);
921
1071
  console.log(`Anthropic API: ${ANTHROPIC_API_KEY ? 'configured' : 'NOT SET'}`);