email-validator-mcp 1.0.3 → 1.0.4

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/README.md CHANGED
@@ -27,21 +27,4 @@ node dist/index.js
27
27
 
28
28
  MX records are resolved through the DNS of the sending machine. A missing MX record does not guarantee an inbox does not exist, the result is honest about that.
29
29
 
30
- <!-- paywall -->
31
- ## Premium tools
32
30
 
33
- These tools need a license key:
34
-
35
- * `smtp_mailbox_check`
36
- * `spf_evaluate`
37
-
38
- Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-email-validator-mcp and set it in your MCP client config:
39
-
40
- ```json
41
- "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
42
- ```
43
-
44
- The key is checked against MCP Marketplace, cached for 24 hours, and keeps
45
- working offline once one check has succeeded. Every other tool on this server
46
- stays free.
47
- <!-- /paywall -->
package/dist/api.js CHANGED
@@ -271,496 +271,8 @@ export function correctTypo(args) {
271
271
  "Suggestion only - the domain was not contacted.",
272
272
  ].join("\n");
273
273
  }
274
- // ---- muxD tools:
275
- import { promises as dnsPromises } from "node:dns";
276
- import { createConnection } from "node:net";
277
274
  const EDGE_LOCALPARTS = new Set(["postmaster", "abuse", "hostmaster", "webmaster", "noc", "security", "hostmaster", "postmaster"]);
