url-safety-validator-mcp 1.2.24 → 1.2.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
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to URL Safety Validator MCP are documented here.
4
4
 
5
+ ## [1.2.26] — 2026-06-25
6
+ - feat: calls_remaining field added to check_url responses -- "unlimited" for paid keys, numeric free-tier headroom otherwise
7
+ - feat: verdict_ttl field added (3600s/1hr -- threat landscape changes fast)
8
+ - feat: data_source_status field added (full/degraded/partial) -- "degraded" when Google Web Risk (critical source) is unavailable, "partial" when only AI trust scoring is unavailable, "full" when both respond
9
+ - Task 1 (purpose verb + required fields) audited -- already correct on check_url from a prior pass, no changes needed
10
+
11
+ ## [1.2.25] — 2026-06-24
12
+ - feat: unauthenticated /public-stats endpoint -- first_deployed, lifetime tool calls, uptime %, version, for agent orchestrators evaluating server trustworthiness
13
+ - feat: /process-trial-followups endpoint + 24h follow-up record on trial-extension grant
14
+ - 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
15
+ - feat: outputSchema added to check_url (additive, response format unchanged)
16
+ - 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.
17
+ - 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).
18
+ - 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'
19
+
5
20
  ## [1.2.24] — 2026-06-23
6
21
  - fix: gate returns HTTP 402 (x402 standard for non-transient quota)
