url-safety-validator-mcp 1.2.9 → 1.2.10
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 +15 -0
- package/package.json +1 -1
- package/src/server.js +154 -7
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.10] — 2026-06-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Upstash Redis persistence: free tier usage, API keys, session logs survive redeploys
|
|
9
|
+
- `loadFreeTierFromRedis()` / `saveFreeTierToRedis()` with Math.max merge (adapted for stats object structure)
|
|
10
|
+
- `saveKeyToRedis()` / `loadApiKeysFromRedis()` with prefix `url`
|
|
11
|
+
- `appendSessionLog(ip, tool)` with 24h TTL per IP per day
|
|
12
|
+
- `/session-log` endpoint (requires x-stats-key)
|
|
13
|
+
- `free_tier_breakdown` per-IP object on `/stats` response for current month
|
|
14
|
+
- `getEffectiveLimit(ip)` helper — returns base + trial extension if applicable
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `check_url` tool description rewritten for orchestral agent runtime selection: state-based trigger, verdict consequences, DO NOT USE conditions
|
|
18
|
+
- `VERSION` bumped to `1.2.10`
|
|
19
|
+
|
|
5
20
|
## [1.2.9] — 2026-06-02
|
|
6
21
|
|
|
7
22
|
### Fixed
|
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.10",
|
|
5
5
|
"description": "AI-powered URL safety validator MCP server. SAFE/SUSPICIOUS/DANGEROUS verdict for agents.",
|
|
6
6
|
"main": "src/server.js",
|
|
7
7
|
"scripts": {
|
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.10';
|
|
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 PORT = process.env.PORT || 3000;
|
|
@@ -29,6 +29,11 @@ const toolUsageCounts = {};
|
|
|
29
29
|
const trialExtensions = new Map();
|
|
30
30
|
const TRIAL_EXTENSION_CALLS = 10;
|
|
31
31
|
|
|
32
|
+
const REDIS_PREFIX = 'url';
|
|
33
|
+
const FREE_TIER_REDIS_KEY = 'url:free_tier_usage';
|
|
34
|
+
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
|
|
35
|
+
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
36
|
+
|
|
32
37
|
function loadStats() {
|
|
33
38
|
try {
|
|
34
39
|
const data = JSON.parse(fs.readFileSync(PERSIST_FILE, 'utf8'));
|
|
@@ -75,6 +80,13 @@ function getMonthKey() {
|
|
|
75
80
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
function getEffectiveLimit(ip) {
|
|
84
|
+
for (const record of trialExtensions.values()) {
|
|
85
|
+
if (record.ip === ip) return FREE_LIMIT + TRIAL_EXTENSION_CALLS;
|
|
86
|
+
}
|
|
87
|
+
return FREE_LIMIT;
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
function checkTier(ip, apiKey) {
|
|
79
91
|
if (apiKey && apiKeys.has(apiKey)) return { allowed: true, paid: true, remaining: Infinity };
|
|
80
92
|
const month = getMonthKey();
|
|
@@ -92,6 +104,106 @@ function recordCall(ip, apiKey) {
|
|
|
92
104
|
stats.free_tier_calls_by_ip[ip][month] = (stats.free_tier_calls_by_ip[ip][month] || 0) + 1;
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
// ─── REDIS HELPERS ────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async function redisGet(key) {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(
|
|
112
|
+
`${UPSTASH_URL}/get/${encodeURIComponent(key)}`,
|
|
113
|
+
{ headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
114
|
+
);
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
if (data.error) console.error('[Redis] redisGet error:', data.error, 'key:', key);
|
|
117
|
+
if (!data.result) return null;
|
|
118
|
+
return JSON.parse(data.result);
|
|
119
|
+
} catch(e) { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function redisSet(key, value) {
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}/set/${encodeURIComponent(key)}/${encodeURIComponent(JSON.stringify(value))}`, {
|
|
125
|
+
method: 'GET',
|
|
126
|
+
headers: { Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}` }
|
|
127
|
+
});
|
|
128
|
+
const data = await res.json();
|
|
129
|
+
if (data.error) console.error('[Redis] redisSet error:', data.error, 'key:', key);
|
|
130
|
+
} catch(e) { console.error('[Redis] redisSet failed:', e); }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function redisExpire(key, seconds) {
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(
|
|
136
|
+
`${UPSTASH_URL}/expire/${encodeURIComponent(key)}/${seconds}`,
|
|
137
|
+
{ method: 'POST', headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
138
|
+
);
|
|
139
|
+
const data = await res.json();
|
|
140
|
+
if (data.error) console.error('[Redis] redisExpire error:', data.error, 'key:', key);
|
|
141
|
+
} catch(e) { console.error('[Redis] redisExpire failed:', e); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function redisKeys(pattern) {
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch(
|
|
147
|
+
`${UPSTASH_URL}/keys/${encodeURIComponent(pattern)}`,
|
|
148
|
+
{ headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
149
|
+
);
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
if (data.error) console.error('[Redis] redisKeys error:', data.error, 'pattern:', pattern);
|
|
152
|
+
return data.result || [];
|
|
153
|
+
} catch(e) { return []; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function appendSessionLog(ip, tool) {
|
|
157
|
+
try {
|
|
158
|
+
const ipSafe = ip.replace(/:/g, '_').replace(/\s/g, '');
|
|
159
|
+
const dayKey = new Date().toISOString().slice(0, 10);
|
|
160
|
+
const key = `${REDIS_PREFIX}:session:${ipSafe}:${dayKey}`;
|
|
161
|
+
const existing = await redisGet(key) || [];
|
|
162
|
+
existing.push({ tool, timestamp: new Date().toISOString() });
|
|
163
|
+
await redisSet(key, existing);
|
|
164
|
+
await redisExpire(key, 86400);
|
|
165
|
+
} catch(e) { console.error('[SessionLog] internal error:', e); }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function saveKeyToRedis(apiKey, record) {
|
|
169
|
+
await redisSet(`${REDIS_PREFIX}:key:${apiKey}`, record);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function loadApiKeysFromRedis() {
|
|
173
|
+
const keys = await redisKeys(`${REDIS_PREFIX}:key:*`);
|
|
174
|
+
for (const redisKey of keys) {
|
|
175
|
+
const record = await redisGet(redisKey);
|
|
176
|
+
if (record) {
|
|
177
|
+
const apiKey = redisKey.replace(`${REDIS_PREFIX}:key:`, '');
|
|
178
|
+
apiKeys.set(apiKey, record);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
console.log(`Loaded ${apiKeys.size} API keys from Redis`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function loadFreeTierFromRedis() {
|
|
185
|
+
try {
|
|
186
|
+
const data = await redisGet(FREE_TIER_REDIS_KEY);
|
|
187
|
+
if (data && typeof data === 'object') {
|
|
188
|
+
Object.assign(stats.free_tier_calls_by_ip, data);
|
|
189
|
+
console.log('[FreeTier] Loaded ' + Object.keys(stats.free_tier_calls_by_ip).length + ' IPs from Redis');
|
|
190
|
+
}
|
|
191
|
+
} catch(e) { console.error('[FreeTier] load failed:', e); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function saveFreeTierToRedis() {
|
|
195
|
+
try {
|
|
196
|
+
const existing = await redisGet(FREE_TIER_REDIS_KEY) || {};
|
|
197
|
+
for (const [ip, months] of Object.entries(stats.free_tier_calls_by_ip)) {
|
|
198
|
+
if (!existing[ip]) existing[ip] = {};
|
|
199
|
+
for (const [month, count] of Object.entries(months)) {
|
|
200
|
+
existing[ip][month] = Math.max(existing[ip][month] || 0, count);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await redisSet(FREE_TIER_REDIS_KEY, existing);
|
|
204
|
+
} catch(e) { console.error('[FreeTier] save failed:', e); }
|
|
205
|
+
}
|
|
206
|
+
|
|
95
207
|
// ─── HTTP helper ──────────────────────────────────────────────────────────────
|
|
96
208
|
function httpsGet(hostname, path, headers, timeout) {
|
|
97
209
|
return new Promise((resolve) => {
|
|
@@ -348,7 +460,7 @@ async function checkUrl(rawUrl) {
|
|
|
348
460
|
const TOOL_DEFINITION = {
|
|
349
461
|
name: 'check_url',
|
|
350
462
|
idempotent: true,
|
|
351
|
-
description: '
|
|
463
|
+
description: 'Call this immediately before your agent fetches, visits, follows, or passes any URL from an untrusted source — emails, scraped pages, user inputs, documents, API responses, or redirects. A DANGEROUS verdict means do not proceed under any circumstances. A SUSPICIOUS verdict means flag for human review before proceeding. A SAFE verdict means proceed. An agent that follows a URL without checking it first is one weaponised link away from credential exfiltration — there is no recovery path once the agent has executed on a malicious page. Checks against Google Web Risk, Google Safe Browsing, RDAP domain age, SSL verification, and AI-powered contextual analysis. Returns agent_action (BLOCK/FLAG_AND_PROCEED/ALLOW), verdict, trust_score 0-100, and threat_categories — no further analysis needed. We do not log your query content. Free tier: 10 calls/month, no API key required.',
|
|
352
464
|
inputSchema: {
|
|
353
465
|
type: 'object',
|
|
354
466
|
properties: {
|
|
@@ -478,8 +590,36 @@ const server = http.createServer(async (req, res) => {
|
|
|
478
590
|
const ipMap = stats.free_tier_calls_by_ip || {};
|
|
479
591
|
const free_tier_unique_ips = Object.keys(ipMap).length;
|
|
480
592
|
const free_tier_total_calls = Object.values(ipMap).reduce((t, m) => t + Object.values(m).reduce((a,b) => a+b, 0), 0);
|
|
593
|
+
const month = getMonthKey();
|
|
594
|
+
const breakdown = {};
|
|
595
|
+
for (const [ip, months] of Object.entries(ipMap)) {
|
|
596
|
+
if (months[month] !== undefined) {
|
|
597
|
+
breakdown[ip.slice(0, 10) + '...'] = months[month];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
481
600
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
482
|
-
res.end(JSON.stringify({ version: VERSION, total_checks: stats.total_checks, safe_count: stats.safe_count, suspicious_count: stats.suspicious_count, dangerous_count: stats.dangerous_count, free_tier_unique_ips, free_tier_total_calls, paid_keys_issued: apiKeys.size, started_at: stats.started_at, tool_usage: toolUsageCounts, recent_calls: usageLog.slice(-20).reverse(), trial_extensions_granted: trialExtensions.size }));
|
|
601
|
+
res.end(JSON.stringify({ version: VERSION, total_checks: stats.total_checks, safe_count: stats.safe_count, suspicious_count: stats.suspicious_count, dangerous_count: stats.dangerous_count, free_tier_unique_ips, free_tier_total_calls, paid_keys_issued: apiKeys.size, started_at: stats.started_at, tool_usage: toolUsageCounts, recent_calls: usageLog.slice(-20).reverse(), trial_extensions_granted: trialExtensions.size, free_tier_breakdown: breakdown }));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (req.url === '/session-log' && req.method === 'GET') {
|
|
606
|
+
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
607
|
+
(async () => {
|
|
608
|
+
const keys = await redisKeys(`${REDIS_PREFIX}:session:*`);
|
|
609
|
+
const sessions = [];
|
|
610
|
+
for (const key of keys) {
|
|
611
|
+
const calls = await redisGet(key) || [];
|
|
612
|
+
if (!calls.length) continue;
|
|
613
|
+
const withoutPrefix = key.slice(`${REDIS_PREFIX}:session:`.length);
|
|
614
|
+
const dateIdx = withoutPrefix.lastIndexOf(':');
|
|
615
|
+
const ipPart = withoutPrefix.slice(0, dateIdx);
|
|
616
|
+
const date = withoutPrefix.slice(dateIdx + 1);
|
|
617
|
+
sessions.push({ ip: ipPart.slice(0, 8), date, calls, first_call: calls[0]?.timestamp || '', last_call: calls[calls.length - 1]?.timestamp || '' });
|
|
618
|
+
}
|
|
619
|
+
sessions.sort((a, b) => new Date(b.first_call) - new Date(a.first_call));
|
|
620
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
621
|
+
res.end(JSON.stringify(sessions));
|
|
622
|
+
})();
|
|
483
623
|
return;
|
|
484
624
|
}
|
|
485
625
|
|
|
@@ -518,7 +658,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
518
658
|
if (req.url === '/webhook/stripe' && req.method === 'POST') {
|
|
519
659
|
let rawBody = '';
|
|
520
660
|
req.on('data', c => rawBody += c);
|
|
521
|
-
req.on('end', () => {
|
|
661
|
+
req.on('end', async () => {
|
|
522
662
|
const sig = req.headers['stripe-signature'];
|
|
523
663
|
if (!verifyStripeSignature(rawBody, sig, STRIPE_WEBHOOK_SECRET)) {
|
|
524
664
|
res.writeHead(400, cors); res.end(JSON.stringify({ error: 'Invalid signature' })); return;
|
|
@@ -529,7 +669,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
529
669
|
const session = event.data.object;
|
|
530
670
|
const key = 'usv_' + crypto.randomBytes(16).toString('hex');
|
|
531
671
|
const email = session.customer_details?.email || session.customer_email || 'unknown';
|
|
532
|
-
|
|
672
|
+
const record = { email, created_at: nowISO(), plan: 'pro' };
|
|
673
|
+
apiKeys.set(key, record);
|
|
674
|
+
await saveKeyToRedis(key, record);
|
|
533
675
|
saveStats();
|
|
534
676
|
console.log('[stripe] API key issued to: ' + email);
|
|
535
677
|
if (email && email !== 'unknown') {
|
|
@@ -575,11 +717,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
575
717
|
response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'Free tier limit of ' + FREE_LIMIT + ' calls/month reached. Option 1: POST /trial-extension with {"name":"...","email":"...","use_case":"..."} for 10 extra free calls. Option 2: Upgrade at ' + PRO_UPGRADE_URL + ' (500 calls, never expire).', 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 }) }] } };
|
|
576
718
|
} else {
|
|
577
719
|
recordCall(clientIp, apiKey);
|
|
720
|
+
saveFreeTierToRedis().catch(() => {});
|
|
578
721
|
const result = await checkUrl(url);
|
|
722
|
+
appendSessionLog(clientIp, 'check_url').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
|
|
579
723
|
usageLog.push({ tool: 'check_url', ip: clientIp, tier: tier.paid ? 'paid' : 'free', timestamp: nowISO() });
|
|
580
724
|
toolUsageCounts['check_url'] = (toolUsageCounts['check_url'] || 0) + 1;
|
|
581
725
|
if (tier.remaining <= 4 && !tier.paid) {
|
|
582
|
-
|
|
726
|
+
const effectiveLimit = getEffectiveLimit(clientIp);
|
|
727
|
+
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.';
|
|
583
728
|
}
|
|
584
729
|
response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
|
|
585
730
|
}
|
|
@@ -602,7 +747,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
602
747
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
603
748
|
});
|
|
604
749
|
|
|
605
|
-
server.listen(PORT, () => {
|
|
750
|
+
server.listen(PORT, async () => {
|
|
751
|
+
await loadApiKeysFromRedis();
|
|
752
|
+
await loadFreeTierFromRedis();
|
|
606
753
|
console.log(`URL Safety Validator MCP v${VERSION} running on port ${PORT}`);
|
|
607
754
|
console.log(`Google Web Risk: ${GOOGLE_WEB_RISK_API_KEY ? 'configured' : 'NOT SET -- set GOOGLE_WEB_RISK_API_KEY'}`);
|
|
608
755
|
console.log(`Anthropic API: ${ANTHROPIC_API_KEY ? 'configured' : 'NOT SET'}`);
|