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 +12 -0
- package/package.json +8 -15
- package/smithery.yaml +6 -40
- package/src/server.js +87 -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.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.
|
|
5
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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
|
}
|