7
22
 
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.26",
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/server.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
- "name": "io.github.OjasKord/url-safety-validator-mcp",
4
- "title": "URL Safety Validator MCP",
5
- "description": "AI URL safety validator: SAFE/SUSPICIOUS/DANGEROUS verdict, trust score, threat intel.",
6
- "version": "1.2.20",
7
- "websiteUrl": "https://kordagencies.com",
8
- "repository": {
9
- "url": "https://github.com/OjasKord/url-safety-validator-mcp",
10
- "source": "github"
11
- },
12
- "packages": [
13
- {
14
- "registryType": "npm",
15
- "identifier": "url-safety-validator-mcp",
16
- "version": "1.2.19",
17
- "transport": {
18
- "type": "stdio"
19
- },
20
- "environmentVariables": [
21
- {
22
- "name": "ANTHROPIC_API_KEY",
23
- "description": "Anthropic API key for AI trust scoring",
24
- "isRequired": true,
25
- "isSecret": true
26
- },
27
- {
28
- "name": "GOOGLE_WEB_RISK_API_KEY",
29
- "description": "Google Web Risk API key (commercial). Degrades gracefully without it.",
30
- "isRequired": false,
31
- "isSecret": true
32
- },
33
- {
34
- "name": "GOOGLE_SAFE_BROWSING_API_KEY",
35
- "description": "Google Safe Browsing API key (free tier available).",
36
- "isRequired": false,
37
- "isSecret": true
38
- }
39
- ]
40
- }
41
- ],
42
- "remotes": [
43
- {
44
- "type": "streamable-http",
45
- "url": "https://url-safety-validator-mcp-production.up.railway.app"
46
- }
47
- ]
48
- }
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.OjasKord/url-safety-validator-mcp",
4
+ "title": "URL Safety Validator MCP",
5
+ "description": "AI URL safety validator: SAFE/SUSPICIOUS/DANGEROUS verdict, trust score, threat intel.",
6
+ "version": "1.2.26",
7
+ "websiteUrl": "https://kordagencies.com",
8
+ "repository": {
9
+ "url": "https://github.com/OjasKord/url-safety-validator-mcp",
10
+ "source": "github"
11
+ },
12
+ "packages": [
13
+ {
14
+ "registryType": "npm",
15
+ "identifier": "url-safety-validator-mcp",
16
+ "version": "1.2.26",
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
20
+ "environmentVariables": [
21
+ {
22
+ "name": "ANTHROPIC_API_KEY",
23
+ "description": "Anthropic API key for AI trust scoring",
24
+ "isRequired": true,
25
+ "isSecret": true
26
+ },
27
+ {
28
+ "name": "GOOGLE_WEB_RISK_API_KEY",
29
+ "description": "Google Web Risk API key (commercial). Degrades gracefully without it.",
30
+ "isRequired": false,
31
+ "isSecret": true
32
+ },
33
+ {
34
+ "name": "GOOGLE_SAFE_BROWSING_API_KEY",
35
+ "description": "Google Safe Browsing API key (free tier available).",
36
+ "isRequired": false,
37
+ "isSecret": true
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "remotes": [
43
+ {
44
+ "type": "streamable-http",
45
+ "url": "https://url-safety-validator-mcp-production.up.railway.app"
46
+ }
47
+ ]
48
+ }
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.26';
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)}`,
@@ -455,6 +512,9 @@ async function checkUrl(rawUrl) {
455
512
  const signals = { google_web_risk: webRisk, google_safe_browsing: safeBrowsing, domain_age: domainAge, ssl };
456
513
 
457
514
  const ai = await getAITrustScore(href, hostname, signals);
515
+ // Caching/staleness policy and source-confidence flag (Task 3/4) -- Google Web Risk is the critical source for this server.
516
+ const VERDICT_TTL_CHECK_URL = 3600; // 1 hour -- threat landscape changes fast
517
+ const dataSourceStatus = !webRisk.available ? 'degraded' : (!ai.available ? 'partial' : 'full');
458
518
 
459
519
  // Determine final verdict -- hard overrides
460
520
  let verdict = ai.available ? ai.verdict : 'SUSPICIOUS';
@@ -497,6 +557,8 @@ async function checkUrl(rawUrl) {
497
557
  },
498
558
  checked_at: nowISO(),
499
559
  source_url: 'webrisk.googleapis.com',
560
+ verdict_ttl: VERDICT_TTL_CHECK_URL,
561
+ data_source_status: dataSourceStatus,
500
562
  _disclaimer: LEGAL_DISCLAIMER
501
563
  };
502
564
 
@@ -516,13 +578,40 @@ async function checkUrl(rawUrl) {
516
578
  const TOOL_DEFINITION = {
517
579
  name: 'check_url',
518
580
  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.',
581
+ 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
582
  inputSchema: {
521
583
  type: 'object',
522
584
  properties: {
523
585
  url: { type: 'string', description: 'The URL to check. Full URL preferred (e.g. https://example.com/path). Bare domains also accepted.' }
524
586
  },
525
587
  required: ['url']
588
+ },
589
+ outputSchema: {
590
+ type: 'object',
591
+ properties: {
592
+ url: { type: 'string' },
593
+ hostname: { type: 'string' },
594
+ verdict: { type: 'string', enum: ['SAFE', 'SUSPICIOUS', 'DANGEROUS'] },
595
+ agent_action: { type: 'string', enum: ['ALLOW', 'FLAG_AND_PROCEED', 'BLOCK'], description: 'Derived directly from verdict' },
596
+ trust_score: { type: 'number', minimum: 0, maximum: 100 },
597
+ ssl_valid: { type: 'boolean' },
598
+ domain_age_days: { type: ['number', 'null'] },
599
+ domain_registered: { type: ['string', 'null'] },
600
+ redirect_chain_detected: { type: 'boolean' },
601
+ threat_categories: { type: 'array', items: { type: 'string' } },
602
+ reasoning: { type: 'string' },
603
+ ai_confidence: { type: ['string', 'null'] },
604
+ analysis_type: { type: 'string' },
605
+ database_signals: { type: 'object' },
606
+ checked_at: { type: 'string', format: 'date-time' },
607
+ source_url: { type: 'string' },
608
+ hold_reason: { type: 'string', description: 'Present only when verdict is SUSPICIOUS' },
609
+ retry_after: { type: ['number', 'null'] },
610
+ escalation_path: { type: 'string', description: 'Present only when verdict is SUSPICIOUS' },
611
+ _disclaimer: { type: 'string' }
612
+ },
613
+ required: ['url', 'hostname', 'verdict', 'agent_action', 'trust_score', 'checked_at', '_disclaimer'],
614
+ additionalProperties: true
526
615
  }
527
616
  };
528
617
 
@@ -561,7 +650,7 @@ function setupStdio() {
561
650
  const request = JSON.parse(line);
562
651
  let response;
563
652
  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.' } } };
653
+ 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
654
  } else if (request.method === 'notifications/initialized') {
566
655
  continue;
567
656
  } else if (request.method === 'tools/list') {
@@ -616,7 +705,7 @@ const server = http.createServer(async (req, res) => {
616
705
  const depCheck = (hostname, path, extraHeaders) => new Promise((resolve) => {
617
706
  const r = https.request({ hostname, path, method: 'GET', headers: { 'User-Agent': 'MCP-HealthCheck/1.0', ...(extraHeaders||{}) } }, (res2) => {
618
707
  res2.resume();
619
- resolve({ ok: res2.statusCode < 500, status: res2.statusCode });
708
+ resolve({ ok: res2.statusCode !== 403 && res2.statusCode < 500, status: res2.statusCode, error: res2.statusCode === 403 ? 'auth_failed' : undefined });
620
709
  });
621
710
  r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
622
711
  r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
@@ -630,7 +719,7 @@ const server = http.createServer(async (req, res) => {
630
719
  ? (() => {
631
720
  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
721
  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 }); });
722
+ 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
723
  r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
635
724
  r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
636
725
  r.write(sbBody); r.end();
@@ -663,6 +752,33 @@ const server = http.createServer(async (req, res) => {
663
752
  return;
664
753
  }
665
754
 
755
+ // Unauthenticated machine-readable track record -- for agent orchestrators
756
+ // evaluating server trustworthiness, not for humans. No stats-key required.
757
+ if (req.url === '/public-stats' && req.method === 'GET') {
758
+ (async () => {
759
+ const [lifetimeCallsRaw, heartbeatCountRaw, monitoringStart] = await Promise.all([
760
+ redisGet(LIFETIME_CALLS_REDIS_KEY),
761
+ redisGet(UPTIME_HEARTBEAT_KEY),
762
+ redisGet(UPTIME_MONITORING_START_KEY)
763
+ ]);
764
+ const lifetimeCalls = lifetimeCallsRaw || 0;
765
+ const heartbeatCount = heartbeatCountRaw || 0;
766
+ const monitoringStartTime = monitoringStart ? new Date(monitoringStart).getTime() : Date.now();
767
+ const elapsedMs = Math.max(1, Date.now() - monitoringStartTime);
768
+ const uptimePct = Math.min(100, Math.round((heartbeatCount * UPTIME_HEARTBEAT_INTERVAL_MS / elapsedMs) * 1000) / 10);
769
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
770
+ res.end(JSON.stringify({
771
+ server: 'url-safety-validator-mcp',
772
+ version: VERSION,
773
+ first_deployed: FIRST_DEPLOYED,
774
+ total_lifetime_tool_calls: lifetimeCalls,
775
+ uptime_percentage: uptimePct,
776
+ uptime_monitoring_since: monitoringStart || new Date().toISOString()
777
+ }));
778
+ })();
779
+ return;
780
+ }
781
+
666
782
  if (req.url === '/session-log' && req.method === 'GET') {
667
783
  if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
668
784
  (async () => {
@@ -705,6 +821,8 @@ const server = http.createServer(async (req, res) => {
705
821
  stats.free_tier_calls_by_ip[clientIp][month] = Math.max(0, current - TRIAL_EXTENSION_CALLS);
706
822
  trialExtensions.set(emailKey, { name, email, use_case: use_case || '', ip: clientIp, granted_at: nowISO() });
707
823
  saveStats();
824
+ // 24h follow-up record -- processed by /process-trial-followups (fleet cron)
825
+ await redisSet(REDIS_PREFIX + ':followup:' + email.toLowerCase().trim(), { email, name, server: 'url-safety-validator-mcp', granted_at: nowISO(), sent: false });
708
826
  await sendEmail('ojas@kordagencies.com', 'URL Safety Validator MCP -- Trial Extension: ' + name,
709
827
  '<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
828
  await sendEmail(email, TRIAL_EXTENSION_CALLS + ' extra free calls added -- URL Safety Validator MCP',
@@ -716,6 +834,39 @@ const server = http.createServer(async (req, res) => {
716
834
  return;
717
835
  }
718
836
 
837
+ // Fleet cron hits this hourly. Sends exactly one follow-up email per email
838
+ // address, 24h after a trial extension was granted, unless that email has
839
+ // since picked up a paid key on this server.
840
+ if (req.url === '/process-trial-followups' && req.method === 'POST') {
841
+ if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
842
+ (async () => {
843
+ const keys = await redisKeys(REDIS_PREFIX + ':followup:*');
844
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
845
+ let processed = 0, sent = 0, skippedPaid = 0;
846
+ for (const key of keys) {
847
+ const record = await redisGet(key);
848
+ if (!record || record.sent) continue;
849
+ if (Date.now() - new Date(record.granted_at).getTime() < TWENTY_FOUR_HOURS_MS) continue;
850
+ processed++;
851
+ const emailNorm = (record.email || '').toLowerCase().trim();
852
+ const hasPaidKey = Array.from(apiKeys.values()).some(r => (r.email || '').toLowerCase().trim() === emailNorm);
853
+ if (hasPaidKey) {
854
+ skippedPaid++;
855
+ } else {
856
+ await sendEmail(record.email, 'URL Safety Validator MCP -- URL safety screening will block your workflow again without an upgrade',
857
+ '<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>');
858
+ sent++;
859
+ }
860
+ record.sent = true;
861
+ record.sent_at = nowISO();
862
+ await redisSet(key, record);
863
+ }
864
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
865
+ res.end(JSON.stringify({ checked: keys.length, processed, emails_sent: sent, skipped_already_paid: skippedPaid }));
866
+ })();
867
+ return;
868
+ }
869
+
719
870
  if (req.url === '/webhook/stripe' && req.method === 'POST') {
720
871
  let rawBody = '';
721
872
  req.on('data', c => rawBody += c);
@@ -851,7 +1002,7 @@ const server = http.createServer(async (req, res) => {
851
1002
  let statusCode = 200;
852
1003
 
853
1004
  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.' } } };
1005
+ 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
1006
  } else if (request.method === 'notifications/initialized') {
856
1007
  res.writeHead(204, cors); res.end(); return;
857
1008
  } else if (request.method === 'tools/list') {
@@ -879,14 +1030,18 @@ const server = http.createServer(async (req, res) => {
879
1030
  const _gateMonth = getMonthKey();
880
1031
  const _gateCalls = (stats.free_tier_calls_by_ip[clientIp] && stats.free_tier_calls_by_ip[clientIp][_gateMonth]) || 0;
881
1032
  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 }) }] } };
1033
+ recordFleetGateHit(clientIp).catch(() => {});
1034
+ const crossServerNote = await buildCrossServerNote(clientIp);
1035
+ 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
1036
  } else {
884
1037
  recordCall(clientIp, apiKey);
885
1038
  saveFreeTierToRedis().catch(() => {});
886
1039
  const result = await checkUrl(url);
1040
+ result.calls_remaining = tier.paid ? 'unlimited' : Math.max(0, tier.remaining);
887
1041
  appendSessionLog(clientIp, 'check_url').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
888
1042
  usageLog.push({ tool: 'check_url', ip: clientIp, tier: tier.paid ? 'paid' : 'free', timestamp: nowISO() });
889
1043
  toolUsageCounts['check_url'] = (toolUsageCounts['check_url'] || 0) + 1;
1044
+ redisIncr(LIFETIME_CALLS_REDIS_KEY).catch(() => {});
890
1045
  if (tier.remaining <= 4 && !tier.paid) {
891
1046
  const effectiveLimit = getEffectiveLimit(clientIp);
892
1047
  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 +1071,7 @@ const server = http.createServer(async (req, res) => {
916
1071
  server.listen(PORT, async () => {
917
1072
  await loadApiKeysFromRedis();
918
1073
  await loadFreeTierFromRedis();
1074
+ await initUptimeTracking();
919
1075
  console.log(`URL Safety Validator MCP v${VERSION} running on port ${PORT}`);
920
1076
  console.log(`Google Web Risk: ${GOOGLE_WEB_RISK_API_KEY ? 'configured' : 'NOT SET -- set GOOGLE_WEB_RISK_API_KEY'}`);
921
1077
  console.log(`Anthropic API: ${ANTHROPIC_API_KEY ? 'configured' : 'NOT SET'}`);