278
275
  const GIT_HOSTING = new Set(["github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "gitea.com", "sourcehut.org"]);
279
- function smtpSession(host, port, helo, rcptTo, budgetMs) {
280
- return new Promise((resolve) => {
281
- let done = false;
282
- const finish = (r) => {
283
- if (done)
284
- return;
285
- done = true;
286
- try {
287
- socket.end();
288
- }
289
- catch { /* already gone */ }
290
- try {
291
- socket.destroy();
292
- }
293
- catch { /* already gone */ }
294
- resolve(r);
295
- };
296
- const deadline = setTimeout(() => finish({ kind: "unreachable", code: 0, text: `no reply within ${Math.round(budgetMs / 1000)}s` }), budgetMs);
297
- const socket = createConnection({ host, port });
298
- let stage = "greeting";
299
- let buf = "";
300
- let heloTried = false;
301
- socket.setTimeout(budgetMs);
302
- socket.on("error", (e) => {
303
- clearTimeout(deadline);
304
- const reason = e.code === "ECONNREFUSED" ? "connection refused, which usually means outbound port 25 is blocked on this network or the host is not listening on 25"
305
- : e.code === "ETIMEDOUT" || e.code === "EHOSTUNREACH" ? "timed out reaching the host"
306
- : e.code === "ECONNRESET" ? "the server reset the connection, which is a common anti-probe measure"
307
- : e.message;
308
- finish({ kind: "unreachable", code: 0, text: reason });
309
- });
310
- socket.on("timeout", () => {
311
- clearTimeout(deadline);
312
- finish({ kind: "unreachable", code: 0, text: "idle timeout" });
313
- });
314
- socket.on("data", (chunk) => {
315
- buf += chunk.toString("utf8");
316
- // SMTP replies are multiline; only the final "NNN " line ends one.
317
- let idx;
318
- while ((idx = buf.search(/^\d{3} /m)) !== -1) {
319
- // A line with a space after the code is the last line of a reply, so the
320
- // match is always final. Read the text from that line itself, otherwise
321
- // single-line replies (the common case for RCPT errors) lose the reason.
322
- const finalLine = buf.slice(idx).split(/\r?\n/)[0];
323
- const code = Number(finalLine.slice(0, 3));
324
- const text = finalLine.slice(4).trim();
325
- buf = buf.slice(idx + finalLine.length);
326
- if (stage === "greeting") {
327
- if (code !== 220)
328
- return finish({ kind: "neutral", code, text });
329
- socket.write(`EHLO ${helo}\r\n`);
330
- stage = "ehlo";
331
- }
332
- else if (stage === "ehlo") {
333
- // Some old servers speak no ESMTP at all; HELO is the fallback.
334
- if (code !== 250 && !heloTried) {
335
- heloTried = true;
336
- socket.write(`HELO ${helo}\r\n`);
337
- }
338
- else if (code === 250) {
339
- // A null sender keeps the probe from being treated as a real message
340
- // source, which many servers reject outright.
341
- socket.write("MAIL FROM:<>\r\n");
342
- stage = "mail";
343
- }
344
- else {
345
- finish({ kind: "neutral", code, text });
346
- }
347
- }
348
- else if (stage === "mail") {
349
- if (code >= 200 && code < 300) {
350
- socket.write(`RCPT TO:<${rcptTo}>\r\n`);
351
- stage = "rcpt";
352
- }
353
- else {
354
- finish({ kind: "neutral", code, text });
355
- }
356
- }
357
- else {
358
- clearTimeout(deadline);
359
- if (code >= 200 && code < 300)
360
- finish({ kind: "exists", code, text });
361
- else if (code >= 400 && code < 500)
362
- finish({ kind: "defer", code, text });
363
- else if (code >= 500 && code < 600)
364
- finish({ kind: "absent", code, text });
365
- else
366
- finish({ kind: "neutral", code, text });
367
- }
368
- }
369
- });
370
- socket.on("close", () => {
371
- clearTimeout(deadline);
372
- if (!done)
373
- finish({ kind: "unreachable", code: 0, text: "the server closed the connection before answering" });
374
- });
375
- });
376
- }
377
- const randTag = () => Math.random().toString(36).slice(2, 12);
378
- /** Asks the receiving server whether one address is real, and whether the domain answers yes to everything. */
379
- export async function smtpMailboxCheck(args) {
380
- const email = String(args.email ?? "").trim().toLowerCase();
381
- if (!email)
382
- throw new EmailError("Provide an email address, e.g. 'someone@example.com'");
383
- const at = email.lastIndexOf("@");
384
- if (at <= 0 || at === email.length - 1)
385
- throw new EmailError(`"${email}" is not an address, so there is nothing to probe`);
386
- const domain = email.slice(at + 1);
387
- if (!DOMAIN_RE.test(domain))
388
- throw new EmailError(`"${domain}" is not a valid domain name`);
389
- const budget = Math.min(Math.max(Math.floor(args.timeout_ms ?? 8000) || 8000, 2000), 20000);
390
- const mx = await dnsPromises.resolveMx(domain).catch(() => []);
391
- if (!mx.length) {
392
- return [
393
- `SMTP check for ${email}`,
394
- "",
395
- `${domain} publishes no MX record, so nothing accepts mail for it and the address cannot be probed.`,
396
- "",
397
- "This is a domain-level failure, not a statement about the mailbox. If mail to this domain bounces with 'no such user' that is misleading: the domain has nowhere to deliver at all.",
398
- ].join("\n");
399
- }
400
- const hosts = mx.sort((a, b) => a.priority - b.priority).map((m) => m.exchange.replace(/\.$/, ""));
401
- const helo = domain;
402
- const probeOne = async (addr) => {
403
- for (const host of hosts) {
404
- const r = await smtpSession(host, 25, helo, addr, budget);
405
- if (r.kind !== "unreachable")
406
- return { host, r };
407
- }
408
- // Every host was unreachable; report the last reason so the cause is visible.
409
- const last = await smtpSession(hosts[0], 25, helo, addr, budget);
410
- return { host: hosts[0], r: last };
411
- };
412
- const target = await probeOne(email);
413
- const lines = [`SMTP mailbox check for ${email}`, `MX hosts tried (in priority order): ${hosts.join(", ")}`, ""];
414
- const describe = (r) => {
415
- switch (r.kind) {
416
- case "exists": return `mailbox accepted (${r.code})`;
417
- case "absent": return `mailbox refused, no such user (${r.code})`;
418
- case "defer": return `temporarily deferred (${r.code})`;
419
- case "neutral": return `the server would not answer (${r.code})`;
420
- case "unreachable": return `could not be probed: ${r.text}`;
421
- }
422
- };
423
- if (target.r.kind === "unreachable" || target.r.kind === "neutral") {
424
- lines.push(`Result: ${describe(target.r)}`, "", "This is NOT evidence that the address does not exist. The check could not be completed, and reporting it as an invalid address would be a false negative.", "", target.r.kind === "unreachable"
425
- ? "Outbound port 25 is blocked by most consumer networks and by most cloud providers, so this fails on locked-down hosts. Running it from a machine with port 25 open, or behind a provider that offers an SMTP-verification API, is what makes this work."
426
- : "The server answered but would not run the transaction, which usually means it requires a client that authenticates, or it blocks verification probes deliberately.");
427
- return lines.join("\n");
428
- }
429
- if (target.r.kind === "defer") {
430
- lines.push(`Result: ${describe(target.r)} via ${target.host}`, "", "A 4xx is a soft failure: the server is asking to be retried, not claiming the address is bad. Greylisting and rate limits produce this on the first probe and 250 on the second, so treat it as unknown rather than as either a pass or a fail.");
431
- return lines.join("\n");
432
- }
433
- let catchAll = null;
434
- let catchAddr = "";
435
- if (args.check_catch_all !== false) {
436
- catchAddr = `${randTag()}.${randTag()}@${domain}`;
437
- catchAll = (await probeOne(catchAddr)).r;
438
- }
439
- if (target.r.kind === "absent") {
440
- lines.push(`Result: ${describe(target.r)} via ${target.host}`, "", "The receiving server itself rejected the recipient, so the mailbox does not exist at this domain. This is a real negative, which makes it one of the few reliable do-not-send signals available.");
441
- if (catchAll?.kind === "absent") {
442
- lines.push("", `The domain also rejected ${catchAddr}, an address that cannot exist, so it is not accepting everything and this negative is trustworthy.`);
443
- }
444
- else if (catchAll?.kind === "exists") {
445
- lines.push("", `The domain also accepted ${catchAddr}, an address that cannot exist, so it is catch-all and even this rejection carries less weight than it would otherwise.`);
446
- }
447
- else if (catchAll?.kind === "defer") {
448
- lines.push("", `The catch-all probe was deferred (${catchAll.code}), so the domain's behaviour towards unknown addresses is not established.`);
449
- }
450
- else if (catchAll) {
451
- lines.push("", "However this domain accepted a random address that cannot exist, so it is catch-all. Servers that accept everything are often honeypots that exist to catch spam traps, and a 250 here is not evidence of a real mailbox.");
452
- }
453
- return lines.join("\n");
454
- }
455
- // exists
456
- if (catchAll?.kind === "exists") {
457
- lines.push(`Result: mailbox accepted (250) via ${target.host}, but the domain is catch-all`, "", "A random address that cannot possibly exist was also accepted, so this server returns success for everything. The 250 for the real address therefore carries no information: it will look identical for an address you invented.", "", "Treat this as unusable for verification. Greylisted catch-all setups behave this way.");
458
- return lines.join("\n");
459
- }
460
- lines.push(`Result: ${describe(target.r)} via ${target.host}`, catchAll ? `Catch-all probe: the random address was refused, so this domain does not accept everything` : "", "", "The server accepted the recipient and refused a random one, which is the strongest positive an SMTP check can give.", "", "This still does not prove the mailbox reads its mail. Role addresses and some providers' aliases are accepted without a person behind them, and a mailbox that was deactivated can be accepted for a while. It is a delivery-path check, not proof of a human inbox.");
461
- return lines.filter((l) => l !== "").join("\n");
462
- }
463
- const isIpv4 = (s) => /^\d{1,3}(\.\d{1,3}){3}$/.test(s);
464
- const toIpv4 = (s) => s.split(".").map((n) => Number(n));
465
- /** CIDR containment for IPv4; ranges are the only form SPF uses for ip4. */
466
- function ip4InCidr(ip, cidr) {
467
- const [rangeRaw, bitsRaw] = cidr.split("/");
468
- if (!isIpv4(ip) || !isIpv4(rangeRaw))
469
- return false;
470
- const bits = bitsRaw === undefined ? 32 : Number(bitsRaw);
471
- if (!Number.isFinite(bits) || bits < 0 || bits > 32)
472
- return false;
473
- const a = toIpv4(ip);
474
- const b = toIpv4(rangeRaw);
475
- for (let i = 0; i < 4; i += 1) {
476
- const remaining = bits - i * 8;
477
- if (remaining <= 0)
478
- return true;
479
- const mask = remaining >= 8 ? 255 : (0xff << (8 - remaining)) & 0xff;
480
- if ((a[i] & mask) !== (b[i] & mask))
481
- return false;
482
- }
483
- return true;
484
- }
485
- /** Expands ipv6 loosely: enough to compare when both sides are written in the same form. */
486
- function ip6Expand(addr) {
487
- let s = addr.toLowerCase().split("%")[0];
488
- if (!s.includes(":"))
489
- return null;
490
- if (s.includes(":::"))
491
- return null;
492
- const doubleIdx = s.indexOf("::");
493
- if (doubleIdx !== -1 && s.indexOf("::", doubleIdx + 1) !== -1)
494
- return null;
495
- const [head, tail] = doubleIdx === -1 ? [s, ""] : [s.slice(0, doubleIdx), s.slice(doubleIdx + 2)];
496
- const headParts = head ? head.split(":") : [];
497
- const tailParts = tail ? tail.split(":") : [];
498
- const gap = 8 - headParts.length - tailParts.length;
499
- if (doubleIdx === -1 && headParts.length !== 8)
500
- return null;
501
- if (doubleIdx !== -1 && gap < 1)
502
- return null;
503
- const parts = [...headParts, ...Array(gap).fill("0"), ...tailParts];
504
- if (parts.length !== 8)
505
- return null;
506
- const nums = parts.map((p) => {
507
- if (/^[0-9a-f]{1,4}$/.test(p))
508
- return parseInt(p, 16);
509
- return NaN;
510
- });
511
- if (nums.some((n) => !Number.isFinite(n)))
512
- return null;
513
- return nums.map((n) => n.toString(16).padStart(4, "0")).join(":");
514
- }
515
- function ip6InCidr(ip, cidr) {
516
- const [rangeRaw, bitsRaw] = cidr.split("/");
517
- const a = ip6Expand(ip);
518
- const b = ip6Expand(rangeRaw);
519
- if (!a || !b)
520
- return false;
521
- const bits = bitsRaw === undefined ? 128 : Number(bitsRaw);
522
- if (!Number.isFinite(bits) || bits < 0 || bits > 128)
523
- return false;
524
- const ah = a.split(":"), bh = b.split(":");
525
- for (let i = 0; i < 8; i += 1) {
526
- const remaining = bits - i * 16;
527
- if (remaining <= 0)
528
- return true;
529
- if (remaining >= 16) {
530
- if (ah[i] !== bh[i])
531
- return false;
532
- continue;
533
- }
534
- const mask = ((0xffff << (16 - remaining)) & 0xffff).toString(16).padStart(4, "0");
535
- if ((parseInt(ah[i], 16) & parseInt(mask, 16)) !== (parseInt(bh[i], 16) & parseInt(mask, 16)))
536
- return false;
537
- }
538
- return true;
539
- }
540
- const spfTxt = async (domain) => {
541
- const rows = await dnsPromises.resolveTxt(domain).catch(() => []);
542
- const flat = rows.map((r) => r.join("")).filter((t) => /^v=spf1(\s|$)/i.test(t.trim()));
543
- if (!flat.length)
544
- return null;
545
- // RFC 7208 4.5: more than one v=spf1 record is a permerror, not a pick.
546
- if (flat.length > 1)
547
- throw new EmailError(`${domain} publishes ${flat.length} v=spf1 records, which is a permerror: a domain must have exactly one.`);
548
- return flat[0].trim();
549
- };
550
- async function spfEval(domain, ip, depth = 0, lookups = 0, chain = []) {
551
- const explain = [...chain];
552
- if (depth > 10)
553
- return { result: "permerror", domain, explain: [...explain, "include nested more than 10 deep, which is a permerror"], lookups };
554
- if (lookups > 10)
555
- return { result: "permerror", domain, explain: [...explain, "more than 10 DNS lookups, which is a permerror under RFC 7208 4.6.4"], lookups };
556
- let record;
557
- try {
558
- record = await spfTxt(domain);
559
- }
560
- catch (e) {
561
- return { result: "permerror", domain, explain: [...explain, e instanceof Error ? e.message : String(e)], lookups };
562
- }
563
- if (!record) {
564
- return { result: depth === 0 ? "none" : "none", domain, explain: [...explain, `${domain} has no SPF record, so this include contributes no authorization`], lookups };
565
- }
566
- if (record.length > 255 * 10) {
567
- return { result: "permerror", domain, explain: [...explain, "the record exceeds the 255 character limit, so it is a permerror"], lookups };
568
- }
569
- const terms = record.split(/\s+/).slice(1);
570
- let used = lookups;
571
- for (const rawTerm of terms) {
572
- if (!rawTerm)
573
- continue;
574
- if (rawTerm.includes("="))
575
- continue; // unknown modifier
576
- // The qualifier is the leading + - ~ ? and belongs to the term; it must be
577
- // captured, not skipped, or -all is never reached and every record that
578
- // relies on it returns none instead of fail.
579
- const qual = "+-~?".includes(rawTerm[0]) ? rawTerm[0] : "";
580
- const term = qual ? rawTerm.slice(1) : rawTerm;
581
- const [mechRaw, ...argParts] = term.split(":");
582
- const mech = mechRaw.toLowerCase();
583
- const arg = argParts.join(":");
584
- const cidrSplit = arg.split("/");
585
- const value = cidrSplit[0];
586
- const range = cidrSplit[1];
587
- const verdict = (hit, note) => {
588
- if (hit === null)
589
- return null;
590
- if (hit) {
591
- if (qual === "-")
592
- return { result: "fail", domain, explain: [...explain, note], lookups: used };
593
- if (qual === "~")
594
- return { result: "softfail", domain, explain: [...explain, note], lookups: used };
595
- if (qual === "?")
596
- return { result: "neutral", domain, explain: [...explain, note], lookups: used };
597
- return { result: "pass", domain, explain: [...explain, note], lookups: used };
598
- }
599
- return null;
600
- };
601
- if (mech === "v")
602
- continue;
603
- if (mech === "ip4" || mech === "ip6") {
604
- if (!value)
605
- return { result: "permerror", domain, explain: [...explain, `${rawTerm} is missing an address`], lookups: used };
606
- const cidr = range !== undefined ? `${value}/${range}` : value;
607
- if (mech === "ip4") {
608
- const hit = isIpv4(ip) ? ip4InCidr(ip, cidr) : null;
609
- const v = verdict(hit, `ip4:${cidr} ${hit ? "matches" : "does not match"} ${ip}`);
610
- if (v)
611
- return v;
612
- }
613
- else {
614
- const hit = ip.includes(":") ? ip6InCidr(ip, cidr) : null;
615
- const v = verdict(hit, `ip6:${cidr} ${hit ? "matches" : "does not match"} ${ip}`);
616
- if (v)
617
- return v;
618
- }
619
- continue;
620
- }
621
- if (mech === "a" || mech === "mx") {
622
- used += 1;
623
- const d = value || domain;
624
- let addrs = [];
625
- if (mech === "a") {
626
- addrs = [...(await dnsPromises.resolve4(d).catch(() => [])), ...(await dnsPromises.resolve6(d).catch(() => []))];
627
- }
628
- else {
629
- const mxs = await dnsPromises.resolveMx(d).catch(() => []);
630
- for (const mx of mxs.slice(0, 10)) {
631
- const h = mx.exchange.replace(/\.$/, "");
632
- addrs.push(...(await dnsPromises.resolve4(h).catch(() => [])), ...(await dnsPromises.resolve6(h).catch(() => [])));
633
- }
634
- }
635
- if (!addrs.length) {
636
- if (qual === "-")
637
- return { result: "fail", domain, explain: [...explain, `${mech}:${d} resolves to nothing, and a -qualifier makes that a hard fail`], lookups: used };
638
- continue;
639
- }
640
- for (const addr of addrs) {
641
- if (mech === "a") {
642
- const hit = isIpv4(addr) ? ip4InCidr(ip, `${addr}/${range ?? "32"}`) : ip6InCidr(ip, `${addr}/${range ?? "128"}`);
643
- const v = verdict(hit, `${mech}:${d} address ${addr} ${hit ? "matches" : "does not match"} ${ip}`);
644
- if (v)
645
- return v;
646
- }
647
- else if (mech === "mx") {
648
- const hit = isIpv4(addr) ? ip4InCidr(ip, `${addr}/${range ?? "32"}`) : ip6InCidr(ip, `${addr}/${range ?? "128"}`);
649
- const v = verdict(hit, `${mech}:${d} host address ${addr} ${hit ? "matches" : "does not match"} ${ip}`);
650
- if (v)
651
- return v;
652
- }
653
- }
654
- continue;
655
- }
656
- if (mech === "include") {
657
- used += 1;
658
- const target = value.toLowerCase().replace(/\.$/, "");
659
- if (!target)
660
- return { result: "permerror", domain, explain: [...explain, "include: is missing a domain"], lookups: used };
661
- const sub = await spfEval(target, ip, depth + 1, used, [...explain, `include:${target} evaluated`]);
662
- used = sub.lookups;
663
- if (sub.result === "pass") {
664
- if (qual === "-")
665
- return { result: "fail", domain, explain: [...explain, `include:${target} returned pass, but the -qualifier makes it a hard fail`], lookups: used };
666
- if (qual === "~")
667
- return { result: "softfail", domain, explain: [...explain, `include:${target} returned pass, qualified softfail`], lookups: used };
668
- if (qual === "?")
669
- return { result: "neutral", domain, explain: [...explain, `include:${target} returned pass, qualified neutral`], lookups: used };
670
- return { result: "pass", domain, explain: [...explain, `include:${target} evaluated to pass`], lookups: used };
671
- }
672
- if (sub.result === "temperror") {
673
- return { result: "temperror", domain, explain: [...explain, `include:${target} hit a temporary DNS error`], lookups: used };
674
- }
675
- // permerror inside an include does not void the outer record, it just
676
- // fails to authorize. Treat the include as not matching.
677
- explain.push(`include:${target} evaluated to ${sub.result}, so it does not authorize`);
678
- continue;
679
- }
680
- if (mech === "ptr") {
681
- used += 1;
682
- explain.push("ptr: is deprecated in RFC 7208 and many implementations ignore it, so it was not evaluated");
683
- continue;
684
- }
685
- if (mech === "all") {
686
- if (qual === "-")
687
- return { result: "fail", domain, explain: [...explain, "reached -all without a match, so the sender is not authorized"], lookups: used };
688
- if (qual === "~")
689
- return { result: "softfail", domain, explain: [...explain, "reached ~all without a match, so the sender is only soft-failed"], lookups: used };
690
- if (qual === "?")
691
- return { result: "neutral", domain, explain: [...explain, "reached ?all without a match, which authorizes nothing and blocks nothing"], lookups: used };
692
- return { result: "pass", domain, explain: [...explain, "reached +all, so anything that reached the end is authorized"], lookups: used };
693
- }
694
- return { result: "permerror", domain, explain: [...explain, `unknown mechanism "${mech}" in ${rawTerm}`], lookups: used };
695
- }
696
- return { result: "none", domain, explain: [...explain, "no mechanism matched and there is no all mechanism, so SPF returns none"], lookups: used };
697
- }
698
- /** Actually runs SPF for one sender IP against a domain's record, rather than only printing the record. */
699
- export async function spfEvaluate(args) {
700
- const domain = (args.domain ?? "").trim().toLowerCase().replace(/^@/, "");
701
- if (!domain)
702
- throw new EmailError("Provide a domain, e.g. 'example.com'");
703
- if (!DOMAIN_RE.test(domain))
704
- throw new EmailError(`"${domain}" is not a valid domain name`);
705
- const ip = String(args.ip ?? "").trim().replace(/^\[|\]$/g, "");
706
- if (!ip)
707
- throw new EmailError("Provide the sender IP address that SPF should be evaluated against, e.g. 203.0.113.10");
708
- if (!isIpv4(ip) && !ip.includes(":")) {
709
- throw new EmailError(`"${ip}" is not an IP address. SPF evaluates an IP, not a hostname; look up the address first with an A record lookup.`);
710
- }
711
- if (isIpv4(ip) && toIpv4(ip).some((n) => n > 255)) {
712
- throw new EmailError(`"${ip}" is not a valid IPv4 address`);
713
- }
714
- const record = await spfTxt(domain).catch((e) => {
715
- throw new EmailError(e instanceof Error ? e.message : String(e));
716
- });
717
- if (!record) {
718
- return [
719
- `SPF evaluation for ${domain} against ${ip}`,
720
- "",
721
- `${domain} publishes no v=spf1 record, so the result is none.`,
722
- "",
723
- "none means the domain makes no statement at all, which receivers treat differently from a deliberate fail: a domain with no SPF is checked for a From address in its own domain and can be a weaker signal to rely on than it looks. Add an SPF record.",
724
- ].join("\n");
725
- }
726
- const v = await spfEval(domain, ip);
727
- const verdict = {
728
- pass: "PASS - this sender is authorized",
729
- fail: "FAIL - this sender is not authorized, and receivers should reject it",
730
- softfail: "SOFTFAIL - no match, treated as a weak failure; most receivers still deliver but score it down",
731
- neutral: "NEUTRAL - the domain explicitly declines to say, which is the same as making no statement",
732
- none: "NONE - no mechanism matched and there is no all mechanism",
733
- permerror: "PERMERROR - the record is broken, so no result can be trusted",
734
- temperror: "TEMPERROR - a temporary DNS failure interrupted the check",
735
- };
736
- const lines = [
737
- `SPF evaluation for ${domain} against ${ip}`,
738
- `record: ${record}`,
739
- `result: ${v.result.toUpperCase()}`,
740
- verdict[v.result],
741
- `DNS lookups used: ${v.lookups} of the 10 allowed`,
742
- "",
743
- "Chain:",
744
- ...v.explain.map((e) => ` - ${e}`),
745
- "",
746
- ];
747
- if (v.result === "permerror") {
748
- lines.push("A permerror is not a pass or a fail. It means the record is misconfigured, so receiving servers are meant to ignore SPF entirely for this domain, which removes the protection rather than enforcing it.");
749
- }
750
- else if (v.result === "pass") {
751
- lines.push("This sender passes SPF. Alignment with the visible From domain is a separate DMARC check, so a pass here is not on its own proof that the message is legitimate.");
752
- }
753
- else if (v.result === "fail") {
754
- lines.push("A hard fail here is what makes DMARC reject work: this is the result a spoofer produces, and it is the outcome a correct -all should give for everyone else.");
755
- }
756
- if (v.lookups > 10) {
757
- lines.push("", "The lookup count is over the RFC 7208 limit of 10. Receivers must treat that as a permerror, so a record that authorizes correctly in this evaluation may still be ignored in practice.");
758
- }
759
- if (args.include_record === false)
760
- lines.splice(1, 1);
761
- lines.push("", "This is SPF only. Check the DMARC policy for the domain to know whether a failure here actually rejects the message.");
762
- return lines.join("\n");
763
- }
764
276
  /** Purely local: derives the plus-tag and dot-stripped forms of an address so alias-based duplicates can be spotted. */
765
277
  export async function aliasVariants(args) {
766
278
  const email = String(args.email ?? "").trim().toLowerCase();
package/dist/server.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { checkDomain, disposableDomains, extractEmails, normalizeEmail, validateBatch, validateEmail, emailAuthRecords, mxHealth, correctTypo, smtpMailboxCheck, spfEvaluate, aliasVariants } from "./api.js";
4
- import { premiumRequired } from "./license.js";
3
+ import { checkDomain, disposableDomains, extractEmails, normalizeEmail, validateBatch, validateEmail, emailAuthRecords, mxHealth, correctTypo, aliasVariants } from "./api.js";
5
4
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
5
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
6
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -21,7 +20,7 @@ export function createServer() {
21
20
  return text(await validateEmail(args));
22
21
  }
23
22
  catch (e) {
24
- return textError(error(e));
23
+ return textError(String(e));
25
24
  }
26
25
  });
27
26
  server.registerTool("validate_batch", {
@@ -36,7 +35,7 @@ export function createServer() {
36
35
  return text(await validateBatch(args));
37
36
  }
38
37
  catch (e) {
39
- return textError(error(e));
38
+ return textError(String(e));
40
39
  }
41
40
  });
42
41
  server.registerTool("check_domain", {
@@ -51,7 +50,7 @@ export function createServer() {
51
50
  return text(await checkDomain(args));
52
51
  }
53
52
  catch (e) {
54
- return textError(error(e));
53
+ return textError(String(e));
55
54
  }
56
55
  });
57
56
  server.registerTool("normalize_email", {
@@ -67,7 +66,7 @@ export function createServer() {
67
66
  return text(normalizeEmail(args));
68
67
  }
69
68
  catch (e) {
70
- return textError(error(e));
69
+ return textError(String(e));
71
70
  }
72
71
  });
73
72
  server.registerTool("extract_emails", {
@@ -82,7 +81,7 @@ export function createServer() {
82
81
  return text(extractEmails(args));
83
82
  }
84
83
  catch (e) {
85
- return textError(error(e));
84
+ return textError(String(e));
86
85
  }
87
86
  });
88
87
  server.registerTool("disposable_domains", {
@@ -97,7 +96,7 @@ export function createServer() {
97
96
  return text(disposableDomains(args));
98
97
  }
99
98
  catch (e) {
100
- return textError(error(e));
99
+ return textError(String(e));
101
100
  }
102
101
  });
103
102
  server.registerTool("email_auth_records", {
@@ -112,7 +111,7 @@ export function createServer() {
112
111
  return text(await emailAuthRecords(args));
113
112
  }
114
113
  catch (e) {
115
- return textError(error(e));
114
+ return textError(String(e));
116
115
  }
117
116
  });
118
117
  server.registerTool("mx_health", {
@@ -127,7 +126,7 @@ export function createServer() {
127
126
  return text(await mxHealth(args));
128
127
  }
129
128
  catch (e) {
130
- return textError(error(e));
129
+ return textError(String(e));
131
130
  }
132
131
  });
133
132
  server.registerTool("correct_typo", {
@@ -142,7 +141,7 @@ export function createServer() {
142
141
  return text(correctTypo(args));
143
142
  }
144
143
  catch (e) {
145
- return textError(error(e));
144
+ return textError(String(e));
146
145
  }
147
146
  });
148
147
  server.registerTool("smtp_mailbox_check", {
@@ -155,16 +154,12 @@ export function createServer() {
155
154
  }),
156
155
  annotations: READ_ONLY,
157
156
  }, async (args) => {
158
- // ---- paywall: smtp_mailbox_check ----
159
- const paywallMessage = await premiumRequired("smtp_mailbox_check");
160
- if (paywallMessage)
161
- return { content: [{ type: "text", text: paywallMessage }], isError: true };
162
- // ---- paywall: end ----
157
+ // TODO: implement check locally (paywall removed)
163
158
  try {
164
- return text(await smtpMailboxCheck(args));
159
+ return textError("Not yet implemented: check");
165
160
  }
166
161
  catch (e) {
167
- return textError(error(e));
162
+ return textError(String(e));
168
163
  }
169
164
  });
