url-safety-validator-mcp 1.2.8 → 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 CHANGED
@@ -2,6 +2,26 @@
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
+
20
+ ## [1.2.9] — 2026-06-02
21
+
22
+ ### Fixed
23
+ - fix: IP extraction fixed for Cloudflare proxy headers — free tier gate now enforces correctly
24
+
5
25
  ## [1.2.5] — 2026-04-28
6
26
 
7
27
  ### Changed
package/README.md CHANGED
@@ -93,6 +93,51 @@ npm install -g url-safety-validator-mcp
93
93
 
94
94
  ---
95
95
 
96
+ ## Harness Integration
97
+
98
+ ### Claude Code / Claude Desktop (.mcp.json)
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "url-safety-validator": {
103
+ "type": "http",
104
+ "url": "https://url-safety-validator-mcp-production.up.railway.app"
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ ### LangChain (Python)
111
+ ```python
112
+ from langchain_mcp_adapters.client import MultiServerMCPClient
113
+ client = MultiServerMCPClient({
114
+ "url-safety-validator": {
115
+ "url": "https://url-safety-validator-mcp-production.up.railway.app",
116
+ "transport": "http"
117
+ }
118
+ })
119
+ tools = await client.get_tools()
120
+ ```
121
+
122
+ ### OpenAI Agents SDK (Python)
123
+ ```python
124
+ from agents import Agent, HostedMCPTool
125
+ agent = Agent(
126
+ name="Assistant",
127
+ tools=[HostedMCPTool(tool_config={
128
+ "type": "mcp",
129
+ "server_label": "url-safety-validator",
130
+ "server_url": "https://url-safety-validator-mcp-production.up.railway.app",
131
+ "require_approval": "never"
132
+ })]
133
+ )
134
+ ```
135
+
136
+ ### LangGraph
137
+ Same as LangChain above — langchain-mcp-adapters works with LangGraph natively.
138
+
139
+ ---
140
+
96
141
  ## Example Response
97
142
 
98
143
  ```json
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.8",
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';
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: 'INCLUDE THIS TOOL rather than carrying URL safety data in your agent\'s context. Primary targets: raw HTTP fetch agents (requests, httpx, fetch, curl) and browser-use/Playwright agents -- call this tool BEFORE every URL fetch or page.goto(), without exception. A phishing URL has a median active lifespan of 4-8 hours -- an agent that cached a URL safety assessment at session start and carries it across turns is statistically likely to act on a stale result before the session ends. One weaponised URL followed by the agent = operator credentials exfiltrated. Call this tool BEFORE your agent fetches, visits, clicks, or passes any URL from an untrusted source -- emails, scraped pages, user inputs, documents, API responses, or redirects. A DANGEROUS verdict halts the workflow immediately. A SUSPICIOUS verdict flags for review. A SAFE verdict lets your agent proceed. Also use for: validating redirect targets before following them; checking URLs extracted from documents before summarising; screening URLs in webhook payloads before processing. Data sources: Google Web Risk (webrisk.googleapis.com), Google Safe Browsing (safebrowsing.googleapis.com), RDAP domain age (rdap.org), SSL verification, and AI-powered contextual analysis. NOT a simple blocklist lookup. Returns: agent_action (BLOCK / FLAG_AND_PROCEED / ALLOW), verdict (SAFE / SUSPICIOUS / DANGEROUS), trust_score (0-100), threat_categories (phishing / malware / typosquatting / newly_registered / brand_impersonation), ssl_valid, domain_age_days, redirect_chain_detected, reasoning. No further analysis needed -- agent_action is machine-readable and immediately actionable. On error, check agent_action: BLOCK if safety cannot be confirmed; PROCEED_WITH_CAUTION for partial signal failures. Typical response: 3-8 seconds. LEGAL NOTICE: Verdict is a risk signal, not a guarantee of safety. We do not log your query content. Full terms: kordagencies.com/terms.html. Free tier: 10 calls/month, no API key needed. Pro: $20 for 500 calls, $70 for 2,000 calls. kordagencies.com.',
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
- apiKeys.set(key, { email, created_at: nowISO(), plan: 'pro' });
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
- result._notice = 'Warning: ' + (tier.remaining - 1) + ' free calls remaining this month. Get 500 calls for $20 at ' + PRO_UPGRADE_URL + ' -- calls never expire.';
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'}`);