url-safety-validator-mcp 1.2.10 → 1.2.14

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,18 @@
2
2
 
3
3
  All notable changes to URL Safety Validator MCP are documented here.
4
4
 
5
+ ## [1.2.14] — 2026-06-11
6
+ - feat: per-tool kill switch + per-minute rate limiting on AI tools
7
+
8
+ ## [1.2.13] — 2026-06-08
9
+ - fix: BEFORE trigger language, consequence-first limit error
10
+
11
+ ## [1.2.12] — 2026-06-05
12
+ - feat: Smithery optimisation - updated package.json description/keywords and smithery.yaml with system prompt
13
+
14
+ ## [1.2.11] — 2026-06-04
15
+ - feat: /daily-report endpoint for consolidated daily summary
16
+
5
17
  ## [1.2.10] — 2026-06-04
6
18
 
7
19
  ### Added
package/package.json CHANGED
@@ -1,31 +1,24 @@
1
1
  {
2
2
  "name": "url-safety-validator-mcp",
3
3
  "mcpName": "io.github.OjasKord/url-safety-validator-mcp",
4
- "version": "1.2.10",
5
- "description": "AI-powered URL safety validator MCP server. SAFE/SUSPICIOUS/DANGEROUS verdict for agents.",
4
+ "version": "1.2.14",
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": {
8
8
  "start": "node src/server.js"
9
9
  },
10
10
  "keywords": [
11
11
  "mcp",
12
- "agent",
13
- "url",
14
- "safety",
15
- "validator",
12
+ "url-safety",
16
13
  "phishing",
17
14
  "malware",
18
- "security",
19
- "threat-intelligence",
20
15
  "web-risk",
21
- "url-checker",
22
- "link-safety",
23
- "phishing-detection",
24
- "google-web-risk",
25
16
  "safe-browsing",
26
- "malware-detection",
27
- "url-scanner",
28
- "cybersecurity"
17
+ "cybersecurity",
18
+ "link-checker",
19
+ "threat-detection",
20
+ "url-validator",
21
+ "ai-agents"
29
22
  ],
30
23
  "author": "Kord Agencies Pte Ltd <ojas@kordagencies.com>",
31
24
  "license": "MIT",
package/smithery.yaml CHANGED
@@ -1,4 +1,8 @@
1
- description: "URL safety checker for AI agents. Detects phishing and malware before your agent visits any link."
1
+ name: URL Safety Validator MCP
2
+ 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."
3
+ categories:
4
+ - Security
5
+ - Web
2
6
  startCommand:
3
7
  type: http
4
8
  url: https://url-safety-validator-mcp-production.up.railway.app
@@ -11,42 +15,4 @@ startCommand:
11
15
  x-from:
12
16
  header: "x-api-key"
13
17
  required: []
14
- systemPrompt: |
15
- URL Safety Validator MCP is a link verification layer for AI agents.
16
-
17
- It combines multiple threat intelligence sources with AI-powered analysis to give agents a safety verdict before visiting, processing, or sharing any URL.
18
-
19
- ONE TOOL, ONE VERDICT
20
-
21
- check_url returns SAFE / SUSPICIOUS / DANGEROUS with a trust score 0-100, threat categories, and an agent_action field -- so your agent can proceed or block without any further analysis.
22
-
23
- WHEN TO USE THIS SERVER
24
-
25
- Use this tool whenever your agent is about to:
26
- - Visit or fetch content from any URL provided by a user or external source
27
- - Follow a link in an email, message, or document
28
- - Share or forward a URL to another system or user
29
- - Process a redirect or shortened URL
30
- - Include an unverified URL in a report or output
31
-
32
- RECOMMENDED WORKFLOW
33
-
34
- Before your agent visits or shares any URL:
35
- 1. check_url(url) - get SAFE / SUSPICIOUS / DANGEROUS verdict with trust score 0-100
36
- Only proceed if verdict is SAFE. Block immediately if DANGEROUS. Flag for review if SUSPICIOUS.
37
-
38
- TOOLS
39
-
40
- check_url (AI-POWERED)
41
- - Checks URL against Google Safe Browsing, domain reputation databases, and AI-powered phishing analysis
42
- - Returns: verdict (SAFE / SUSPICIOUS / DANGEROUS), trust_score 0-100, threat_categories, domain_age_days, redirect_chain, agent_action
43
- - AI analysis catches obfuscated phishing and newly registered domains that blocklists miss
44
- - One call replaces three separate threat intelligence lookups
45
- - Free tier: first 10 calls/month, no API key needed
46
-
47
- LEGAL NOTICE
48
- No URL safety check provides a 100% guarantee. Results are for informational purposes only. We do not log your query content. Full terms: kordagencies.com/terms.html
49
-
50
- FREE TIER
51
- 10 calls/month with no API key.
52
- Upgrade at kordagencies.com -- Pro $49/month, Enterprise $199/month.
18
+ systemPrompt: "You are a URL safety specialist. Before your agent fetches, visits, clicks, or passes any URL from an untrusted source - emails, scraped pages, user inputs, documents, API responses, or redirects - use check_url. A DANGEROUS verdict means stop immediately. Never follow an unchecked URL from an external source."
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.10';
8
+ const VERSION = '1.2.14';
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,22 @@ const toolUsageCounts = {};
29
29
  const trialExtensions = new Map();