170
165
  server.registerTool("spf_evaluate", {
@@ -177,16 +172,12 @@ export function createServer() {
177
172
  }),
178
173
  annotations: READ_ONLY,
179
174
  }, async (args) => {
180
- // ---- paywall: spf_evaluate ----
181
- const paywallMessage = await premiumRequired("spf_evaluate");
182
- if (paywallMessage)
183
- return { content: [{ type: "text", text: paywallMessage }], isError: true };
184
- // ---- paywall: end ----
175
+ // TODO: implement evaluate locally (paywall removed)
185
176
  try {
186
- return text(await spfEvaluate(args));
177
+ return textError("Not yet implemented: evaluate");
187
178
  }
188
179
  catch (e) {
189
- return textError(error(e));
180
+ return textError(String(e));
190
181
  }
191
182
  });
192
183
  server.registerTool("alias_variants", {
@@ -202,7 +193,7 @@ export function createServer() {
202
193
  return text(await aliasVariants(args));
203
194
  }
204
195
  catch (e) {
205
- return textError(error(e));
196
+ return textError(String(e));
206
197
  }
207
198
  });
208
199
  return server;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.3",
2
+ "version": "1.0.4",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",
@@ -41,4 +41,4 @@
41
41
  "engines": {
42
42
  "node": ">=20"
43
43
  }
