url-safety-validator-mcp 1.2.24 → 1.2.26
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/README.md +4 -5
- package/glama.json +1 -1
- package/package.json +1 -1
- package/server.json +48 -48
- package/smithery.yaml +1 -1
- package/src/server.js +163 -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.26] — 2026-06-25
|
|
6
|
+
- feat: calls_remaining field added to check_url responses -- "unlimited" for paid keys, numeric free-tier headroom otherwise
|
|
7
|
+
- feat: verdict_ttl field added (3600s/1hr -- threat landscape changes fast)
|
|
8
|
+
- feat: data_source_status field added (full/degraded/partial) -- "degraded" when Google Web Risk (critical source) is unavailable, "partial" when only AI trust scoring is unavailable, "full" when both respond
|
|
9
|
+
- Task 1 (purpose verb + required fields) audited -- already correct on check_url from a prior pass, no changes needed
|
|
10
|
+
|
|
11
|
+
## [1.2.25] — 2026-06-24
|
|
12
|
+
- feat: unauthenticated /public-stats endpoint -- first_deployed, lifetime tool calls, uptime %, version, for agent orchestrators evaluating server trustworthiness
|
|
13
|
+
- feat: /process-trial-followups endpoint + 24h follow-up record on trial-extension grant
|
|
14
|
+
- feat: gate response now self-contained (server + workflow impact + upgrade path in one sentence) and detects cross-server operators via shared fleet Redis, with cross-server trial-extension note
|
|
15
|
+
- feat: outputSchema added to check_url (additive, response format unchanged)
|
|
16
|
+
- fix: tool description and both initialize descriptions said "Returns BLOCK / FLAG_AND_PROCEED / ALLOW verdict" -- the real `verdict` field is SAFE/SUSPICIOUS/DANGEROUS; BLOCK/FLAG_AND_PROCEED/ALLOW is a separate derived `agent_action` field. Clarified both fields and their relationship everywhere this was stated.
|
|
17
|
+
- fix: glama.json and README claimed cross-checking against URLhaus and PhishTank -- neither is ever called in code, only Google Web Risk and Google Safe Browsing are. Removed the false claims, including a fabricated PhishTank citation in a README example response. Also fixed smithery.yaml claiming "2 focused tools" when this server has exactly 1 (check_url).
|
|
18
|
+
- fix: /deps health check previously treated HTTP 403 (key rejected) on Google Web Risk as ok:true via the `statusCode < 500` pattern -- now treats 403 on an authenticated API as ok:false with error:'auth_failed'
|
|
19
|
+
|
|
5
20
|
## [1.2.24] — 2026-06-23
|
|
6
21
|
- fix: gate returns HTTP 402 (x402 standard for non-transient quota)
|
|
7
22
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Stop your agent from fetching a dangerous URL before it's too late.**
|
|
8
8
|
|
|
9
|
-
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,
|
|
9
|
+
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, Google Safe Browsing, and AI analysis.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ One tool: `check_url`. One call returns:
|
|
|
20
20
|
- **SSL status:** valid or not
|
|
21
21
|
- **Domain age:** registration date and age in days
|
|
22
22
|
- **Redirect chain flag:** detected from URL parameters
|
|
23
|
-
- **Database signals:** raw results from Google Web Risk
|
|
23
|
+
- **Database signals:** raw results from Google Web Risk and Google Safe Browsing
|
|
24
24
|
- **AI reasoning:** 2–3 sentence plain-English explanation
|
|
25
25
|
- **AI confidence:** HIGH / MEDIUM / LOW
|
|
26
26
|
|
|
@@ -46,8 +46,7 @@ If the verdict is DANGEROUS — halt. If SUSPICIOUS — flag for review. If SAFE
|
|
|
46
46
|
| Source | Type | Coverage |
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| Google Web Risk | Commercial API | Malware, phishing, unwanted software |
|
|
49
|
-
|
|
|
50
|
-
| PhishTank | Free | Community-verified phishing URLs |
|
|
49
|
+
| Google Safe Browsing | Free | Malware, phishing, unwanted software (fallback when Web Risk key absent) |
|
|
51
50
|
| RDAP | Free | Domain registration date |
|
|
52
51
|
| Anthropic Claude | AI | Trust scoring and reasoning synthesis |
|
|
53
52
|
|
|
@@ -152,7 +151,7 @@ Same as LangChain above — langchain-mcp-adapters works with LangGraph natively
|
|
|
152
151
|
"domain_age_days": 12,
|
|
153
152
|
"redirect_chain_detected": false,
|
|
154
153
|
"threat_categories": ["phishing", "newly_registered"],
|
|
155
|
-
"reasoning": "Domain registered 12 days ago and
|
|
154
|
+
"reasoning": "Domain registered 12 days ago and impersonates a financial institution's login page. Google Web Risk flags this as SOCIAL_ENGINEERING.",
|
|
156
155
|
"ai_confidence": "HIGH",
|
|
157
156
|
"analysis_type": "AI-powered -- NOT a simple database lookup"
|
|
158
157
|
}
|
package/glama.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
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
|
|
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 and Google Safe Browsing.",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"homepage": "https://kordagencies.com",
|
|
6
6
|
"repository": "https://github.com/OjasKord/url-safety-validator-mcp"
|
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.26",
|
|
5
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": {
|
package/server.json
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
-
"name": "io.github.OjasKord/url-safety-validator-mcp",
|
|
4
|
-
"title": "URL Safety Validator MCP",
|
|
5
|
-
"description": "AI URL safety validator: SAFE/SUSPICIOUS/DANGEROUS verdict, trust score, threat intel.",
|
|
6
|
-
"version": "1.2.
|
|
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
|
-
"identifier": "url-safety-validator-mcp",
|
|
16
|
-
"version": "1.2.
|
|
17
|
-
"transport": {
|
|
18
|
-
"type": "stdio"
|
|
19
|
-
},
|
|
20
|
-
"environmentVariables": [
|
|
21
|
-
{
|
|
22
|
-
"name": "ANTHROPIC_API_KEY",
|
|
23
|
-
"description": "Anthropic API key for AI trust scoring",
|
|
24
|
-
"isRequired": true,
|
|
25
|
-
"isSecret": true
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"name": "GOOGLE_WEB_RISK_API_KEY",
|
|
29
|
-
"description": "Google Web Risk API key (commercial). Degrades gracefully without it.",
|
|
30
|
-
"isRequired": false,
|
|
31
|
-
"isSecret": true
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"name": "GOOGLE_SAFE_BROWSING_API_KEY",
|
|
35
|
-
"description": "Google Safe Browsing API key (free tier available).",
|
|
36
|
-
"isRequired": false,
|
|
37
|
-
"isSecret": true
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
}
|
|
41
|
-
],
|
|
42
|
-
"remotes": [
|
|
43
|
-
{
|
|
44
|
-
"type": "streamable-http",
|
|
45
|
-
"url": "https://url-safety-validator-mcp-production.up.railway.app"
|
|
46
|
-
}
|
|
47
|
-
]
|
|
48
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.OjasKord/url-safety-validator-mcp",
|
|
4
|
+
"title": "URL Safety Validator MCP",
|
|
5
|
+
"description": "AI URL safety validator: SAFE/SUSPICIOUS/DANGEROUS verdict, trust score, threat intel.",
|
|
6
|
+
"version": "1.2.26",
|
|
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
|
+
"identifier": "url-safety-validator-mcp",
|
|
16
|
+
"version": "1.2.26",
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
},
|
|
20
|
+
"environmentVariables": [
|
|
21
|
+
{
|
|
22
|
+
"name": "ANTHROPIC_API_KEY",
|
|
23
|
+
"description": "Anthropic API key for AI trust scoring",
|
|
24
|
+
"isRequired": true,
|
|
25
|
+
"isSecret": true
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "GOOGLE_WEB_RISK_API_KEY",
|
|
29
|
+
"description": "Google Web Risk API key (commercial). Degrades gracefully without it.",
|
|
30
|
+
"isRequired": false,
|
|
31
|
+
"isSecret": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "GOOGLE_SAFE_BROWSING_API_KEY",
|
|
35
|
+
"description": "Google Safe Browsing API key (free tier available).",
|
|
36
|
+
"isRequired": false,
|
|
37
|
+
"isSecret": true
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"remotes": [
|
|
43
|
+
{
|
|
44
|
+
"type": "streamable-http",
|
|
45
|
+
"url": "https://url-safety-validator-mcp-production.up.railway.app"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
package/smithery.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: URL Safety Validator MCP
|
|
2
2
|
agentRole: "URL Safety Specialist — equip before fetching, visiting, or following any untrusted URL"
|
|
3
|
-
description: "
|
|
3
|
+
description: "Pre-condition: an agent has received a URL from an untrusted source -- email, document, scraped page, or API response -- and is about to fetch, visit, or follow it. Skip this and a payment executed on a phishing domain via an agentic payment rail has no recovery path, since the redirect itself is the attack vector. check_url returns verdict SAFE/SUSPICIOUS/DANGEROUS with a derived agent_action ALLOW/FLAG_AND_PROCEED/BLOCK, checked live against Google Web Risk and Google Safe Browsing. 1 tool, one verdict, no further reasoning required."
|
|
4
4
|
categories:
|
|
5
5
|
- Security
|
|
6
6
|
- Web
|
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.26';
|
|
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 ALLOWED_PAYMENT_LINK_IDS = ['plink_1TQzIHD6WvRe6sn3820kFk07', 'plink_1TQzJdD6WvRe6sn3GN8mQkj9'];
|
|
@@ -50,6 +50,13 @@ const REDIS_PREFIX = 'url';
|
|
|
50
50
|
const FREE_TIER_REDIS_KEY = 'url:free_tier_usage';
|
|
51
51
|
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
|
|
52
52
|
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
53
|
+
const FIRST_DEPLOYED = '2026-04-22T06:38:09Z';
|
|
54
|
+
const LIFETIME_CALLS_REDIS_KEY = 'url:lifetime_calls';
|
|
55
|
+
const UPTIME_HEARTBEAT_KEY = 'url:uptime:heartbeat_count';
|
|
56
|
+
const UPTIME_MONITORING_START_KEY = 'url:uptime:monitoring_started';
|
|
57
|
+
const UPTIME_HEARTBEAT_INTERVAL_MS = 60000;
|
|
58
|
+
const FLEET_IP24_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
59
|
+
const FLEET_CROSS_SERVER_THRESHOLD = 3;
|
|
53
60
|
|
|
54
61
|
function loadStats() {
|
|
55
62
|
try {
|
|
@@ -181,6 +188,56 @@ async function redisDelete(key) {
|
|
|
181
188
|
} catch(e) { console.error('[Redis] redisDelete failed:', e); }
|
|
182
189
|
}
|
|
183
190
|
|
|
191
|
+
async function redisIncr(key) {
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(
|
|
194
|
+
`${UPSTASH_URL}/incr/${encodeURIComponent(key)}`,
|
|
195
|
+
{ headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
196
|
+
);
|
|
197
|
+
const data = await res.json();
|
|
198
|
+
if (data.error) { console.error('[Redis] redisIncr error:', data.error, 'key:', key); return null; }
|
|
199
|
+
return data.result;
|
|
200
|
+
} catch(e) { console.error('[Redis] redisIncr failed:', e); return null; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── FLEET CROSS-SERVER OPERATOR DETECTION ─────────────────────────────────────
|
|
204
|
+
async function recordFleetGateHit(ip) {
|
|
205
|
+
try {
|
|
206
|
+
const ip24 = truncateIp(ip);
|
|
207
|
+
const key = `fleet:ip24:${ip24}:${REDIS_PREFIX}`;
|
|
208
|
+
await redisSet(key, new Date().toISOString());
|
|
209
|
+
await redisExpire(key, FLEET_IP24_TTL_SECONDS);
|
|
210
|
+
} catch(e) { console.error('[Fleet] recordFleetGateHit failed:', e); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function checkFleetCrossServer(ip) {
|
|
214
|
+
try {
|
|
215
|
+
const ip24 = truncateIp(ip);
|
|
216
|
+
const keys = await redisKeys(`fleet:ip24:${ip24}:*`);
|
|
217
|
+
return keys.length;
|
|
218
|
+
} catch(e) { return 0; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function buildCrossServerNote(ip) {
|
|
222
|
+
const serverCount = await checkFleetCrossServer(ip);
|
|
223
|
+
if (serverCount >= FLEET_CROSS_SERVER_THRESHOLD) {
|
|
224
|
+
return 'Cross-server trial extension available -- this operator is already using ' + serverCount + ' Kord Agencies MCP servers. POST /trial-extension on any one of those servers to extend the trial across all of them.';
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── UPTIME TRACKING (for /public-stats) ───────────────────────────────────────
|
|
230
|
+
async function initUptimeTracking() {
|
|
231
|
+
try {
|
|
232
|
+
let started = await redisGet(UPTIME_MONITORING_START_KEY);
|
|
233
|
+
if (!started) {
|
|
234
|
+
started = new Date().toISOString();
|
|
235
|
+
await redisSet(UPTIME_MONITORING_START_KEY, started);
|
|
236
|
+
}
|
|
237
|
+
setInterval(() => { redisIncr(UPTIME_HEARTBEAT_KEY).catch(() => {}); }, UPTIME_HEARTBEAT_INTERVAL_MS);
|
|
238
|
+
} catch(e) { console.error('[Uptime] initUptimeTracking failed:', e); }
|
|
239
|
+
}
|
|
240
|
+
|
|
184
241
|
async function findCheckoutSessionEmail(paymentIntentId) {
|
|
185
242
|
const res = await fetch(
|
|
186
243
|
`https://api.stripe.com/v1/checkout/sessions?payment_intent=${encodeURIComponent(paymentIntentId)}`,
|
|
@@ -455,6 +512,9 @@ async function checkUrl(rawUrl) {
|
|
|
455
512
|
const signals = { google_web_risk: webRisk, google_safe_browsing: safeBrowsing, domain_age: domainAge, ssl };
|
|
456
513
|
|
|
457
514
|
const ai = await getAITrustScore(href, hostname, signals);
|
|
515
|
+
// Caching/staleness policy and source-confidence flag (Task 3/4) -- Google Web Risk is the critical source for this server.
|
|
516
|
+
const VERDICT_TTL_CHECK_URL = 3600; // 1 hour -- threat landscape changes fast
|
|
517
|
+
const dataSourceStatus = !webRisk.available ? 'degraded' : (!ai.available ? 'partial' : 'full');
|
|
458
518
|
|
|
459
519
|
// Determine final verdict -- hard overrides
|
|
460
520
|
let verdict = ai.available ? ai.verdict : 'SUSPICIOUS';
|
|
@@ -497,6 +557,8 @@ async function checkUrl(rawUrl) {
|
|
|
497
557
|
},
|
|
498
558
|
checked_at: nowISO(),
|
|
499
559
|
source_url: 'webrisk.googleapis.com',
|
|
560
|
+
verdict_ttl: VERDICT_TTL_CHECK_URL,
|
|
561
|
+
data_source_status: dataSourceStatus,
|
|
500
562
|
_disclaimer: LEGAL_DISCLAIMER
|
|
501
563
|
};
|
|
502
564
|
|
|
@@ -516,13 +578,40 @@ async function checkUrl(rawUrl) {
|
|
|
516
578
|
const TOOL_DEFINITION = {
|
|
517
579
|
name: 'check_url',
|
|
518
580
|
idempotent: true,
|
|
519
|
-
description: 'Checks a URL for phishing, malware, typosquatting, and redirect threats. Call this BEFORE your agent fetches, follows, or forwards any URL in an agentic commerce workflow -- at the moment a merchant site, supplier portal, or payment redirect URL is received and no navigation has occurred. Use this when your agent has received a URL from an external source — email, document, or API response — and is about to navigate to it or pass it downstream. Checks live against Google Web Risk (webrisk.googleapis.com) and Google Safe Browsing (safebrowsing.googleapis.com). Returns
|
|
581
|
+
description: 'Checks a URL for phishing, malware, typosquatting, and redirect threats. Call this BEFORE your agent fetches, follows, or forwards any URL in an agentic commerce workflow -- at the moment a merchant site, supplier portal, or payment redirect URL is received and no navigation has occurred. Use this when your agent has received a URL from an external source — email, document, or API response — and is about to navigate to it or pass it downstream. Checks live against Google Web Risk (webrisk.googleapis.com) and Google Safe Browsing (safebrowsing.googleapis.com). Returns verdict SAFE / SUSPICIOUS / DANGEROUS with a derived agent_action of ALLOW / FLAG_AND_PROCEED / BLOCK, trust score 0-100, and threat categories. A payment executed on a phishing domain via Stripe MPP, Alipay AI Pay, or Shopify UCP has no recovery path -- the redirect is the attack vector. A DANGEROUS verdict means halt immediately. Returns machine-ready verdict, no further analysis needed.',
|
|
520
582
|
inputSchema: {
|
|
521
583
|
type: 'object',
|
|
522
584
|
properties: {
|
|
523
585
|
url: { type: 'string', description: 'The URL to check. Full URL preferred (e.g. https://example.com/path). Bare domains also accepted.' }
|
|
524
586
|
},
|
|
525
587
|
required: ['url']
|
|
588
|
+
},
|
|
589
|
+
outputSchema: {
|
|
590
|
+
type: 'object',
|
|
591
|
+
properties: {
|
|
592
|
+
url: { type: 'string' },
|
|
593
|
+
hostname: { type: 'string' },
|
|
594
|
+
verdict: { type: 'string', enum: ['SAFE', 'SUSPICIOUS', 'DANGEROUS'] },
|
|
595
|
+
agent_action: { type: 'string', enum: ['ALLOW', 'FLAG_AND_PROCEED', 'BLOCK'], description: 'Derived directly from verdict' },
|
|
596
|
+
trust_score: { type: 'number', minimum: 0, maximum: 100 },
|
|
597
|
+
ssl_valid: { type: 'boolean' },
|
|
598
|
+
domain_age_days: { type: ['number', 'null'] },
|
|
599
|
+
domain_registered: { type: ['string', 'null'] },
|
|
600
|
+
redirect_chain_detected: { type: 'boolean' },
|
|
601
|
+
threat_categories: { type: 'array', items: { type: 'string' } },
|
|
602
|
+
reasoning: { type: 'string' },
|
|
603
|
+
ai_confidence: { type: ['string', 'null'] },
|
|
604
|
+
analysis_type: { type: 'string' },
|
|
605
|
+
database_signals: { type: 'object' },
|
|
606
|
+
checked_at: { type: 'string', format: 'date-time' },
|
|
607
|
+
source_url: { type: 'string' },
|
|
608
|
+
hold_reason: { type: 'string', description: 'Present only when verdict is SUSPICIOUS' },
|
|
609
|
+
retry_after: { type: ['number', 'null'] },
|
|
610
|
+
escalation_path: { type: 'string', description: 'Present only when verdict is SUSPICIOUS' },
|
|
611
|
+
_disclaimer: { type: 'string' }
|
|
612
|
+
},
|
|
613
|
+
required: ['url', 'hostname', 'verdict', 'agent_action', 'trust_score', 'checked_at', '_disclaimer'],
|
|
614
|
+
additionalProperties: true
|
|
526
615
|
}
|
|
527
616
|
};
|
|
528
617
|
|
|
@@ -561,7 +650,7 @@ function setupStdio() {
|
|
|
561
650
|
const request = JSON.parse(line);
|
|
562
651
|
let response;
|
|
563
652
|
if (request.method === 'initialize') {
|
|
564
|
-
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable
|
|
653
|
+
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable verdict (SAFE/SUSPICIOUS/DANGEROUS) with a derived agent_action (ALLOW/FLAG_AND_PROCEED/BLOCK), checked live against Google Web Risk and Google Safe Browsing, every call. Used before any agent URL fetch, page navigation, or redirect follow in payment and procurement workflows.' } } };
|
|
565
654
|
} else if (request.method === 'notifications/initialized') {
|
|
566
655
|
continue;
|
|
567
656
|
} else if (request.method === 'tools/list') {
|
|
@@ -616,7 +705,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
616
705
|
const depCheck = (hostname, path, extraHeaders) => new Promise((resolve) => {
|
|
617
706
|
const r = https.request({ hostname, path, method: 'GET', headers: { 'User-Agent': 'MCP-HealthCheck/1.0', ...(extraHeaders||{}) } }, (res2) => {
|
|
618
707
|
res2.resume();
|
|
619
|
-
resolve({ ok: res2.statusCode < 500, status: res2.statusCode });
|
|
708
|
+
resolve({ ok: res2.statusCode !== 403 && res2.statusCode < 500, status: res2.statusCode, error: res2.statusCode === 403 ? 'auth_failed' : undefined });
|
|
620
709
|
});
|
|
621
710
|
r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
|
|
622
711
|
r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
|
|
@@ -630,7 +719,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
630
719
|
? (() => {
|
|
631
720
|
const sbBody = JSON.stringify({ client: { clientId: 'kord-dep-check', clientVersion: '1.0' }, threatInfo: { threatTypes: ['MALWARE'], platformTypes: ['ANY_PLATFORM'], threatEntryTypes: ['URL'], threatEntries: [{ url: 'https://example.com' }] } });
|
|
632
721
|
return new Promise((resolve) => {
|
|
633
|
-
const r = https.request({ hostname: 'safebrowsing.googleapis.com', path: `/v4/threatMatches:find?key=${GOOGLE_SAFE_BROWSING_API_KEY}`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(sbBody) } }, (res2) => { res2.resume(); resolve({ ok: res2.statusCode < 500, status: res2.statusCode }); });
|
|
722
|
+
const r = https.request({ hostname: 'safebrowsing.googleapis.com', path: `/v4/threatMatches:find?key=${GOOGLE_SAFE_BROWSING_API_KEY}`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(sbBody) } }, (res2) => { res2.resume(); resolve({ ok: res2.statusCode !== 403 && res2.statusCode < 500, status: res2.statusCode, error: res2.statusCode === 403 ? 'auth_failed' : undefined }); });
|
|
634
723
|
r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
|
|
635
724
|
r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
|
|
636
725
|
r.write(sbBody); r.end();
|
|
@@ -663,6 +752,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
663
752
|
return;
|
|
664
753
|
}
|
|
665
754
|
|
|
755
|
+
// Unauthenticated machine-readable track record -- for agent orchestrators
|
|
756
|
+
// evaluating server trustworthiness, not for humans. No stats-key required.
|
|
757
|
+
if (req.url === '/public-stats' && req.method === 'GET') {
|
|
758
|
+
(async () => {
|
|
759
|
+
const [lifetimeCallsRaw, heartbeatCountRaw, monitoringStart] = await Promise.all([
|
|
760
|
+
redisGet(LIFETIME_CALLS_REDIS_KEY),
|
|
761
|
+
redisGet(UPTIME_HEARTBEAT_KEY),
|
|
762
|
+
redisGet(UPTIME_MONITORING_START_KEY)
|
|
763
|
+
]);
|
|
764
|
+
const lifetimeCalls = lifetimeCallsRaw || 0;
|
|
765
|
+
const heartbeatCount = heartbeatCountRaw || 0;
|
|
766
|
+
const monitoringStartTime = monitoringStart ? new Date(monitoringStart).getTime() : Date.now();
|
|
767
|
+
const elapsedMs = Math.max(1, Date.now() - monitoringStartTime);
|
|
768
|
+
const uptimePct = Math.min(100, Math.round((heartbeatCount * UPTIME_HEARTBEAT_INTERVAL_MS / elapsedMs) * 1000) / 10);
|
|
769
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
770
|
+
res.end(JSON.stringify({
|
|
771
|
+
server: 'url-safety-validator-mcp',
|
|
772
|
+
version: VERSION,
|
|
773
|
+
first_deployed: FIRST_DEPLOYED,
|
|
774
|
+
total_lifetime_tool_calls: lifetimeCalls,
|
|
775
|
+
uptime_percentage: uptimePct,
|
|
776
|
+
uptime_monitoring_since: monitoringStart || new Date().toISOString()
|
|
777
|
+
}));
|
|
778
|
+
})();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
666
782
|
if (req.url === '/session-log' && req.method === 'GET') {
|
|
667
783
|
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
668
784
|
(async () => {
|
|
@@ -705,6 +821,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
705
821
|
stats.free_tier_calls_by_ip[clientIp][month] = Math.max(0, current - TRIAL_EXTENSION_CALLS);
|
|
706
822
|
trialExtensions.set(emailKey, { name, email, use_case: use_case || '', ip: clientIp, granted_at: nowISO() });
|
|
707
823
|
saveStats();
|
|
824
|
+
// 24h follow-up record -- processed by /process-trial-followups (fleet cron)
|
|
825
|
+
await redisSet(REDIS_PREFIX + ':followup:' + email.toLowerCase().trim(), { email, name, server: 'url-safety-validator-mcp', granted_at: nowISO(), sent: false });
|
|
708
826
|
await sendEmail('ojas@kordagencies.com', 'URL Safety Validator MCP -- Trial Extension: ' + name,
|
|
709
827
|
'<p><b>Name:</b> ' + name + '<br><b>Email:</b> ' + email + '<br><b>Use case:</b> ' + (use_case || 'Not provided') + '<br><b>IP:</b> ' + clientIp + '<br><b>Calls granted:</b> ' + TRIAL_EXTENSION_CALLS + '</p>');
|
|
710
828
|
await sendEmail(email, TRIAL_EXTENSION_CALLS + ' extra free calls added -- URL Safety Validator MCP',
|
|
@@ -716,6 +834,39 @@ const server = http.createServer(async (req, res) => {
|
|
|
716
834
|
return;
|
|
717
835
|
}
|
|
718
836
|
|
|
837
|
+
// Fleet cron hits this hourly. Sends exactly one follow-up email per email
|
|
838
|
+
// address, 24h after a trial extension was granted, unless that email has
|
|
839
|
+
// since picked up a paid key on this server.
|
|
840
|
+
if (req.url === '/process-trial-followups' && req.method === 'POST') {
|
|
841
|
+
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
842
|
+
(async () => {
|
|
843
|
+
const keys = await redisKeys(REDIS_PREFIX + ':followup:*');
|
|
844
|
+
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
|
845
|
+
let processed = 0, sent = 0, skippedPaid = 0;
|
|
846
|
+
for (const key of keys) {
|
|
847
|
+
const record = await redisGet(key);
|
|
848
|
+
if (!record || record.sent) continue;
|
|
849
|
+
if (Date.now() - new Date(record.granted_at).getTime() < TWENTY_FOUR_HOURS_MS) continue;
|
|
850
|
+
processed++;
|
|
851
|
+
const emailNorm = (record.email || '').toLowerCase().trim();
|
|
852
|
+
const hasPaidKey = Array.from(apiKeys.values()).some(r => (r.email || '').toLowerCase().trim() === emailNorm);
|
|
853
|
+
if (hasPaidKey) {
|
|
854
|
+
skippedPaid++;
|
|
855
|
+
} else {
|
|
856
|
+
await sendEmail(record.email, 'URL Safety Validator MCP -- URL safety screening will block your workflow again without an upgrade',
|
|
857
|
+
'<p>Hi ' + record.name + ',</p><p>Your trial extension on URL Safety Validator MCP was granted 24 hours ago. Once those extra calls run out, URL safety screening stops and any fetch/follow workflow that depends on it pauses until you upgrade.</p><p>Upgrade now -- 500 calls for $20/month: ' + PRO_UPGRADE_URL + '</p><p>Ojas<br>kordagencies.com</p>');
|
|
858
|
+
sent++;
|
|
859
|
+
}
|
|
860
|
+
record.sent = true;
|
|
861
|
+
record.sent_at = nowISO();
|
|
862
|
+
await redisSet(key, record);
|
|
863
|
+
}
|
|
864
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
865
|
+
res.end(JSON.stringify({ checked: keys.length, processed, emails_sent: sent, skipped_already_paid: skippedPaid }));
|
|
866
|
+
})();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
719
870
|
if (req.url === '/webhook/stripe' && req.method === 'POST') {
|
|
720
871
|
let rawBody = '';
|
|
721
872
|
req.on('data', c => rawBody += c);
|
|
@@ -851,7 +1002,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
851
1002
|
let statusCode = 200;
|
|
852
1003
|
|
|
853
1004
|
if (request.method === 'initialize') {
|
|
854
|
-
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable
|
|
1005
|
+
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'url-safety-validator-mcp', version: VERSION, description: 'Every agentic commerce workflow built on Stripe MPP, Alipay AI Pay, or Shopify UCP has a moment where the agent must navigate to a merchant or supplier URL before it can proceed. This server answers that question in one call -- returning a machine-readable verdict (SAFE/SUSPICIOUS/DANGEROUS) with a derived agent_action (ALLOW/FLAG_AND_PROCEED/BLOCK), checked live against Google Web Risk and Google Safe Browsing, every call. Used before any agent URL fetch, page navigation, or redirect follow in payment and procurement workflows.' } } };
|
|
855
1006
|
} else if (request.method === 'notifications/initialized') {
|
|
856
1007
|
res.writeHead(204, cors); res.end(); return;
|
|
857
1008
|
} else if (request.method === 'tools/list') {
|
|
@@ -879,14 +1030,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
879
1030
|
const _gateMonth = getMonthKey();
|
|
880
1031
|
const _gateCalls = (stats.free_tier_calls_by_ip[clientIp] && stats.free_tier_calls_by_ip[clientIp][_gateMonth]) || 0;
|
|
881
1032
|
notifyGateHit('URL Safety Validator', clientIp, 'check_url', _gateCalls, PRO_UPGRADE_URL);
|
|
882
|
-
|
|
1033
|
+
recordFleetGateHit(clientIp).catch(() => {});
|
|
1034
|
+
const crossServerNote = await buildCrossServerNote(clientIp);
|
|
1035
|
+
response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: 'URL Safety Validator MCP free tier exhausted -- URL safety screening is now blocked, halting any workflow that depends on verifying a link before fetch or follow, until you extend via POST /trial-extension or upgrade at ' + PRO_UPGRADE_URL + '. 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 + '.' + (crossServerNote ? ' ' + crossServerNote : ''), 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 }) }] } };
|
|
883
1036
|
} else {
|
|
884
1037
|
recordCall(clientIp, apiKey);
|
|
885
1038
|
saveFreeTierToRedis().catch(() => {});
|
|
886
1039
|
const result = await checkUrl(url);
|
|
1040
|
+
result.calls_remaining = tier.paid ? 'unlimited' : Math.max(0, tier.remaining);
|
|
887
1041
|
appendSessionLog(clientIp, 'check_url').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
|
|
888
1042
|
usageLog.push({ tool: 'check_url', ip: clientIp, tier: tier.paid ? 'paid' : 'free', timestamp: nowISO() });
|
|
889
1043
|
toolUsageCounts['check_url'] = (toolUsageCounts['check_url'] || 0) + 1;
|
|
1044
|
+
redisIncr(LIFETIME_CALLS_REDIS_KEY).catch(() => {});
|
|
890
1045
|
if (tier.remaining <= 4 && !tier.paid) {
|
|
891
1046
|
const effectiveLimit = getEffectiveLimit(clientIp);
|
|
892
1047
|
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.';
|
|
@@ -916,6 +1071,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
916
1071
|
server.listen(PORT, async () => {
|
|
917
1072
|
await loadApiKeysFromRedis();
|
|
918
1073
|
await loadFreeTierFromRedis();
|
|
1074
|
+
await initUptimeTracking();
|
|
919
1075
|
console.log(`URL Safety Validator MCP v${VERSION} running on port ${PORT}`);
|
|
920
1076
|
console.log(`Google Web Risk: ${GOOGLE_WEB_RISK_API_KEY ? 'configured' : 'NOT SET -- set GOOGLE_WEB_RISK_API_KEY'}`);
|
|
921
1077
|
console.log(`Anthropic API: ${ANTHROPIC_API_KEY ? 'configured' : 'NOT SET'}`);
|