@unphurl/mcp-server
Advanced tools
| // Allowlist tools — list, add, and remove trusted domains | ||
| // Allowlisted domains suppress compound and brand_impersonation_floor scoring. | ||
| // Full pipeline still runs — all signals remain visible for monitoring. | ||
| import { z } from "zod"; | ||
| import { ApiRequestError } from "../api.js"; | ||
| import { successResult, authError, apiErrorToResult, errorResult, } from "./helpers.js"; | ||
| export function registerAllowlistTools(server, api) { | ||
| // --- list_allowlist --- | ||
| server.registerTool("list_allowlist", { | ||
| description: `List all domains on this account's trusted allowlist. | ||
| Allowlisted domains suppress the compound signal and brand impersonation floor in scoring. The full pipeline still runs — all signals remain visible for monitoring. Use this to see which domains are currently trusted. | ||
| Returns the list of domains, current count, and the 1,000-domain limit.`, | ||
| inputSchema: {}, | ||
| }, async () => { | ||
| if (!api.hasApiKey) | ||
| return authError(); | ||
| try { | ||
| const result = await api.listAllowlist(); | ||
| return successResult(result); | ||
| } | ||
| catch (err) { | ||
| if (err instanceof ApiRequestError) | ||
| return apiErrorToResult(err); | ||
| return errorResult(err instanceof Error ? err.message : "Unknown error"); | ||
| } | ||
| }); | ||
| // --- add_to_allowlist --- | ||
| server.registerTool("add_to_allowlist", { | ||
| description: `Add one or more domains to this account's trusted allowlist. | ||
| Allowlisted domains suppress the compound signal and brand impersonation floor in scoring. The full pipeline still runs — all signals remain visible so you can monitor trusted domains for SSL expiry, parking, or other changes. | ||
| Submit the registrable domain only (e.g. partnerco.com). Subdomains and full URLs are rejected. Adding partnerco.com covers sub.partnerco.com and all other subdomains automatically. | ||
| Maximum 1,000 domains per account. Maximum 100 domains per request. Duplicates are silently skipped.`, | ||
| inputSchema: { | ||
| domains: z | ||
| .array(z.string().min(1)) | ||
| .min(1) | ||
| .max(100) | ||
| .describe("Registrable domains to add (e.g. ['partnerco.com', 'trustedvendor.io']). Subdomains and full URLs are rejected."), | ||
| }, | ||
| }, async ({ domains }) => { | ||
| if (!api.hasApiKey) | ||
| return authError(); | ||
| try { | ||
| const result = await api.addToAllowlist(domains); | ||
| return successResult(result); | ||
| } | ||
| catch (err) { | ||
| if (err instanceof ApiRequestError) | ||
| return apiErrorToResult(err); | ||
| return errorResult(err instanceof Error ? err.message : "Unknown error"); | ||
| } | ||
| }); | ||
| // --- remove_from_allowlist --- | ||
| server.registerTool("remove_from_allowlist", { | ||
| description: `Remove one or more domains from this account's trusted allowlist. | ||
| Once removed, those domains resume normal scoring on the next check. Use list_allowlist to see what is currently on the list before removing.`, | ||
| inputSchema: { | ||
| domains: z | ||
| .array(z.string().min(1)) | ||
| .min(1) | ||
| .max(100) | ||
| .describe("Registrable domains to remove (e.g. ['partnerco.com'])"), | ||
| }, | ||
| }, async ({ domains }) => { | ||
| if (!api.hasApiKey) | ||
| return authError(); | ||
| try { | ||
| const result = await api.removeFromAllowlist(domains); | ||
| return successResult(result); | ||
| } | ||
| catch (err) { | ||
| if (err instanceof ApiRequestError) | ||
| return apiErrorToResult(err); | ||
| return errorResult(err instanceof Error ? err.message : "Unknown error"); | ||
| } | ||
| }); | ||
| } |
+9
-0
@@ -143,2 +143,11 @@ // HTTP client for the Unphurl API | ||
| } | ||
| async listAllowlist() { | ||
| return this.doRequest("GET", "/v1/allowlist"); | ||
| } | ||
| async addToAllowlist(domains) { | ||
| return this.doRequest("POST", "/v1/allowlist", { domains }); | ||
| } | ||
| async removeFromAllowlist(domains) { | ||
| return this.doRequest("DELETE", "/v1/allowlist", { domains }); | ||
| } | ||
| } | ||
@@ -145,0 +154,0 @@ export class ApiRequestError extends Error { |
+11
-6
@@ -62,8 +62,8 @@ // Hardcoded default scoring weights and signal descriptions | ||
| default_weight: 10, | ||
| description: "3 or more signals detected together (amplifier for multiple weak signals)", | ||
| description: "3 or more signals detected together with at least one high-severity anchor (domain ≤30 days, 5+ redirects, invalid certificate, parked, or incomplete chain). The score breakdown names which signals contributed.", | ||
| }, | ||
| { | ||
| key: "phishing_floor", | ||
| key: "brand_impersonation_floor", | ||
| default_weight: 80, | ||
| description: "Minimum score applied when brand impersonation is detected alongside any other signal", | ||
| description: "Minimum score applied when brand impersonation is confirmed alongside a meaningful secondary signal. Structural signals like url_long or subdomain_excessive alone do not qualify.", | ||
| }, | ||
@@ -81,5 +81,10 @@ { | ||
| { | ||
| key: "subdomain_deep", | ||
| default_weight: 3, | ||
| description: "Domain has 2 subdomains", | ||
| }, | ||
| { | ||
| key: "subdomain_excessive", | ||
| default_weight: 5, | ||
| description: "Domain has more than 3 subdomains", | ||
| description: "Domain has 3 or more subdomains", | ||
| }, | ||
@@ -104,3 +109,3 @@ { | ||
| default_weight: 5, | ||
| description: "TLD changed between input URL and final destination", | ||
| description: "TLD changed on redirect to a suspicious final TLD (e.g. .com→.xyz fires; .com→.ca does not)", | ||
| }, | ||
@@ -123,2 +128,2 @@ { | ||
| ]; | ||
| export const DEFAULTS_NOTE = "Profiles override specific weights. Signals not in a profile use these defaults. 23 configurable signals plus suspicious_tld (+3 points) which is internal only and not configurable."; | ||
| export const DEFAULTS_NOTE = "Profiles override specific weights. Signals not in a profile use these defaults. 24 configurable signals plus suspicious_tld (+3 points) which is internal only and not configurable."; |
+6
-4
| #!/usr/bin/env node | ||
| // Unphurl MCP Server — domain intelligence for AI tools | ||
| // Wraps the Unphurl API as 13 MCP tools for Claude Code, Cursor, Windsurf, etc. | ||
| // Unphurl MCP Server — URL intelligence for AI agents and developers | ||
| // Wraps the Unphurl API as 16 MCP tools for Claude Code, Cursor, Windsurf, etc. | ||
| // | ||
@@ -18,2 +18,3 @@ // Configuration: | ||
| import { registerStatsTool } from "./tools/stats.js"; | ||
| import { registerAllowlistTools } from "./tools/allowlist.js"; | ||
| const DEFAULT_API_URL = "https://api.unphurl.com"; | ||
@@ -26,5 +27,5 @@ // Backward compatibility: accept LINKCHECK_* env vars for users migrating from LinkCheck | ||
| name: "unphurl", | ||
| version: "0.1.1", | ||
| version: "0.2.0", | ||
| }); | ||
| // Register all 13 tools across 7 modules | ||
| // Register all 16 tools across 8 modules | ||
| registerSignupTools(server, api); // signup, resend_verification | ||
@@ -37,4 +38,5 @@ registerCheckTool(server, api); | ||
| registerStatsTool(server, api); | ||
| registerAllowlistTools(server, api); // list_allowlist, add_to_allowlist, remove_from_allowlist | ||
| // Start the server on stdio | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); |
@@ -55,3 +55,3 @@ // Profile tools — list, create, delete, and show defaults | ||
| .record(z.string(), z.number().int().min(0).max(1000)) | ||
| .describe("Custom weights for scoring signals. Only include signals you want to override. Available signals: brand_impersonation (default 40), domain_age_3 (35), domain_age_7 (25), domain_age_30 (15), domain_age_90 (5), ssl_invalid (10), http_only (5), redirects_3 (10), redirects_5 (25), chain_incomplete (15), parked (10), compound (10), phishing_floor (80), url_long (3), path_deep (3), subdomain_excessive (5), domain_entropy_high (5), url_contains_ip (10), encoded_hostname (5), tld_redirect_change (5), expiring_soon (10), domain_status_bad (15), no_mx_record (5)."), | ||
| .describe("Custom weights for scoring signals. Only include signals you want to override. Available signals: brand_impersonation (default 40), domain_age_3 (35), domain_age_7 (25), domain_age_30 (15), domain_age_90 (5), ssl_invalid (10), http_only (5), redirects_3 (10), redirects_5 (25), chain_incomplete (15), parked (10), compound (10), brand_impersonation_floor (80), url_long (3), path_deep (3), subdomain_excessive (5), domain_entropy_high (5), url_contains_ip (10), encoded_hostname (5), tld_redirect_change (5), expiring_soon (10), domain_status_bad (15), no_mx_record (5)."), | ||
| }, | ||
@@ -58,0 +58,0 @@ }, async ({ name, weights }) => { |
+1
-1
| { | ||
| "name": "@unphurl/mcp-server", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "mcpName": "io.github.123Ergo/unphurl", | ||
@@ -5,0 +5,0 @@ "description": "URL intelligence for AI agents and developers. Structured signals on all URLs. 16 tools, 23 signal weights. 20 free checks.", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
55093
9.19%17
6.25%903
11.48%