url-safety-validator-mcp 1.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to URL Safety Validator MCP are documented here.
4
+
5
+ ## [1.0.0] — 2026-04-22
6
+
7
+ ### Initial Release
8
+ - `check_url` tool: SAFE/SUSPICIOUS/DANGEROUS verdict with AI trust score 0-100
9
+ - Google Web Risk integration (malware, phishing, unwanted software)
10
+ - URLhaus integration (active malware distribution URLs)
11
+ - PhishTank integration (community-verified phishing URLs)
12
+ - RDAP domain age lookup
13
+ - SSL validation check
14
+ - Redirect chain parameter detection
15
+ - AI-powered trust scoring and reasoning via Claude
16
+ - Free tier: 10 calls/month per IP, no API key required
17
+ - Pro tier: unlimited calls via API key
18
+ - Stripe webhook for automated key provisioning
19
+ - `/health`, `/deps`, `/stats` endpoints
20
+ - `/.well-known/mcp/server-card.json` for Smithery discovery
21
+ - Both stdio and HTTP POST MCP transport
package/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ UNLICENSED
2
+
3
+ Copyright (c) 2026 Kord Agencies Pte Ltd, Singapore.
4
+
5
+ All rights reserved. This software and associated documentation files are proprietary
6
+ and confidential. No part of this software may be reproduced, distributed, or
7
+ transmitted in any form or by any means without the prior written permission of
8
+ Kord Agencies Pte Ltd.
9
+
10
+ Use of this software is subject to the terms at https://kordagencies.com/terms.html
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # URL Safety Validator MCP
2
+
3
+ **Stop your agent from fetching a dangerous URL before it's too late.**
4
+
5
+ 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, URLhaus, PhishTank, and AI analysis.
6
+
7
+ ---
8
+
9
+ ## What It Does
10
+
11
+ One tool: `check_url`. One call returns:
12
+
13
+ - **Verdict:** SAFE / SUSPICIOUS / DANGEROUS
14
+ - **AI trust score:** 0–100 (0 = definitely dangerous, 100 = definitely safe)
15
+ - **Threat categories:** phishing, malware, unwanted_software, typosquatting, newly_registered, suspicious_redirect, brand_impersonation
16
+ - **SSL status:** valid or not
17
+ - **Domain age:** registration date and age in days
18
+ - **Redirect chain flag:** detected from URL parameters
19
+ - **Database signals:** raw results from Google Web Risk, URLhaus, PhishTank
20
+ - **AI reasoning:** 2–3 sentence plain-English explanation
21
+ - **AI confidence:** HIGH / MEDIUM / LOW
22
+
23
+ AI-powered analysis — NOT a simple database lookup.
24
+
25
+ ---
26
+
27
+ ## When to Call This Tool
28
+
29
+ Call `check_url` BEFORE your agent:
30
+ - Fetches content from a URL found in an email
31
+ - Visits a link extracted from a scraped page or document
32
+ - Passes a URL to a browser tool or web scraper
33
+ - Stores or forwards a URL from any untrusted source
34
+ - Approves any outbound link in a content pipeline
35
+
36
+ If the verdict is DANGEROUS — halt. If SUSPICIOUS — flag for review. If SAFE — proceed.
37
+
38
+ ---
39
+
40
+ ## Data Sources
41
+
42
+ | Source | Type | Coverage |
43
+ |---|---|---|
44
+ | Google Web Risk | Commercial API | Malware, phishing, unwanted software |
45
+ | URLhaus (abuse.ch) | Free | Active malware distribution URLs |
46
+ | PhishTank | Free | Community-verified phishing URLs |
47
+ | RDAP | Free | Domain registration date |
48
+ | Anthropic Claude | AI | Trust scoring and reasoning synthesis |
49
+
50
+ ---
51
+
52
+ ## Pricing
53
+
54
+ | Tier | Calls | Price |
55
+ |---|---|---|
56
+ | Free | 10/month | No API key needed |
57
+ | Pro | Unlimited | $29/month — kordagencies.com |
58
+ | Enterprise | Unlimited + SLA | $99/month — kordagencies.com |
59
+
60
+ ---
61
+
62
+ ## Remote Usage (No Install)
63
+
64
+ ```
65
+ https://url-safety-validator-mcp-production.up.railway.app
66
+ ```
67
+
68
+ Add `x-api-key: YOUR_KEY` header for Pro/Enterprise tiers. Leave blank for free tier.
69
+
70
+ ---
71
+
72
+ ## Local Install (stdio)
73
+
74
+ ```bash
75
+ npm install -g url-safety-validator-mcp
76
+ ```
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "url-safety-validator": {
82
+ "command": "url-safety-validator-mcp",
83
+ "env": {
84
+ "ANTHROPIC_API_KEY": "your-key",
85
+ "GOOGLE_WEB_RISK_API_KEY": "your-key"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Example Response
95
+
96
+ ```json
97
+ {
98
+ "url": "https://suspicious-domain.xyz/login",
99
+ "hostname": "suspicious-domain.xyz",
100
+ "verdict": "DANGEROUS",
101
+ "trust_score": 4,
102
+ "ssl_valid": true,
103
+ "domain_age_days": 12,
104
+ "redirect_chain_detected": false,
105
+ "threat_categories": ["phishing", "newly_registered"],
106
+ "reasoning": "Domain registered 12 days ago and confirmed in PhishTank as an active phishing site impersonating a financial institution. Google Web Risk flags this as SOCIAL_ENGINEERING.",
107
+ "ai_confidence": "HIGH",
108
+ "analysis_type": "AI-powered -- NOT a simple database lookup"
109
+ }
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Legal
115
+
116
+ Results are for informational purposes only. Verdict is a risk signal — not a guarantee of safety or danger. We do not log or store your query content. Full terms: kordagencies.com/terms.html
117
+
118
+ Provider: Kord Agencies Pte Ltd, Singapore.
package/glama.json ADDED
@@ -0,0 +1,7 @@
1
+ {
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, URLhaus, and PhishTank.",
4
+ "license": "UNLICENSED",
5
+ "homepage": "https://kordagencies.com",
6
+ "repository": "https://github.com/OjasKord/url-safety-validator-mcp"
7
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "url-safety-validator-mcp",
3
+ "version": "1.1.0",
4
+ "description": "AI-powered URL safety validator MCP server. SAFE/SUSPICIOUS/DANGEROUS verdict for agents.",
5
+ "main": "src/server.js",
6
+ "scripts": {
7
+ "start": "node src/server.js"
8
+ },
9
+ "license": "UNLICENSED",
10
+ "homepage": "https://kordagencies.com",
11
+ "mcpName": "io.github.OjasKord/url-safety-validator-mcp",
12
+ "keywords": [
13
+ "mcp",
14
+ "agent",
15
+ "url",
16
+ "safety",
17
+ "validator",
18
+ "phishing",
19
+ "malware",
20
+ "security",
21
+ "threat-intelligence",
22
+ "web-risk"
23
+ ],
24
+ "author": "Kord Agencies Pte Ltd",
25
+ "dependencies": {}
26
+ }
package/server.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.OjasKord/url-safety-validator-mcp",
4
+ "version": "1.0.0",
5
+ "description": "AI URL safety validator: SAFE/SUSPICIOUS/DANGEROUS verdict, trust score, threat intel.",
6
+ "title": "URL Safety Validator",
7
+ "websiteUrl": "https://kordagencies.com",
8
+ "repository": {
9
+ "url": "https://github.com/OjasKord/url-safety-validator-mcp",
10
+ "source": "github"
11
+ },
12
+ "packages": [
13
+ {
14
+ "registryType": "npm",
15
+ "registryBaseUrl": "https://registry.npmjs.org",
16
+ "identifier": "url-safety-validator-mcp",
17
+ "version": "1.0.0",
18
+ "transport": { "type": "stdio" },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "ANTHROPIC_API_KEY",
22
+ "description": "Anthropic API key for AI trust scoring",
23
+ "isRequired": true,
24
+ "isSecret": true
25
+ },
26
+ {
27
+ "name": "GOOGLE_WEB_RISK_API_KEY",
28
+ "description": "Google Web Risk API key (commercial). Server degrades gracefully without it.",
29
+ "isRequired": false,
30
+ "isSecret": true
31
+ }
32
+ ]
33
+ }
34
+ ],
35
+ "remotes": [
36
+ {
37
+ "type": "streamable-http",
38
+ "url": "https://url-safety-validator-mcp-production.up.railway.app"
39
+ }
40
+ ]
41
+ }
package/smithery.yaml ADDED
@@ -0,0 +1,12 @@
1
+ startCommand:
2
+ type: http
3
+ url: https://url-safety-validator-mcp-production.up.railway.app
4
+ configSchema:
5
+ type: object
6
+ properties:
7
+ apiKey:
8
+ type: string
9
+ description: "API key from kordagencies.com. Leave blank for free tier (10 calls/month)."
10
+ x-from:
11
+ header: "x-api-key"
12
+ required: []
package/src/server.js ADDED
@@ -0,0 +1,525 @@
1
+ 'use strict';
2
+ const http = require('http');
3
+ const https = require('https');
4
+ const fs = require('fs');
5
+ const crypto = require('crypto');
6
+ const { Readable } = require('stream');
7
+
8
+ const VERSION = '1.1.0';
9
+ const PORT = process.env.PORT || 3000;
10
+ const STATS_KEY = process.env.STATS_KEY || 'ojas2026';
11
+ const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || '';
12
+ const GOOGLE_WEB_RISK_API_KEY = process.env.GOOGLE_WEB_RISK_API_KEY || '';
13
+ const GOOGLE_SAFE_BROWSING_API_KEY = process.env.GOOGLE_SAFE_BROWSING_API_KEY || '';
14
+ const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
15
+ const PERSIST_FILE = '/tmp/urlsafety_stats.json';
16
+
17
+ const LEGAL_DISCLAIMER = 'Results sourced from Google Web Risk, Google Safe Browsing, and AI analysis. We do not log or store your query content. Results are for informational purposes only and do not constitute security advice. Verdict is a risk signal — not a guarantee of safety or danger. Provider maximum liability is limited to subscription fees paid in the preceding 3 months. Full terms: kordagencies.com/terms.html';
18
+
19
+ const FREE_LIMIT = 10;
20
+
21
+ // ─── Stats ────────────────────────────────────────────────────────────────────
22
+ let stats = { free_tier_calls_by_ip: {}, total_checks: 0, safe_count: 0, suspicious_count: 0, dangerous_count: 0, started_at: new Date().toISOString() };
23
+ const apiKeys = new Map();
24
+
25
+ function loadStats() {
26
+ try {
27
+ const data = JSON.parse(fs.readFileSync(PERSIST_FILE, 'utf8'));
28
+ stats = data.stats || stats;
29
+ if (data.api_keys) data.api_keys.forEach(([k, v]) => apiKeys.set(k, v));
30
+ } catch(e) { /* fresh start */ }
31
+ }
32
+
33
+ function saveStats() {
34
+ try {
35
+ fs.writeFileSync(PERSIST_FILE, JSON.stringify({ stats, api_keys: [...apiKeys.entries()] }));
36
+ } catch(e) {}
37
+ }
38
+
39
+ loadStats();
40
+
41
+ function nowISO() { return new Date().toISOString(); }
42
+
43
+ // ─── Free/Paid Tier ───────────────────────────────────────────────────────────
44
+ function getMonthKey() {
45
+ const d = new Date();
46
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
47
+ }
48
+
49
+ function checkTier(ip, apiKey) {
50
+ if (apiKey && apiKeys.has(apiKey)) return { allowed: true, paid: true, remaining: Infinity };
51
+ const month = getMonthKey();
52
+ const ipMap = stats.free_tier_calls_by_ip;
53
+ if (!ipMap[ip]) ipMap[ip] = {};
54
+ const used = ipMap[ip][month] || 0;
55
+ if (used >= FREE_LIMIT) return { allowed: false, paid: false, remaining: 0 };
56
+ return { allowed: true, paid: false, remaining: FREE_LIMIT - used };
57
+ }
58
+
59
+ function recordCall(ip, apiKey) {
60
+ if (apiKey && apiKeys.has(apiKey)) return;
61
+ const month = getMonthKey();
62
+ if (!stats.free_tier_calls_by_ip[ip]) stats.free_tier_calls_by_ip[ip] = {};
63
+ stats.free_tier_calls_by_ip[ip][month] = (stats.free_tier_calls_by_ip[ip][month] || 0) + 1;
64
+ }
65
+
66
+ // ─── HTTP helper ──────────────────────────────────────────────────────────────
67
+ function httpsGet(hostname, path, headers, timeout) {
68
+ return new Promise((resolve) => {
69
+ const req = https.request({ hostname, path, method: 'GET', headers: Object.assign({ 'User-Agent': 'MCPUrlSafetyValidator/1.0' }, headers || {}) }, (res) => {
70
+ let body = '';
71
+ res.on('data', c => body += c);
72
+ res.on('end', () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body }));
73
+ });
74
+ req.on('error', (e) => resolve({ ok: false, status: 0, error: e.message }));
75
+ req.setTimeout(timeout || 6000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
76
+ req.end();
77
+ });
78
+ }
79
+
80
+ function httpsPost(hostname, path, postBody, headers, timeout) {
81
+ return new Promise((resolve) => {
82
+ const data = typeof postBody === 'string' ? postBody : JSON.stringify(postBody);
83
+ const opts = {
84
+ hostname, path, method: 'POST',
85
+ headers: Object.assign({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'User-Agent': 'MCPUrlSafetyValidator/1.0' }, headers || {})
86
+ };
87
+ const req = https.request(opts, (res) => {
88
+ let body = '';
89
+ res.on('data', c => body += c);
90
+ res.on('end', () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body }));
91
+ });
92
+ req.on('error', (e) => resolve({ ok: false, status: 0, error: e.message }));
93
+ req.setTimeout(timeout || 8000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
94
+ req.write(data);
95
+ req.end();
96
+ });
97
+ }
98
+
99
+ // ─── URL parsing helper ───────────────────────────────────────────────────────
100
+ function parseUrl(rawUrl) {
101
+ try {
102
+ let u = rawUrl.trim();
103
+ if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
104
+ const parsed = new URL(u);
105
+ return { valid: true, href: parsed.href, hostname: parsed.hostname, protocol: parsed.protocol };
106
+ } catch(e) {
107
+ return { valid: false };
108
+ }
109
+ }
110
+
111
+ // ─── Google Web Risk ──────────────────────────────────────────────────────────
112
+ async function checkGoogleWebRisk(url) {
113
+ if (!GOOGLE_WEB_RISK_API_KEY) return { available: false, reason: 'GOOGLE_WEB_RISK_API_KEY not set' };
114
+ const encoded = encodeURIComponent(url);
115
+ const path = `/v1/uris:search?threatTypes=MALWARE&threatTypes=SOCIAL_ENGINEERING&threatTypes=UNWANTED_SOFTWARE&uri=${encoded}&key=${GOOGLE_WEB_RISK_API_KEY}`;
116
+ const r = await httpsGet('webrisk.googleapis.com', path, {}, 6000);
117
+ if (!r.ok) return { available: false, reason: `Google Web Risk API error: ${r.status}` };
118
+ try {
119
+ const parsed = JSON.parse(r.body);
120
+ const threat = parsed.threat;
121
+ if (threat && threat.threatTypes && threat.threatTypes.length > 0) {
122
+ return { available: true, flagged: true, threat_types: threat.threatTypes, expires_time: threat.expireTime };
123
+ }
124
+ return { available: true, flagged: false, threat_types: [] };
125
+ } catch(e) {
126
+ return { available: false, reason: 'Parse error' };
127
+ }
128
+ }
129
+
130
+ // ─── Google Safe Browsing ────────────────────────────────────────────────────
131
+ async function checkGoogleSafeBrowsing(url) {
132
+ if (!GOOGLE_SAFE_BROWSING_API_KEY) return { available: false, reason: 'GOOGLE_SAFE_BROWSING_API_KEY not set' };
133
+ const body = JSON.stringify({
134
+ client: { clientId: 'kord-url-safety-mcp', clientVersion: VERSION },
135
+ threatInfo: {
136
+ threatTypes: ['MALWARE', 'SOCIAL_ENGINEERING', 'UNWANTED_SOFTWARE', 'POTENTIALLY_HARMFUL_APPLICATION'],
137
+ platformTypes: ['ANY_PLATFORM'],
138
+ threatEntryTypes: ['URL'],
139
+ threatEntries: [{ url }]
140
+ }
141
+ });
142
+ const path = `/v4/threatMatches:find?key=${GOOGLE_SAFE_BROWSING_API_KEY}`;
143
+ const r = await httpsPost('safebrowsing.googleapis.com', path, body, { 'Content-Type': 'application/json' }, 6000);
144
+ if (!r.ok) return { available: false, reason: `Google Safe Browsing error: ${r.status}` };
145
+ try {
146
+ const parsed = JSON.parse(r.body);
147
+ const matches = parsed.matches || [];
148
+ return {
149
+ available: true,
150
+ flagged: matches.length > 0,
151
+ threat_types: matches.map(m => m.threatType),
152
+ platform_types: matches.map(m => m.platformType)
153
+ };
154
+ } catch(e) {
155
+ return { available: false, reason: 'Parse error' };
156
+ }
157
+ }
158
+
159
+ // ─── WHOIS domain age via RDAP ────────────────────────────────────────────────
160
+ async function checkDomainAge(hostname) {
161
+ const r = await httpsGet('rdap.org', `/domain/${hostname}`, {}, 6000);
162
+ if (!r.ok) return { available: false, reason: 'RDAP unavailable' };
163
+ try {
164
+ const parsed = JSON.parse(r.body);
165
+ const events = parsed.events || [];
166
+ const reg = events.find(e => e.eventAction === 'registration');
167
+ if (!reg) return { available: true, registration_date: null, domain_age_days: null };
168
+ const regDate = new Date(reg.eventDate);
169
+ const ageDays = Math.floor((Date.now() - regDate.getTime()) / 86400000);
170
+ return { available: true, registration_date: reg.eventDate, domain_age_days: ageDays };
171
+ } catch(e) {
172
+ return { available: false, reason: 'Parse error' };
173
+ }
174
+ }
175
+
176
+ // ─── SSL check ────────────────────────────────────────────────────────────────
177
+ async function checkSSL(hostname) {
178
+ return new Promise((resolve) => {
179
+ const req = https.request({ hostname, path: '/', method: 'HEAD', rejectUnauthorized: true }, (res) => {
180
+ res.resume();
181
+ resolve({ valid_ssl: true, status: res.statusCode });
182
+ });
183
+ req.on('error', (e) => resolve({ valid_ssl: false, error: e.message }));
184
+ req.setTimeout(5000, () => { req.destroy(); resolve({ valid_ssl: false, error: 'timeout' }); });
185
+ req.end();
186
+ });
187
+ }
188
+
189
+ // ─── AI Trust Score ───────────────────────────────────────────────────────────
190
+ async function getAITrustScore(url, hostname, signals) {
191
+ if (!ANTHROPIC_API_KEY) return { available: false, reason: 'ANTHROPIC_API_KEY not set' };
192
+ const prompt = `You are a URL safety analyst. Assess this URL and return a JSON object only, no markdown.
193
+
194
+ URL: ${url}
195
+ Hostname: ${hostname}
196
+
197
+ Signals from external databases:
198
+ ${JSON.stringify(signals, null, 2)}
199
+
200
+ Return this exact JSON structure:
201
+ {
202
+ "trust_score": <integer 0-100, where 0=definitely dangerous, 100=definitely safe>,
203
+ "verdict": "<SAFE|SUSPICIOUS|DANGEROUS>",
204
+ "threat_categories": [<list of applicable strings: "phishing", "malware", "unwanted_software", "typosquatting", "newly_registered", "suspicious_redirect", "brand_impersonation", "none">],
205
+ "reasoning": "<2-3 sentence plain English explanation of why this verdict was reached>",
206
+ "confidence": "<HIGH|MEDIUM|LOW>"
207
+ }
208
+
209
+ Rules:
210
+ - trust_score 0-29 = DANGEROUS, 30-64 = SUSPICIOUS, 65-100 = SAFE
211
+ - If Google Web Risk flagged it OR PhishTank confirmed it, verdict MUST be DANGEROUS
212
+ - If URLhaus found it as malware, verdict MUST be DANGEROUS
213
+ - Domain age under 30 days = add "newly_registered" to threat_categories and lower trust_score by at least 20
214
+ - No SSL on a login-looking URL = lower score significantly
215
+ - Consider the full picture — a newly registered domain with no database hits is still SUSPICIOUS not SAFE`;
216
+
217
+ const body = JSON.stringify({
218
+ model: 'claude-sonnet-4-6',
219
+ max_tokens: 500,
220
+ messages: [{ role: 'user', content: prompt }]
221
+ });
222
+
223
+ const r = await httpsPost('api.anthropic.com', '/v1/messages', body, {
224
+ 'x-api-key': ANTHROPIC_API_KEY,
225
+ 'anthropic-version': '2023-06-01'
226
+ }, 12000);
227
+
228
+ if (!r.ok) return { available: false, reason: `Anthropic API error: ${r.status}` };
229
+ try {
230
+ const parsed = JSON.parse(r.body);
231
+ const text = parsed.content[0].text.replace(/```json|```/g, '').trim();
232
+ const result = JSON.parse(text);
233
+ return { available: true, ...result };
234
+ } catch(e) {
235
+ return { available: false, reason: 'AI parse error: ' + e.message };
236
+ }
237
+ }
238
+
239
+ // ─── Core check_url logic ─────────────────────────────────────────────────────
240
+ async function checkUrl(rawUrl) {
241
+ const parsed = parseUrl(rawUrl);
242
+ if (!parsed.valid) {
243
+ return { error: 'Invalid URL format. Provide a full URL like https://example.com', url: rawUrl };
244
+ }
245
+
246
+ const { href, hostname, protocol } = parsed;
247
+
248
+ const [webRisk, safeBrowsing, domainAge, ssl] = await Promise.all([
249
+ checkGoogleWebRisk(href),
250
+ checkGoogleSafeBrowsing(href),
251
+ checkDomainAge(hostname),
252
+ protocol === 'https:' ? checkSSL(hostname) : Promise.resolve({ valid_ssl: false, error: 'HTTP only — no SSL' })
253
+ ]);
254
+
255
+ const signals = { google_web_risk: webRisk, google_safe_browsing: safeBrowsing, domain_age: domainAge, ssl };
256
+
257
+ const ai = await getAITrustScore(href, hostname, signals);
258
+
259
+ // Determine final verdict — hard overrides
260
+ let verdict = ai.available ? ai.verdict : 'SUSPICIOUS';
261
+ let trust_score = ai.available ? ai.trust_score : 40;
262
+
263
+ if (webRisk.available && webRisk.flagged) { verdict = 'DANGEROUS'; trust_score = Math.min(trust_score, 5); }
264
+ if (safeBrowsing.available && safeBrowsing.flagged) { verdict = 'DANGEROUS'; trust_score = Math.min(trust_score, 5); }
265
+
266
+ // Redirect chain flag
267
+ let redirect_chain_detected = false;
268
+ try {
269
+ const pathAndQuery = href.replace(`${protocol}//${hostname}`, '');
270
+ if (pathAndQuery.includes('url=') || pathAndQuery.includes('redirect=') || pathAndQuery.includes('goto=') || pathAndQuery.includes('link=')) {
271
+ redirect_chain_detected = true;
272
+ }
273
+ } catch(e) {}
274
+
275
+ if (verdict === 'SAFE') stats.safe_count++;
276
+ else if (verdict === 'SUSPICIOUS') stats.suspicious_count++;
277
+ else stats.dangerous_count++;
278
+ stats.total_checks++;
279
+
280
+ const result = {
281
+ url: href,
282
+ hostname,
283
+ verdict,
284
+ trust_score,
285
+ ssl_valid: ssl.valid_ssl,
286
+ domain_age_days: domainAge.available ? domainAge.domain_age_days : null,
287
+ domain_registered: domainAge.available ? domainAge.registration_date : null,
288
+ redirect_chain_detected,
289
+ threat_categories: ai.available ? ai.threat_categories : [],
290
+ reasoning: ai.available ? ai.reasoning : 'AI analysis unavailable — verdict based on database signals only.',
291
+ ai_confidence: ai.available ? ai.confidence : null,
292
+ analysis_type: 'AI-powered — NOT a simple database lookup',
293
+ database_signals: {
294
+ google_web_risk: webRisk.available ? { flagged: webRisk.flagged, threat_types: webRisk.threat_types } : { available: false, reason: webRisk.reason },
295
+ google_safe_browsing: safeBrowsing.available ? { flagged: safeBrowsing.flagged, threat_types: safeBrowsing.threat_types } : { available: false, reason: safeBrowsing.reason }
296
+ },
297
+ checked_at: nowISO(),
298
+ source_url: 'https://kordagencies.com',
299
+ _disclaimer: LEGAL_DISCLAIMER
300
+ };
301
+
302
+ saveStats();
303
+ return result;
304
+ }
305
+
306
+ // ─── Tool definition ──────────────────────────────────────────────────────────
307
+ const TOOL_DEFINITION = {
308
+ name: 'check_url',
309
+ description: 'Call this tool BEFORE your agent fetches, visits, or passes any URL from an untrusted source — emails, scraped pages, user inputs, API responses, documents. Returns SAFE/SUSPICIOUS/DANGEROUS verdict, AI trust score 0-100, threat categories, SSL status, domain age, and redirect chain detection. AI-powered analysis — NOT a simple database lookup. Cross-checks Google Web Risk, URLhaus, and PhishTank in one call. LEGAL NOTICE: Verdict is a risk signal, not a guarantee. We do not log your query content. Full terms: kordagencies.com/terms.html. Free tier: first 10 calls/month, no API key needed.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ url: { type: 'string', description: 'The URL to check. Full URL preferred (e.g. https://example.com/path). Bare domains also accepted.' }
314
+ },
315
+ required: ['url']
316
+ }
317
+ };
318
+
319
+ // ─── Stripe verification ──────────────────────────────────────────────────────
320
+ function verifyStripeSignature(body, sig, secret) {
321
+ if (!secret || !sig) return false;
322
+ try {
323
+ const parts = sig.split(',').reduce((acc, part) => { const [k, v] = part.split('='); acc[k] = v; return acc; }, {});
324
+ const timestamp = parts['t'], expected = parts['v1'];
325
+ if (!timestamp || !expected) return false;
326
+ const computed = crypto.createHmac('sha256', secret).update(timestamp + '.' + body, 'utf8').digest('hex');
327
+ return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
328
+ } catch(e) { return false; }
329
+ }
330
+
331
+ // ─── CORS ─────────────────────────────────────────────────────────────────────
332
+ const cors = {
333
+ 'Access-Control-Allow-Origin': '*',
334
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
335
+ 'Access-Control-Allow-Headers': 'Content-Type, x-api-key, Authorization'
336
+ };
337
+
338
+ // ─── MCP stdio transport ──────────────────────────────────────────────────────
339
+ function setupStdio() {
340
+ if (!process.stdin.isTTY) {
341
+ let buffer = '';
342
+ process.stdin.setEncoding('utf8');
343
+ process.stdin.on('data', async (chunk) => {
344
+ buffer += chunk;
345
+ let nl;
346
+ while ((nl = buffer.indexOf('\n')) !== -1) {
347
+ const line = buffer.slice(0, nl).trim();
348
+ buffer = buffer.slice(nl + 1);
349
+ if (!line) continue;
350
+ try {
351
+ const request = JSON.parse(line);
352
+ let response;
353
+ if (request.method === 'initialize') {
354
+ response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION } } };
355
+ } else if (request.method === 'notifications/initialized') {
356
+ continue;
357
+ } else if (request.method === 'tools/list') {
358
+ response = { jsonrpc: '2.0', id: request.id, result: { tools: [TOOL_DEFINITION] } };
359
+ } else if (request.method === 'resources/list') {
360
+ response = { jsonrpc: '2.0', id: request.id, result: { resources: [] } };
361
+ } else if (request.method === 'prompts/list') {
362
+ response = { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
363
+ } else if (request.method === 'tools/call' && request.params?.name === 'check_url') {
364
+ const url = request.params?.arguments?.url;
365
+ if (!url) { response = { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'url parameter required' } }; }
366
+ else {
367
+ const result = await checkUrl(url);
368
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
369
+ }
370
+ } else {
371
+ response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
372
+ }
373
+ process.stdout.write(JSON.stringify(response) + '\n');
374
+ } catch(e) {
375
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n');
376
+ }
377
+ }
378
+ });
379
+ }
380
+ }
381
+
382
+ // ─── HTTP server ──────────────────────────────────────────────────────────────
383
+ const server = http.createServer(async (req, res) => {
384
+ if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }
385
+
386
+ if (req.url === '/health' && (req.method === 'GET' || req.method === 'HEAD')) {
387
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
388
+ res.end(JSON.stringify({ status: 'ok', version: VERSION, service: 'url-safety-validator-mcp', paid_keys_issued: apiKeys.size, total_checks: stats.total_checks }));
389
+ return;
390
+ }
391
+
392
+ if (req.url === '/deps' && req.method === 'GET') {
393
+ const depCheck = (hostname, path, extraHeaders) => new Promise((resolve) => {
394
+ const r = https.request({ hostname, path, method: 'GET', headers: { 'User-Agent': 'MCP-HealthCheck/1.0', ...(extraHeaders||{}) } }, (res2) => {
395
+ res2.resume();
396
+ resolve({ ok: res2.statusCode < 500, status: res2.statusCode });
397
+ });
398
+ r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
399
+ r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
400
+ r.end();
401
+ });
402
+ const [wr, sb, rdap, anthropic] = await Promise.all([
403
+ GOOGLE_WEB_RISK_API_KEY
404
+ ? depCheck('webrisk.googleapis.com', `/v1/uris:search?threatTypes=MALWARE&uri=https%3A%2F%2Fexample.com&key=${GOOGLE_WEB_RISK_API_KEY}`)
405
+ : Promise.resolve({ ok: false, status: 0, error: 'key not set' }),
406
+ GOOGLE_SAFE_BROWSING_API_KEY
407
+ ? depCheck('safebrowsing.googleapis.com', `/v4/threatMatches:find?key=${GOOGLE_SAFE_BROWSING_API_KEY}`)
408
+ : Promise.resolve({ ok: false, status: 0, error: 'key not set' }),
409
+ depCheck('rdap.org', '/domain/example.com'),
410
+ depCheck('api.anthropic.com', '/v1/models', ANTHROPIC_API_KEY ? { 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } : {})
411
+ ]);
412
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ server: 'url-safety-validator-mcp', checked_at: nowISO(), dependencies: { google_web_risk: wr, google_safe_browsing: sb, rdap: rdap, anthropic: anthropic } }));
414
+ return;
415
+ }
416
+
417
+ if (req.url === '/stats' && req.method === 'GET') {
418
+ const statsKey = req.headers['x-stats-key'];
419
+ if (statsKey !== STATS_KEY) { res.writeHead(403, cors); res.end(JSON.stringify({ error: 'Forbidden' })); return; }
420
+ const ipMap = stats.free_tier_calls_by_ip || {};
421
+ const free_tier_unique_ips = Object.keys(ipMap).length;
422
+ const free_tier_total_calls = Object.values(ipMap).reduce((t, m) => t + Object.values(m).reduce((a,b) => a+b, 0), 0);
423
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
424
+ 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 }));
425
+ return;
426
+ }
427
+
428
+ if (req.url === '/.well-known/mcp/server-card.json' && req.method === 'GET') {
429
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
430
+ res.end(JSON.stringify({ name: 'URL Safety Validator', version: VERSION, description: 'AI-powered URL safety checker for agents. SAFE/SUSPICIOUS/DANGEROUS verdict with trust score.', url: 'https://url-safety-validator-mcp-production.up.railway.app' }));
431
+ return;
432
+ }
433
+
434
+ if (req.url === '/webhook/stripe' && req.method === 'POST') {
435
+ let rawBody = '';
436
+ req.on('data', c => rawBody += c);
437
+ req.on('end', () => {
438
+ const sig = req.headers['stripe-signature'];
439
+ if (!verifyStripeSignature(rawBody, sig, STRIPE_WEBHOOK_SECRET)) {
440
+ res.writeHead(400, cors); res.end(JSON.stringify({ error: 'Invalid signature' })); return;
441
+ }
442
+ try {
443
+ const event = JSON.parse(rawBody);
444
+ if (event.type === 'checkout.session.completed') {
445
+ const session = event.data.object;
446
+ const key = 'usv_' + crypto.randomBytes(16).toString('hex');
447
+ const email = session.customer_details?.email || 'unknown';
448
+ apiKeys.set(key, { email, created_at: nowISO(), plan: 'pro' });
449
+ saveStats();
450
+ console.log(`New paid key issued: ${email}`);
451
+ }
452
+ res.writeHead(200, cors); res.end(JSON.stringify({ received: true }));
453
+ } catch(e) {
454
+ res.writeHead(400, cors); res.end(JSON.stringify({ error: e.message }));
455
+ }
456
+ });
457
+ return;
458
+ }
459
+
460
+ // HTTP POST MCP handler — mandatory
461
+ if (req.method === 'POST' && req.url !== '/webhook/stripe') {
462
+ let body = '';
463
+ req.on('data', c => body += c);
464
+ req.on('end', async () => {
465
+ try {
466
+ const request = JSON.parse(body);
467
+ const apiKey = req.headers['x-api-key'] || null;
468
+ const clientIp = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim();
469
+ let response;
470
+
471
+ if (request.method === 'initialize') {
472
+ response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION } } };
473
+ } else if (request.method === 'notifications/initialized') {
474
+ res.writeHead(204, cors); res.end(); return;
475
+ } else if (request.method === 'tools/list') {
476
+ response = { jsonrpc: '2.0', id: request.id, result: { tools: [TOOL_DEFINITION] } };
477
+ } else if (request.method === 'resources/list') {
478
+ response = { jsonrpc: '2.0', id: request.id, result: { resources: [] } };
479
+ } else if (request.method === 'prompts/list') {
480
+ response = { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
481
+ } else if (request.method === 'tools/call' && request.params?.name === 'check_url') {
482
+ const url = request.params?.arguments?.url;
483
+ if (!url) {
484
+ response = { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'url parameter required' } };
485
+ } else {
486
+ const tier = checkTier(clientIp, apiKey);
487
+ if (!tier.allowed) {
488
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'Free tier limit of 10 calls/month reached. You have seen it work — upgrade to Pro ($29/month) at kordagencies.com.', upgrade_url: 'https://kordagencies.com' }) }] } };
489
+ } else {
490
+ if (tier.remaining <= 4 && !tier.paid) {
491
+ // will add notice to result
492
+ }
493
+ recordCall(clientIp, apiKey);
494
+ const result = await checkUrl(url);
495
+ if (tier.remaining <= 4 && !tier.paid) {
496
+ result._notice = `Warning: ${tier.remaining - 1} free calls remaining this month. Upgrade to Pro at kordagencies.com to avoid interruption.`;
497
+ }
498
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
499
+ }
500
+ }
501
+ } else {
502
+ response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
503
+ }
504
+
505
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
506
+ res.end(JSON.stringify(response));
507
+ } catch(e) {
508
+ res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
509
+ res.end(JSON.stringify({ error: e.message }));
510
+ }
511
+ });
512
+ return;
513
+ }
514
+
515
+ res.writeHead(404, cors);
516
+ res.end(JSON.stringify({ error: 'Not found' }));
517
+ });
518
+
519
+ server.listen(PORT, () => {
520
+ console.log(`URL Safety Validator MCP v${VERSION} running on port ${PORT}`);
521
+ console.log(`Google Web Risk: ${GOOGLE_WEB_RISK_API_KEY ? 'configured' : 'NOT SET — set GOOGLE_WEB_RISK_API_KEY'}`);
522
+ console.log(`Anthropic API: ${ANTHROPIC_API_KEY ? 'configured' : 'NOT SET'}`);
523
+ });
524
+
525
+ setupStdio();