mxprobe 0.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/LICENSE +21 -0
- package/README.md +54 -0
- package/bin/mxprobe.mjs +14 -0
- package/package.json +48 -0
- package/src/cli.mjs +121 -0
- package/src/client.mjs +73 -0
- package/src/config.mjs +40 -0
- package/src/mcp.mjs +130 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andriy Chemerynskiy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# mxprobe
|
|
2
|
+
|
|
3
|
+
Email verification for AI agents. One call returns `send`, `hold` or `kill`
|
|
4
|
+
with the reason. The DNS tier is free and runs on your machine; the hosted
|
|
5
|
+
SMTP probe is 9 USD per 10,000 checks, 100 free at signup.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx mxprobe check hello@example.com ops@example.org # free, local, no key
|
|
9
|
+
npx mxprobe signup you@company.com # a key by API, 100 free checks, saved to ~/.config/mxprobe
|
|
10
|
+
npx mxprobe check --hosted hello@example.com # DNS kills stay local; survivors go to the mailbox probe
|
|
11
|
+
npx mxprobe balance
|
|
12
|
+
npx mxprobe buy # a Stripe link: 9 USD per 10,000, credits never expire
|
|
13
|
+
npx mxprobe mcp # the MCP server on stdio
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## MCP
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
claude mcp add mxprobe -- npx -y mxprobe mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Any client: `{"mcpServers":{"mxprobe":{"command":"npx","args":["-y","mxprobe","mcp"]}}}`
|
|
23
|
+
|
|
24
|
+
Tools: `verify_email`, `verify_batch`, `signup`, `balance`, `buy_credits`.
|
|
25
|
+
With a key configured, `verify_*` probe the mailbox on the hosted tier; without
|
|
26
|
+
one they run the free DNS tier. Set `MXPROBE_API_KEY` to use a key from the
|
|
27
|
+
environment.
|
|
28
|
+
|
|
29
|
+
## Verdict
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"email": "hello@example.com",
|
|
34
|
+
"action": "send",
|
|
35
|
+
"verdict": "OK",
|
|
36
|
+
"reason": "mailbox accepted by aspmx.l.google.com",
|
|
37
|
+
"checks": { "syntax": true, "mx": "aspmx.l.google.com", "smtp": "accepted", "catch_all": false }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `send`: the mail server accepted the mailbox.
|
|
42
|
+
- `hold`: send only with a fallback in hand. A catch-all, a forwarder, a
|
|
43
|
+
greylist, or a server that refused the probe rather than the mailbox.
|
|
44
|
+
- `kill`: never send. No mail server, or the mailbox does not exist.
|
|
45
|
+
|
|
46
|
+
## From Node
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { createClient, checkEmails } from "mxprobe";
|
|
50
|
+
const client = createClient({ apiKey: process.env.MXPROBE_API_KEY });
|
|
51
|
+
const { results, summary } = await checkEmails(["a@b.com"], { hosted: true, client });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Docs: https://mxprobe.dev. Source: https://github.com/andrewchmr/mxprobe. MIT.
|
package/bin/mxprobe.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../src/cli.mjs";
|
|
3
|
+
|
|
4
|
+
// No forced exit on success: the MCP server may still be answering a call
|
|
5
|
+
// when stdin closes, and `check` has nothing left to wait for anyway.
|
|
6
|
+
main(process.argv.slice(2)).then(
|
|
7
|
+
(code) => {
|
|
8
|
+
process.exitCode = code ?? 0;
|
|
9
|
+
},
|
|
10
|
+
(err) => {
|
|
11
|
+
console.error(err?.message ?? err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
},
|
|
14
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mxprobe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Email verification for AI agents. One call returns send, hold or kill with the reason. Free DNS tier runs locally; the hosted SMTP probe is 9 USD per 10,000 checks. CLI + MCP server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mxprobe": "./bin/mxprobe.mjs"
|
|
9
|
+
},
|
|
10
|
+
"main": "./src/client.mjs",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/client.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/andrewchmr/mxprobe",
|
|
25
|
+
"directory": "packages/cli"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://mxprobe.dev",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"email",
|
|
30
|
+
"verification",
|
|
31
|
+
"email-verifier",
|
|
32
|
+
"mx",
|
|
33
|
+
"smtp",
|
|
34
|
+
"bounce",
|
|
35
|
+
"ai-agent",
|
|
36
|
+
"mcp",
|
|
37
|
+
"mcp-server",
|
|
38
|
+
"outreach"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
42
|
+
"zod": "^4.5.4",
|
|
43
|
+
"mxprobe-core": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test test/*.test.mjs"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { readConfig, writeConfig, configPath } from "./config.mjs";
|
|
5
|
+
import { createClient, checkEmails, ApiError } from "./client.mjs";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { version } = require("../package.json");
|
|
9
|
+
|
|
10
|
+
const HELP = `mxprobe ${version}: email verification for AI agents. Verdicts: send, hold, kill.
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
mxprobe check <email>... DNS tier, local, free, no key
|
|
14
|
+
mxprobe check --hosted <email>... send the survivors to the hosted SMTP probe (1 credit each)
|
|
15
|
+
mxprobe check --file list.txt one address per line
|
|
16
|
+
mxprobe check --json ... print the verdict objects as JSON
|
|
17
|
+
mxprobe signup <email> get an API key by mail, with 100 free checks
|
|
18
|
+
mxprobe balance credits left on the key
|
|
19
|
+
mxprobe buy [--packs N] a Stripe Checkout link: 9 USD per 10,000 checks
|
|
20
|
+
mxprobe mcp start the MCP server on stdio
|
|
21
|
+
|
|
22
|
+
Options
|
|
23
|
+
--smtp run the SMTP probe locally too (needs outbound port 25; most laptops and clouds block it)
|
|
24
|
+
--api-url hosted API base (default https://api.mxprobe.dev)
|
|
25
|
+
|
|
26
|
+
Key: MXPROBE_API_KEY in the environment, else ${configPath()}
|
|
27
|
+
Docs: https://mxprobe.dev`;
|
|
28
|
+
|
|
29
|
+
export async function main(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
30
|
+
const { values, positionals } = parseArgs({
|
|
31
|
+
args: argv,
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
options: {
|
|
34
|
+
hosted: { type: "boolean", default: false },
|
|
35
|
+
smtp: { type: "boolean", default: false },
|
|
36
|
+
json: { type: "boolean", default: false },
|
|
37
|
+
file: { type: "string" },
|
|
38
|
+
packs: { type: "string", default: "1" },
|
|
39
|
+
"api-url": { type: "string" },
|
|
40
|
+
help: { type: "boolean", short: "h", default: false },
|
|
41
|
+
version: { type: "boolean", short: "v", default: false },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (values.version) return void stdout.write(`${version}\n`);
|
|
45
|
+
const [command, ...rest] = positionals;
|
|
46
|
+
if (values.help || !command || command === "help") return void stdout.write(HELP + "\n");
|
|
47
|
+
|
|
48
|
+
const cfg = readConfig(env);
|
|
49
|
+
if (values["api-url"]) cfg.apiUrl = values["api-url"].replace(/\/$/, "");
|
|
50
|
+
const client = createClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey });
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
switch (command) {
|
|
54
|
+
case "check": {
|
|
55
|
+
const emails = [...rest];
|
|
56
|
+
if (values.file) {
|
|
57
|
+
emails.push(
|
|
58
|
+
...readFileSync(values.file, "utf8")
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((l) => l.trim())
|
|
61
|
+
.filter((l) => l && !l.startsWith("#")),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (emails.length === 0) {
|
|
65
|
+
stderr.write("usage: mxprobe check [--hosted] [--json] <email>... | --file <list>\n");
|
|
66
|
+
return 2;
|
|
67
|
+
}
|
|
68
|
+
const out = await checkEmails(emails, { hosted: values.hosted, client, smtp: values.smtp });
|
|
69
|
+
if (values.json) {
|
|
70
|
+
stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
71
|
+
} else {
|
|
72
|
+
const width = Math.max(...out.results.map((r) => r.email.length));
|
|
73
|
+
for (const r of out.results) stdout.write(`${r.action.padEnd(4)} ${r.verdict.padEnd(4)} ${r.email.padEnd(width)} ${r.reason}\n`);
|
|
74
|
+
const s = out.summary;
|
|
75
|
+
stderr.write(`\n${s.total} checked: ${s.send} send, ${s.hold} hold, ${s.kill} kill`);
|
|
76
|
+
if (values.hosted) stderr.write(`; ${out.hosted} probed on the hosted tier, ${out.credits_left ?? "?"} credits left`);
|
|
77
|
+
else if (!values.smtp) stderr.write(`. DNS tier only: a send means the domain takes mail, not that the mailbox exists. Add --hosted to probe the mailbox.`);
|
|
78
|
+
stderr.write("\n");
|
|
79
|
+
}
|
|
80
|
+
return out.summary.kill > 0 && emails.length === 1 ? 1 : 0;
|
|
81
|
+
}
|
|
82
|
+
case "signup": {
|
|
83
|
+
const email = rest[0];
|
|
84
|
+
if (!email) {
|
|
85
|
+
stderr.write("usage: mxprobe signup <email>\n");
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
const res = await client.signup(email);
|
|
89
|
+
const path = writeConfig({ api_key: res.api_key, api_url: cfg.apiUrl, email: res.email }, env);
|
|
90
|
+
stdout.write(`Key saved to ${path}. ${res.credits} free checks on it. The key was also mailed to ${res.email}.\n`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
case "balance": {
|
|
94
|
+
const res = await client.balance();
|
|
95
|
+
stdout.write(values.json ? JSON.stringify(res) + "\n" : `${res.credits} credits left on the key for ${res.email} (${res.checks_total} checks so far)\n`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
case "buy": {
|
|
99
|
+
const packs = Math.max(1, Number.parseInt(values.packs, 10) || 1);
|
|
100
|
+
const res = await client.checkout(packs);
|
|
101
|
+
stdout.write(values.json ? JSON.stringify(res) + "\n" : `Pay ${res.amount_usd} USD for ${res.credits} checks here:\n${res.url}\nCredits land on the key as soon as Stripe confirms the payment.\n`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
case "mcp": {
|
|
105
|
+
const { startMcpServer } = await import("./mcp.mjs");
|
|
106
|
+
await startMcpServer({ client, config: cfg, env });
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
default:
|
|
110
|
+
stderr.write(`unknown command "${command}"\n\n${HELP}\n`);
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof ApiError) {
|
|
115
|
+
stderr.write(`${err.status}: ${err.message}\n`);
|
|
116
|
+
if (err.status === 402 && err.body?.checkout_hint) stderr.write(`${err.body.checkout_hint}\n`);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The hosted API client. Also the package's main export, so a Node agent can
|
|
2
|
+
// `import { createClient } from "mxprobe"` without the CLI.
|
|
3
|
+
import { createVerifier, summarize } from "mxprobe-core";
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
constructor(status, body) {
|
|
7
|
+
super(body?.message || body?.error || `HTTP ${status}`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createClient({ apiUrl = "https://api.mxprobe.dev", apiKey = null, fetchImpl = fetch } = {}) {
|
|
14
|
+
const base = apiUrl.replace(/\/$/, "");
|
|
15
|
+
|
|
16
|
+
async function call(method, path, body, { auth = true } = {}) {
|
|
17
|
+
const headers = { "content-type": "application/json", "user-agent": "mxprobe-cli" };
|
|
18
|
+
if (auth) {
|
|
19
|
+
if (!apiKey) throw new ApiError(401, { error: "no_api_key", message: "No API key. Run `mxprobe signup you@company.com` or set MXPROBE_API_KEY." });
|
|
20
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
21
|
+
}
|
|
22
|
+
const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
let json;
|
|
25
|
+
try {
|
|
26
|
+
json = text ? JSON.parse(text) : {};
|
|
27
|
+
} catch {
|
|
28
|
+
json = { error: "bad_response", message: text.slice(0, 200) };
|
|
29
|
+
}
|
|
30
|
+
if (!res.ok) throw new ApiError(res.status, json);
|
|
31
|
+
return json;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
signup: (email) => call("POST", "/v1/signup", { email }, { auth: false }),
|
|
36
|
+
verify: (emails) => call("POST", "/v1/verify", { emails }),
|
|
37
|
+
balance: () => call("GET", "/v1/balance"),
|
|
38
|
+
checkout: (packs = 1) => call("POST", "/v1/credits/checkout", { packs }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The two-tier check the CLI and the MCP server share. The DNS tier runs
|
|
44
|
+
* locally and is free; with `hosted` on, the survivors (send and hold) go to
|
|
45
|
+
* the hosted SMTP probe, one credit each. A DNS kill never costs a credit.
|
|
46
|
+
*/
|
|
47
|
+
export async function checkEmails(emails, { hosted = false, client = null, smtp = false, verifierOptions = {} } = {}) {
|
|
48
|
+
const local = createVerifier({ ...verifierOptions, smtp });
|
|
49
|
+
const results = await local.verifyBatch(emails);
|
|
50
|
+
let hostedCount = 0;
|
|
51
|
+
let creditsLeft = null;
|
|
52
|
+
if (hosted) {
|
|
53
|
+
if (!client) throw new Error("hosted check needs an API client");
|
|
54
|
+
const survivors = results.filter((r) => r.action !== "kill").map((r) => r.email);
|
|
55
|
+
if (survivors.length) {
|
|
56
|
+
const byEmail = new Map();
|
|
57
|
+
for (let i = 0; i < survivors.length; i += 100) {
|
|
58
|
+
const chunk = survivors.slice(i, i + 100);
|
|
59
|
+
const res = await client.verify(chunk);
|
|
60
|
+
for (const r of res.results) byEmail.set(r.email, r);
|
|
61
|
+
creditsLeft = res.credits_left ?? creditsLeft;
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < results.length; i++) {
|
|
64
|
+
const h = byEmail.get(results[i].email);
|
|
65
|
+
if (h) {
|
|
66
|
+
results[i] = h;
|
|
67
|
+
hostedCount++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { results, summary: summarize(results), hosted: hostedCount, credits_left: creditsLeft };
|
|
73
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Where the API key lives: MXPROBE_API_KEY in the environment wins, then
|
|
2
|
+
// ~/.config/mxprobe/config.json (written by `mxprobe signup`).
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_API_URL = "https://api.mxprobe.dev";
|
|
8
|
+
|
|
9
|
+
export function configPath(env = process.env) {
|
|
10
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
11
|
+
return join(base, "mxprobe", "config.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readConfig(env = process.env) {
|
|
15
|
+
let file = {};
|
|
16
|
+
try {
|
|
17
|
+
file = JSON.parse(readFileSync(configPath(env), "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
/* no file yet */
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
apiKey: env.MXPROBE_API_KEY || file.api_key || null,
|
|
23
|
+
apiUrl: (env.MXPROBE_API_URL || file.api_url || DEFAULT_API_URL).replace(/\/$/, ""),
|
|
24
|
+
email: file.email || null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeConfig(patch, env = process.env) {
|
|
29
|
+
const path = configPath(env);
|
|
30
|
+
let file = {};
|
|
31
|
+
try {
|
|
32
|
+
file = JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
/* new file */
|
|
35
|
+
}
|
|
36
|
+
const next = { ...file, ...patch };
|
|
37
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
38
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
39
|
+
return path;
|
|
40
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The MCP server: the same five things the API does, as tools an agent can
|
|
2
|
+
// call. verify_email and verify_batch run the free DNS tier locally and, when
|
|
3
|
+
// a key is configured, send the survivors to the hosted SMTP probe.
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { readConfig, writeConfig } from "./config.mjs";
|
|
9
|
+
import { createClient, checkEmails, ApiError } from "./client.mjs";
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { version } = require("../package.json");
|
|
13
|
+
|
|
14
|
+
const VERDICT_DOC =
|
|
15
|
+
"Each result is { email, action, verdict, reason, checks }. action is send | hold | kill: send means go ahead, hold means send only with a fallback in hand (catch-all, forwarder, greylisted or refused probe), kill means never send (no mail server, or the mailbox does not exist). checks.smtp is skipped on the free DNS tier; the hosted tier probes the mailbox.";
|
|
16
|
+
|
|
17
|
+
function text(obj) {
|
|
18
|
+
return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }], structuredContent: obj };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function failure(err) {
|
|
22
|
+
const body = err instanceof ApiError ? { error: err.body?.error ?? "api_error", status: err.status, message: err.message, ...(err.body?.checkout_hint ? { checkout_hint: err.body.checkout_hint } : {}) } : { error: "failed", message: err.message };
|
|
23
|
+
return { ...text(body), isError: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildMcpServer({ env = process.env } = {}) {
|
|
27
|
+
const server = new McpServer({ name: "mxprobe", version });
|
|
28
|
+
|
|
29
|
+
// Re-read the config on every call so a signup made through the server is
|
|
30
|
+
// picked up without a restart.
|
|
31
|
+
const state = () => {
|
|
32
|
+
const cfg = readConfig(env);
|
|
33
|
+
return { cfg, client: createClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey }) };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
server.registerTool(
|
|
37
|
+
"verify_email",
|
|
38
|
+
{
|
|
39
|
+
title: "Verify one email address",
|
|
40
|
+
description: `Check whether an email address can receive mail before sending to it. ${VERDICT_DOC} Uses the hosted SMTP probe (1 credit) when an API key is configured and hosted is not false; otherwise the free local DNS tier.`,
|
|
41
|
+
inputSchema: { email: z.string().describe("The address to check"), hosted: z.boolean().optional().describe("Probe the mailbox on the hosted tier (default: yes when a key is configured)") },
|
|
42
|
+
},
|
|
43
|
+
async ({ email, hosted }) => {
|
|
44
|
+
try {
|
|
45
|
+
const { cfg, client } = state();
|
|
46
|
+
const useHosted = hosted ?? !!cfg.apiKey;
|
|
47
|
+
const out = await checkEmails([email], { hosted: useHosted, client });
|
|
48
|
+
return text({ ...out.results[0], hosted: useHosted, credits_left: out.credits_left });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return failure(err);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
server.registerTool(
|
|
56
|
+
"verify_batch",
|
|
57
|
+
{
|
|
58
|
+
title: "Verify a list of email addresses",
|
|
59
|
+
description: `Check up to 500 addresses in one call. ${VERDICT_DOC} DNS kills cost nothing; with a key the survivors go to the hosted probe at 1 credit each.`,
|
|
60
|
+
inputSchema: { emails: z.array(z.string()).min(1).max(500).describe("The addresses to check"), hosted: z.boolean().optional().describe("Probe mailboxes on the hosted tier (default: yes when a key is configured)") },
|
|
61
|
+
},
|
|
62
|
+
async ({ emails, hosted }) => {
|
|
63
|
+
try {
|
|
64
|
+
const { cfg, client } = state();
|
|
65
|
+
const useHosted = hosted ?? !!cfg.apiKey;
|
|
66
|
+
const out = await checkEmails(emails, { hosted: useHosted, client });
|
|
67
|
+
return text({ results: out.results, summary: out.summary, hosted: useHosted, credits_left: out.credits_left });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return failure(err);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
server.registerTool(
|
|
75
|
+
"signup",
|
|
76
|
+
{
|
|
77
|
+
title: "Create an MX Probe API key",
|
|
78
|
+
description: "Sign up with an email address. Returns an API key with 100 free checks and saves it locally for the other tools. The key is also mailed to the address. One key per address.",
|
|
79
|
+
inputSchema: { email: z.string().describe("The operator's email address, where the key is mailed") },
|
|
80
|
+
},
|
|
81
|
+
async ({ email }) => {
|
|
82
|
+
try {
|
|
83
|
+
const { cfg, client } = state();
|
|
84
|
+
const res = await client.signup(email);
|
|
85
|
+
const path = writeConfig({ api_key: res.api_key, api_url: cfg.apiUrl, email: res.email }, env);
|
|
86
|
+
return text({ ok: true, email: res.email, credits: res.credits, api_key: res.api_key, saved_to: path, message: res.message });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return failure(err);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
server.registerTool(
|
|
94
|
+
"balance",
|
|
95
|
+
{ title: "Credits left", description: "How many hosted checks are left on the configured API key.", inputSchema: {} },
|
|
96
|
+
async () => {
|
|
97
|
+
try {
|
|
98
|
+
return text(await state().client.balance());
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return failure(err);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
server.registerTool(
|
|
106
|
+
"buy_credits",
|
|
107
|
+
{
|
|
108
|
+
title: "Buy credits",
|
|
109
|
+
description: "Get a Stripe Checkout link for more hosted checks: 9 USD per 10,000, one payment, credits never expire. Open the link to pay; credits land on the key when Stripe confirms.",
|
|
110
|
+
inputSchema: { packs: z.number().int().min(1).max(100).optional().describe("How many packs of 10,000 (default 1)") },
|
|
111
|
+
},
|
|
112
|
+
async ({ packs }) => {
|
|
113
|
+
try {
|
|
114
|
+
return text(await state().client.checkout(packs ?? 1));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return failure(err);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return server;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function startMcpServer(opts = {}) {
|
|
125
|
+
const server = buildMcpServer(opts);
|
|
126
|
+
await server.connect(new StdioServerTransport());
|
|
127
|
+
// The transport keeps the process alive while stdin is open; when the
|
|
128
|
+
// client closes it, in-flight calls finish and the loop drains on its own.
|
|
129
|
+
return server;
|
|
130
|
+
}
|