url-safety-validator-mcp 1.2.23 → 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 +12 -0
- package/README.md +4 -5
- package/glama.json +1 -1
- package/package.json +1 -1
- package/smithery.yaml +2 -1
- package/src/server.js +160 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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
|
+
|
|
14
|
+
## [1.2.24] — 2026-06-23
|
|
15
|
+
- fix: gate returns HTTP 402 (x402 standard for non-transient quota)
|
|
16
|
+
|
|
5
17
|
## [1.2.23] — 2026-06-20
|
|
6
18
|
- feat: email notification on free tier gate hit
|
|
7
19
|
|
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,
|
|
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
|
|
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
|
-
|
|
|
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
|
|
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
|
|
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.
|
|
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,5 +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: "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."
|
|
3
4
|
categories:
|
|
4
5
|
- Security
|
|
5
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.
|
|
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
|
|
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
|
|
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);
|
|
@@ -848,9 +994,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
848
994
|
const apiKey = req.headers['x-api-key'] || null;
|
|
849
995
|
const clientIp = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim();
|
|
850
996
|
let response;
|
|
997
|
+
let statusCode = 200;
|
|
851
998
|
|
|
852
999
|
if (request.method === 'initialize') {
|
|
853
|
-
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
|
|
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.' } } };
|
|
854
1001
|
} else if (request.method === 'notifications/initialized') {
|
|
855
1002
|
res.writeHead(204, cors); res.end(); return;
|
|
856
1003
|
} else if (request.method === 'tools/list') {
|
|
@@ -874,10 +1021,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
874
1021
|
} else {
|
|
875
1022
|
const tier = checkTier(clientIp, apiKey);
|
|
876
1023
|
if (!tier.allowed) {
|
|
1024
|
+
statusCode = 402;
|
|
877
1025
|
const _gateMonth = getMonthKey();
|
|
878
1026
|
const _gateCalls = (stats.free_tier_calls_by_ip[clientIp] && stats.free_tier_calls_by_ip[clientIp][_gateMonth]) || 0;
|
|
879
1027
|
notifyGateHit('URL Safety Validator', clientIp, 'check_url', _gateCalls, PRO_UPGRADE_URL);
|
|
880
|
-
|
|
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 }) }] } };
|
|
881
1031
|
} else {
|
|
882
1032
|
recordCall(clientIp, apiKey);
|
|
883
1033
|
saveFreeTierToRedis().catch(() => {});
|
|
@@ -885,6 +1035,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
885
1035
|
appendSessionLog(clientIp, 'check_url').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
|
|
886
1036
|
usageLog.push({ tool: 'check_url', ip: clientIp, tier: tier.paid ? 'paid' : 'free', timestamp: nowISO() });
|
|
887
1037
|
toolUsageCounts['check_url'] = (toolUsageCounts['check_url'] || 0) + 1;
|
|
1038
|
+
redisIncr(LIFETIME_CALLS_REDIS_KEY).catch(() => {});
|
|
888
1039
|
if (tier.remaining <= 4 && !tier.paid) {
|
|
889
1040
|
const effectiveLimit = getEffectiveLimit(clientIp);
|
|
890
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.';
|
|
@@ -897,7 +1048,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
897
1048
|
response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
|
|
898
1049
|
}
|
|
899
1050
|
|
|
900
|
-
res.writeHead(
|
|
1051
|
+
res.writeHead(statusCode, { ...cors, 'Content-Type': 'application/json' });
|
|
901
1052
|
res.end(JSON.stringify(response));
|
|
902
1053
|
} catch(e) {
|
|
903
1054
|
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
|
|
@@ -914,6 +1065,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
914
1065
|
server.listen(PORT, async () => {
|
|
915
1066
|
await loadApiKeysFromRedis();
|
|
916
1067
|
await loadFreeTierFromRedis();
|
|
1068
|
+
await initUptimeTracking();
|
|
917
1069
|
console.log(`URL Safety Validator MCP v${VERSION} running on port ${PORT}`);
|
|
918
1070
|
console.log(`Google Web Risk: ${GOOGLE_WEB_RISK_API_KEY ? 'configured' : 'NOT SET -- set GOOGLE_WEB_RISK_API_KEY'}`);
|
|
919
1071
|
console.log(`Anthropic API: ${ANTHROPIC_API_KEY ? 'configured' : 'NOT SET'}`);
|