30
30
  const TRIAL_EXTENSION_CALLS = 10;
31
31
 
32
+ const perMinuteUsage = new Map();
33
+
34
+ function checkPerMinuteLimit(ip, toolName, limit) {
35
+ const minuteKey = ip + ':' + toolName + ':' + new Date().toISOString().slice(0, 16);
36
+ const count = perMinuteUsage.get(minuteKey) || 0;
37
+ if (count >= limit) return false;
38
+ perMinuteUsage.set(minuteKey, count + 1);
39
+ if (perMinuteUsage.size > 10000) {
40
+ const currentMinute = new Date().toISOString().slice(0, 16);
41
+ for (const [key] of perMinuteUsage) {
42
+ if (!key.includes(currentMinute)) perMinuteUsage.delete(key);
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+
32
48
  const REDIS_PREFIX = 'url';
33
49
  const FREE_TIER_REDIS_KEY = 'url:free_tier_usage';
34
50
  const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
@@ -460,7 +476,7 @@ async function checkUrl(rawUrl) {
460
476
  const TOOL_DEFINITION = {
461
477
  name: 'check_url',
462
478
  idempotent: true,
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.',
479
+ description: 'Call this BEFORE your agent fetches, visits, follows, or forwards any URL from an untrusted source — at the moment a URL is received from an email, scraped page, user input, document, API response, or redirect and has not yet been acted on. Returns BLOCK/FLAG_AND_PROCEED/ALLOW verdict with trust score 0-100 and threat categories. An unchecked URL followed by your agent is one weaponised link away from credential exfiltration with no recovery path. A DANGEROUS verdict means stop immediately — do not proceed under any circumstances.',
464
480
  inputSchema: {
465
481
  type: 'object',
466
482
  properties: {
@@ -515,11 +531,16 @@ function setupStdio() {
515
531
  } else if (request.method === 'prompts/list') {
516
532
  response = { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
517
533
  } else if (request.method === 'tools/call' && request.params?.name === 'check_url') {
518
- const url = request.params?.arguments?.url;
519
- if (!url) { response = { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'url parameter required' } }; }
520
- else {
521
- const result = await checkUrl(url);
522
- response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
534
+ const _ks = 'TOOL_DISABLED_CHECK_URL';
535
+ if (process.env[_ks] === 'true') {
536
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'This tool is temporarily unavailable for maintenance.', agent_action: 'RETRY_IN_30_MIN', retryable: true, retry_after_ms: 1800000 }) }] } };
537
+ } else {
538
+ const url = request.params?.arguments?.url;
539
+ if (!url) { response = { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'url parameter required' } }; }
540
+ else {
541
+ const result = await checkUrl(url);
542
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
543
+ }
523
544
  }
524
545
  } else {
525
546
  response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
@@ -686,6 +707,58 @@ const server = http.createServer(async (req, res) => {
686
707
  return;
687
708
  }
688
709
 
710
+ if (req.url === '/daily-report' && req.method === 'POST') {
711
+ if (req.headers['x-stats-key'] !== process.env.STATS_KEY) {
712
+ res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return;
713
+ }
714
+ (async () => {
715
+ const today = new Date().toISOString().slice(0, 10);
716
+ const since24h = new Date(Date.now() - 86400000).toISOString();
717
+ const cutoffMs = Date.now() - 86400000;
718
+
719
+ const recentLog = usageLog.filter(e => e.timestamp >= since24h);
720
+ const calls24h = recentLog.length;
721
+ const unique24h = new Set(recentLog.map(e => e.ip)).size;
722
+
723
+ const month = new Date().toISOString().slice(0, 7);
724
+ let limitHits = 0;
725
+ for (const months of Object.values(stats.free_tier_calls_by_ip || {})) {
726
+ if ((months[month] || 0) >= FREE_LIMIT) limitHits++;
727
+ }
728
+
729
+ let trialCount = 0;
730
+ for (const record of trialExtensions.values()) {
731
+ if (record.granted_at && record.granted_at >= since24h) trialCount++;
732
+ }
733
+
734
+ let paidCount = 0;
735
+ for (const record of apiKeys.values()) {
736
+ const ts = record.created_at ? new Date(record.created_at).getTime() : 0;
737
+ if (ts >= cutoffMs) paidCount++;
738
+ }
739
+
740
+ const sessionKeys = await redisKeys(REDIS_PREFIX + ':session:*:' + today);
741
+ const toolBreakdown = {};
742
+ for (const key of sessionKeys) {
743
+ const calls = await redisGet(key) || [];
744
+ calls.forEach(c => { if (c.tool) toolBreakdown[c.tool] = (toolBreakdown[c.tool] || 0) + 1; });
745
+ }
746
+
747
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
748
+ res.end(JSON.stringify({
749
+ server: 'url-safety-validator-mcp',
750
+ date: today,
751
+ calls_24h: calls24h,
752
+ unique_ips_24h: unique24h,
753
+ limit_hits: limitHits,
754
+ trial_extensions: trialCount,
755
+ paid_conversions: paidCount,
756
+ tool_breakdown: toolBreakdown
757
+ }));
758
+ })();
759
+ return;
760
+ }
761
+
689
762
  // HTTP POST MCP handler -- mandatory
690
763
  if (req.method === 'POST' && req.url !== '/webhook/stripe') {
691
764
  let body = '';
@@ -708,13 +781,18 @@ const server = http.createServer(async (req, res) => {
708
781
  } else if (request.method === 'prompts/list') {
709
782
  response = { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
710
783
  } else if (request.method === 'tools/call' && request.params?.name === 'check_url') {
784
+ if (process.env['TOOL_DISABLED_CHECK_URL'] === 'true') {
785
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'This tool is temporarily unavailable for maintenance.', agent_action: 'RETRY_IN_30_MIN', retryable: true, retry_after_ms: 1800000 }) }] } };
786
+ } else if (!checkPerMinuteLimit(clientIp, 'check_url', 5)) {
787
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'Rate limit exceeded — maximum 5 calls per minute per IP on AI-powered tools. Your workflow is calling this tool too rapidly.', agent_action: 'RETRY_IN_60_SEC', retryable: true, retry_after_ms: 60000, limit: 5, window: '1 minute' }) }] } };
788
+ } else {
711
789
  const url = request.params?.arguments?.url;
712
790
  if (!url) {
713
791
  response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'url parameter required', likely_cause: 'required field missing or malformed URL provided', retryable: false, retry_after_ms: null, fallback_tool: null, agent_action: 'Retry with a url parameter value. Example: {"url":"https://example.com"}', category: 'invalid_input', trace_id: crypto.randomBytes(8).toString('hex'), _disclaimer: LEGAL_DISCLAIMER }) }] } };
714
792
  } else {
715
793
  const tier = checkTier(clientIp, apiKey);
716
794
  if (!tier.allowed) {
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 }) }] } };
795
+ 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 }) }] } };
718
796
  } else {
719
797
  recordCall(clientIp, apiKey);
720
798
  saveFreeTierToRedis().catch(() => {});
@@ -729,6 +807,7 @@ const server = http.createServer(async (req, res) => {
729
807
  response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
730
808
  }
731
809
  }
810
+ }
732
811
  } else {
733
812
  response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
734
813
  }