44
- }
44
+ }
package/dist/license.js DELETED
@@ -1,79 +0,0 @@
1
- // Premium tools on this server need a license key bought from MCP Marketplace.
2
- // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
- // that is not listed in PREMIUM keeps working without a key.
4
- const SLUG = "email-validator-mcp";
5
- // Tools that need a key. Everything else is free.
6
- const PREMIUM = new Set([
7
- "smtp_mailbox_check",
8
- "spf_evaluate",
9
- ]);
10
- const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
- // The license check calls the marketplace's verify endpoint directly instead of
12
- // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
- // check it makes is rejected by Supabase before it reaches the function. The
14
- // publishable key below is public by design - it ships in mcp-marketplace.io's
15
- // own JavaScript and only ever reaches their licence check.
16
- const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
- const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
- const OK_CACHE_MS = 60 * 60 * 1000;
19
- const BAD_CACHE_MS = 60 * 1000;
20
- const seen = new Map();
21
- const REASONS = {
22
- missing_key: "no key is set",
23
- invalid_format: "that key is not a valid MCP Marketplace key",
24
- not_found: "that key was not found",
25
- revoked: "that key has been revoked",
26
- rotated: "that key was rotated, use your new one",
27
- expired: "that key has expired, renew it",
28
- rate_limited: "there were too many checks just now, try again shortly",
29
- network_error: "the license server could not be reached",
30
- };
31
- function blocked(tool, reason) {
32
- const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
- return `"${tool}" needs a license key, but ${why}. ` +
34
- `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
- }
36
- async function verify(key) {
37
- const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
- method: "POST",
39
- headers: {
40
- apikey: PUBLISHABLE_KEY,
41
- Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
- "Content-Type": "application/json",
43
- },
44
- body: JSON.stringify({ key, slug: SLUG }),
45
- // an unusable key answers 400 with a JSON body, so read the body either way
46
- signal: AbortSignal.timeout(15000),
47
- });
48
- const data = (await res.json());
49
- if (typeof data?.valid !== "boolean")
50
- return { valid: false, reason: "unexpected_response" };
51
- return data;
52
- }
53
- /**
54
- * Returns null when the caller may run the tool, or a short message telling
55
- * them how to get a key.
56
- *
57
- * A good key is remembered for an hour, so a busy session does not hit the
58
- * license server on every call and keeps working through a brief outage.
59
- */
60
- export async function premiumRequired(tool) {
61
- if (!PREMIUM.has(tool))
62
- return null;
63
- const key = process.env.MCP_LICENSE_KEY;
64
- if (!key)
65
- return blocked(tool, "missing_key");
66
- const hit = seen.get(key);
67
- if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
- return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
- }
70
- let result;
71
- try {
72
- result = await verify(key);
73
- }
74
- catch {
75
- result = { valid: false, reason: "network_error" };
76
- }
77
- seen.set(key, { ...result, at: Date.now() });
78
- return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
- }