Server data from the Official MCP Registry
Inspect any domain: tech stack, SPF/DKIM/DMARC/MX email health and security posture, for AI agents
About
Inspect any domain: tech stack, SPF/DKIM/DMARC/MX email health and security posture, for AI agents
Remote endpoints: streamable-http: https://mcp.example.com/mcp
Security Report
27 tools verified · Open access · No issues found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Remote servers are capped at 8.0 because source code is not available for review. The score reflects endpoint verification only.
What You'll Need
Set these up before or after installing:
Environment variable: STACKDNS_USAGE_FILE
Environment variable: STACKDNS_DB_FILE
Environment variable: STACKDNS_DNS_SERVERS
Environment variable: STACKDNS_LOG_LEVEL
How to Install & Connect
Available as Local & Remote
This plugin can run on your machine or connect to a hosted endpoint. during install.
Documentation
View on GitHubFrom the project's GitHub README.
stack-and-dns-mcp
A monetized Model Context Protocol server that inspects any public domain for AI agents. Give it a domain and it returns the company's web technology stack, its email deliverability and DNS health (MX / SPF / DKIM / DMARC / MTA-STS / TLS-RPT / BIMI), and its HTTPS security posture, all as strictly-typed JSON with 0-100 scores and remediation-ready findings.
Built for Cursor, Claude Desktop, Claude Code, autonomous SDR bots and research agents. Runs locally over stdio with zero configuration, or as a hosted Streamable HTTP / SSE service that meters usage per API key and can sell prepaid credits through HTTP 402 payment challenges (Stripe hosted checkout or x402 / Machine Payments Protocol stablecoin payments) or API key subscriptions through a self-serve signup page.
Every scan is remembered, so it can also tell you what changed: domain_history shows when a
company adopted or dropped a tool or weakened its DMARC policy, bulk checks work through whole
lead lists (as MCP tools or a CSV-friendly REST API), watched domains raise alerts, by poll or
signed webhook, when their email posture or stack changes, and find_domains_using answers "who
runs Klaviyo?" from a robots.txt-respecting crawl of a domain list you supply.
Contents
- What the tools return
- Quick start (local, stdio)
- Client configuration
- Tool reference
- Hosted mode (HTTP) and monetization
- Configuration reference
- Defensive design
- Scoring model
- Signature database
- Deployment
- Publishing to MCP marketplaces
- Development and testing
- Troubleshooting
What the tools return
| Tool | What it does | Cost |
|---|---|---|
inspect_tech_stack | Fetches the homepage (3 s per attempt, at most 6 s with its TLS and http:// fallbacks, transparent bot UA, redirect and WAF aware) and matches it against 450+ regex signatures covering CMS, e-commerce, JS frameworks, CDNs, hosting, WAFs, analytics, tag managers, marketing automation, CRM, live chat, payments, ad pixels, consent tools and more. Also inspects the TLS certificate and grades security headers. | 1 credit |
verify_email_infrastructure | Live DNS: MX with provider identification, SPF with recursive include expansion and lookup counting, DKIM discovery across 86 common selectors (key type/length), DMARC with org-domain fallback and report-destination authorization, MTA-STS policy fetch, TLS-RPT, BIMI, disposable-domain detection. Returns severity-sorted risks and a deliverability score. | 1 credit |
comprehensive_audit | Runs both concurrently and aggregates an overall health score (email 40 %, security 35 %, infrastructure maturity 25 %) with merged findings and highlights. | 2 credits |
domain_history | Returns what earlier scans recorded about a domain without scanning again: current and past technologies with first-seen and last-seen dates, the latest email posture, and a dated change log (tool added or removed, DMARC weakened, DKIM key lost, MX provider changed). Available when storage is enabled. | 1 credit |
start_bulk_check + get_bulk_check, list_bulk_checks, cancel_bulk_check | Checks a whole lead list (email, tech or audit per domain) as a background job with paged results and CSV export. Also available as a REST API. | per accepted domain, at the single-domain price; polling is free |
watch_domain + get_alerts, list_watches, unwatch_domain | Re-checks watched domains on a schedule and raises alerts when their email posture or technology stack changes, readable with get_alerts or pushed to a signed webhook. | included in plans; prepaid callers pay per scheduled check |
find_domains_using + list_technologies | Reverse lookup: which domains run a technology (Shopify, Klaviyo, HubSpot...), from a polite background crawl of an operator-supplied domain list. | 2 credits per page; listing is free |
Every successful result is delivered twice: as pretty-printed JSON in content[0].text (for
clients that only read text) and as structuredContent validated against a Zod output schema.
Failures never crash the server; they come back as isError results with no structuredContent,
carrying { "error": { "code", "message", "retryable", "details" } } in content[0].text and in
_meta.error. Arguments that do not match a tool's input schema (an empty domain, a wrong type, a
missing field) and unknown tool names are rejected by the MCP SDK before the tool runs, with a
plain-text isError result (MCP error -32602: ...) that has no error object and is not charged;
fix the arguments rather than retrying unchanged.
Example (abbreviated) comprehensive_audit highlights for a real store:
Platform: Shopify on Cloudflare (10 technologies detected).
Analytics & tags: Elevar, Google Tag Manager, RudderStack.
Go-to-market stack: Attentive.
Security: B (81/100); TLS valid, HSTS on, CSP off.
Email: Google Workspace; SPF fail; DKIM found; DMARC p=reject (score 92/100, A).
Quick start (local, stdio)
Requirements: Node.js 22.13+. Stored scans, the shared credit ledger and every feature built on
them (domain_history, bulk checks, monitoring, reverse lookup) use the built-in node:sqlite
module. When it cannot be loaded, or the database cannot be opened, the server still starts with
the live inspection tools only and logs why.
git clone https://github.com/Mewnah/stack-and-dns-mcp.git
cd stack-and-dns-mcp
npm install
npm run build
Try it without any MCP client:
npm run smoke -- shopify.com # comprehensive audit
npm run smoke -- shopify.com --tech # tech stack only
npm run smoke -- shopify.com --email # email infrastructure only
Open it in the MCP Inspector:
npm run inspector
Or run the server directly (stdio is the default transport):
node dist/index.js
Logs always go to stderr as JSON lines; stdout is reserved for the MCP protocol.
Client configuration
The usage file and the database default to ./data/usage.json and ./data/stackdns.db, relative
to the directory the server is started in. MCP clients usually start servers somewhere else (an
application folder, /, whichever project is open), so the examples below set both to absolute
paths. Otherwise the data lands in a different place per client, or storage is switched off with
only a log warning when that directory is not writable.
Cursor
A project-level file is included at .cursor/mcp.json. Cursor runs
project servers from the workspace root, so the relative paths work after npm run build.
For a global install use absolute paths, with a checkout or with the published package:
{
"mcpServers": {
"stack-and-dns": {
"command": "node",
"args": ["/absolute/path/to/stack-and-dns-mcp/dist/index.js"],
"env": {
"STACKDNS_LOG_LEVEL": "warn",
"STACKDNS_USAGE_FILE": "/absolute/path/to/stack-and-dns-mcp/data/usage.json",
"STACKDNS_DB_FILE": "/absolute/path/to/stack-and-dns-mcp/data/stackdns.db"
}
}
}
}
{
"mcpServers": {
"stack-and-dns": {
"command": "npx",
"args": ["-y", "stack-and-dns-mcp"],
"env": {
"STACKDNS_LOG_LEVEL": "warn",
"STACKDNS_USAGE_FILE": "/absolute/path/to/stack-and-dns-data/usage.json",
"STACKDNS_DB_FILE": "/absolute/path/to/stack-and-dns-data/stackdns.db"
}
}
}
}
Claude Desktop
Edit claude_desktop_config.json
(%APPDATA%\Claude\claude_desktop_config.json on Windows,
~/Library/Application Support/Claude/claude_desktop_config.json on macOS).
A Windows example is in examples/claude_desktop_config.json.
{
"mcpServers": {
"stack-and-dns": {
"command": "node",
"args": ["D:\\Projects\\MCP\\stack-and-dns-mcp\\dist\\index.js"],
"env": {
"STACKDNS_LOG_LEVEL": "warn",
"STACKDNS_USAGE_FILE": "D:\\Projects\\MCP\\stack-and-dns-mcp\\data\\usage.json",
"STACKDNS_DB_FILE": "D:\\Projects\\MCP\\stack-and-dns-mcp\\data\\stackdns.db"
}
}
}
}
macOS / Linux:
{
"mcpServers": {
"stack-and-dns": {
"command": "node",
"args": ["/Users/you/stack-and-dns-mcp/dist/index.js"],
"env": {
"STACKDNS_LOG_LEVEL": "warn",
"STACKDNS_USAGE_FILE": "/Users/you/stack-and-dns-mcp/data/usage.json",
"STACKDNS_DB_FILE": "/Users/you/stack-and-dns-mcp/data/stackdns.db"
}
}
}
}
Restart Claude Desktop; the tools appear under the tools icon: the three inspection tools always,
plus domain_history, the four bulk-check tools and the four monitoring tools when storage is
available (12 in all), and find_domains_using / list_technologies when a crawl seed file is set.
Claude Code
claude mcp add stack-and-dns \
-e STACKDNS_USAGE_FILE=/absolute/path/to/stack-and-dns-mcp/data/usage.json \
-e STACKDNS_DB_FILE=/absolute/path/to/stack-and-dns-mcp/data/stackdns.db \
-- node /absolute/path/to/stack-and-dns-mcp/dist/index.js
Remote deployment:
claude mcp add --transport http stack-and-dns https://mcp.example.com/mcp --header "Authorization: Bearer sdm_live_..."
Metering a local stdio install with a customer key
A stdio server never contacts a hosted deployment; to use a hosted server's key, connect over HTTP
as in the remote example above. STACKDNS_API_KEY only selects one of the keys configured in the
same process (STACKDNS_API_KEYS or STACKDNS_API_KEYS_FILE in the same env block), so a local
install can be metered and rate-limited under that key's plan. Without such keys the variable is
ignored (the server logs a warning at startup) and the server runs in open local mode, unless
STACKDNS_REQUIRE_AUTH=true, in which case every tool call answers UNAUTHORIZED. With keys
configured but STACKDNS_API_KEY unset, or set to a key that matches none (that case logs a
warning), the process runs as an anonymous free caller: the other tools work within the free plan's quota, and the bulk-check and
monitoring tools answer UNAUTHORIZED.
Tool reference
The tools that take a domain (the three inspection tools, domain_history and watch_domain)
accept it as a bare hostname (example.com), a URL (https://www.example.com/pricing) or an
email address (jane@example.com); start_bulk_check takes a list of the same forms. Scheme,
path, port, query and a leading www. (for email checks) are stripped; IDN names are punycoded.
An empty or whitespace-only domain fails the input schema (see above), not INVALID_DOMAIN.
inspect_tech_stack
{ "domain": "allbirds.com", "includeRawHeaders": false, "includeEvidence": true }
Key output fields:
| Field | Description |
|---|---|
fetch | Final URL, status, redirect chain, protocol, timings, HTTPS downgrade / TLS error flags, structured error when unreachable |
blocked | { blocked, vendor, challengeType, reason } for Cloudflare, Akamai, Imperva, DataDome, HUMAN, AWS WAF, Kasada, Vercel, Sucuri, F5 challenges |
partial | true when the fetch failed, was blocked, or the body was truncated |
technologies[] | { id, name, category, categoryLabel, website, confidence, version, implied, evidence[] } |
categories | Category id to detected names |
summary | cms, ecommerce, framework, cdn, hosting, webServer, tagManager, analytics[], marketingAutomation[], crm[], payments[], liveChat[], advertising[], waf[] |
security | score, grade, breakdown, full tls inspection, headers report (HSTS, CSP, XFO, XCTO, Referrer-Policy, Permissions-Policy, COOP/CORP, cookie flags, version leaks), findings[] |
warnings[] | Human-readable caveats (blocked, truncated, downgraded, redirected off-domain) |
billing | { principal, plan, creditsCharged, creditsRemaining, requestsRemainingThisMinute, requestsRemainingToday }, on every tool's result. creditsCharged is the prepaid credits this call itself charged: 0 for plan keys, which spend daily quota instead, and 0 for start_bulk_check, whose up-front charge is in job.creditsCharged / job.quotaUnitsCharged (creditsRemaining already reflects it). requestsRemainingToday is the daily quota left after the call, including the units a bulk submission charges (one per accepted domain). |
verify_email_infrastructure
{ "domain": "example.com", "dkimSelectors": ["mycustomselector"], "checkMtaSts": true }
| Field | Description |
|---|---|
mx | Sorted exchanges with resolved addresses, provider (Google Workspace, Microsoft 365, Proofpoint, Mimecast, Zoho, and 60+ more), Null-MX and implicit-A detection |
spf | Raw record, parsed mechanisms/modifiers, terminal all policy (inherited through redirect=), recursive includeChain, DNS lookup count vs the 10-lookup limit, void lookups, detected sending platforms. allPolicy also reads none when the policy sits behind a redirect= target whose lookup failed (allQualifier null, redirect set, SPF_DNS_ERROR in risks): the policy is then unknown, not absent |
dkim | Every probed selector with key type, bit length, testing/revoked flags and provider hints |
dmarc | Policy, subdomain policy, effective policy, pct, alignment, rua/ruf destinations with external-authorization checks, enforcement level. inheritedFromOrganizationalDomain: true together with DMARC_DNS_ERROR in risks means a subdomain's own _dmarc lookup failed: the record and policies shown are the organizational domain's, and the subdomain's own policy is unknown (scored like p=none) |
mtaSts, tlsRpt, bimi | Transport policy records; the MTA-STS policy file is fetched and checked against the MX hosts |
disposable | Match against a curated list of 469 throwaway-mail providers |
risks[] | Severity-sorted { severity, code, message, recommendation } |
score, grade, scoreBreakdown | 0-100 deliverability score with per-component points |
summary | One-line human summary. A policy that a failed lookup left unknown reads unknown (SPF policy unknown (redirect=<target> lookup failed), DMARC unknown (own _dmarc lookup failed; organizational p=<x>)), and the risk counts in it then leave out the organizational record's DMARC findings (riskCounts keeps every finding) |
comprehensive_audit
{ "domain": "hubspot.com", "dkimSelectors": [], "includeRawHeaders": false, "includeEvidence": false }
Returns scores (overall, grade, email, security, infrastructure, weights and the
infrastructure breakdown), the full techStack and email results (or techStackError /
emailError if one side failed), merged findings, findingCounts, agent-friendly
highlights[] and a summary sentence. The summary sentence's counts leave out the
organizational record's DMARC findings when a subdomain's own _dmarc lookup failed;
findingCounts counts every finding.
domain_history
{ "domain": "example.com", "changesLimit": 50 }
Reads stored scans only; it never contacts the domain. Every completed scan (a call to one of the
three inspection tools, a bulk-check item, a scheduled monitor check or a reverse-lookup crawl) is recorded in the database
named by STACKDNS_DB_FILE, keyed by the site with any leading www. removed. A call that was
cancelled or hit its deadline is not recorded, because its lookups may have been cut short. Stored
scans never record which caller requested them.
| Field | Contents |
|---|---|
known | false when the domain has never been scanned here; the other fields are then empty |
technologies[] | { id, name, category, version, implied, active, firstSeen, lastSeen, removedAt } |
email | Latest posture: score, grade, MX provider, SPF and DMARC policy, live DKIM selectors, MTA-STS mode, critical and high risk codes |
changes[] | Newest first: { kind, change, subject, oldValue, newValue, severity, at }, where change is one of tech_added, tech_removed, mx_provider, mx_added, mx_removed, null_mx, spf_added, spf_removed, spf_policy, spf_broken, spf_fixed, dmarc_added, dmarc_removed, dmarc_policy, dkim_added, dkim_lost, mta_sts_mode, risk_new, risk_resolved, score_drop, score_rise |
recentScans[] | The last 20 scans with source (call, bulk, monitor or crawl), time, success flag, score and grade |
The first usable scan of a domain is a baseline and records no changes. A technology or DKIM selector must be missing from two consecutive clean scans before it is reported as removed, so a lazily loaded tag does not flap. Website scans that were blocked or failed are kept for the record but never change the stored state.
Email components are tracked separately: the MX records, whether the exchanges resolve, the SPF record, the SPF include tree, DMARC, MTA-STS, TLS-RPT/BIMI, and each DKIM selector. Each one updates, and is compared, only when its own lookups answered:
- A component whose lookup failed (SERVFAIL, a timeout, a policy host that answered 5xx or 408, a
bot challenge) keeps its last known value and reports no change. The components that did answer
in the same scan are still compared, so a failed include never hides a
+allin the domain's own record. - A definitive answer is a real change: an MTA-STS policy that returns 404, a policy host that does not exist (confirmed with our own resolver), or an invalid certificate.
- A failure that persists for three scans spanning at least a day is accepted as the answer,
because receivers see the same failure: exchanges that never resolve become
MX_ALL_UNRESOLVABLE, a policy host that never answers removes the MTA-STS mode. Nothing stays frozen forever. The streak only grows while the same lookup keeps failing, so a single timeout of something else is still held. - The score only moves when every lookup answered or its failure was accepted as persistent; only
a scan whose lookups all answered is stored with
ok: true. While a subdomain's own_dmarclookup fails, the stored score keeps the subdomain's last known DMARC points (the live score scores the unknown policy likep=none), so a timeout neither raises nor lowers it. - DKIM risks and the score are compared only between scans that probed the same selectors. A key under a custom selector that one call passed and a bulk scan did not probe never reads as added or lost. Every email scan (calls, bulk items, scheduled checks) also probes every selector already known for the domain.
- Records are evaluated in a fixed order, so a domain that publishes two SPF or DMARC records does not flip policy between scans because resolvers return them in a different order.
- A subdomain whose own
_dmarclookup fails is never credited with the organizational domain's policy: the policy readsunknown(the live score scores it likep=none), and the organizational record's findings are not taken as the subdomain's (the one-linesummarydoes not count them either;riskCountsstill does). The subdomain's own DMARC stays inunverifieduntil its own lookup has answered once, and that first answer is a baseline, not a change. A subdomain that simply has no record of its own still inherits the organizational policy. When the lookup of an SPFredirect=target fails, the stored and bulk policy readsunknowntoo, notnone, and the findings that describe the terminalall(SPF_PASS_ALL,SPF_NEUTRAL_ALL,SPF_SOFTFAIL_ALL) keep their last known state. The liveverify_email_infrastructurefieldspf.allPolicystill readsnonein that case (its enum has nounknown);SPF_DNS_ERROR, the score note and the summary (SPF policy unknown (redirect=<target> lookup failed)) flag it. - SPF whose
redirect=chain has never answered whole (the target or a laterredirect=hop failed) stays inunverified(withspfLookups) only until that failure is accepted as persistent. The domain's own record did answer, so from then on it is compared as usual while its policy readsunknown: a second record raisesSPF_MULTIPLE_RECORDS; deleting the record, or an accepted failure of its own TXT lookup, isspf_removed; and a record that stops handing its policy to that target (anotherredirect=target, or anallof its own, which overridesredirect=) is compared like any other change. A deletion, an accepted lookup failure and a record that stops delegating each end the wait for the target, so whatever follows is compared as usual too. A second record ends it only when the record evaluated (the first in sorted order) no longer delegates to the target. Only what theredirect=chain holds was never read whole. While the record still delegates to the target, the first scan in which the whole chain answers is the baseline of the terminal policy and the findings that describe it (nospf_policy,risk_neworrisk_resolvedfor them). The first such scan in whichspfLookupsis established too (the include tree answers, or an include's failure has been accepted) is the include tree's baseline: nospf_broken,spf_fixed,risk_neworrisk_resolvedfor validity, lookup counts or void lookups (SPF_VOID_LOOKUPS,SPF_TOO_MANY_LOOKUPS,SPF_CIRCULAR_INCLUDE). That is a later scan than the policy's when an include lookup failed in the policy's scan, and it still reports a policy change made in between. Until the whole chain answers, the first scan in which the target answered but a later hop's failure was accepted is a baseline for the tree read so far (if the target already answered in the scan that took SPF out ofunverified, that scan was this baseline); later such scans are compared with it. Each of these baselines covers the score too: noscore_droporscore_risein its scan, even for a score change another component caused. Findings of the domain's own record (such asSPF_MULTIPLE_RECORDSorSPF_SYNTAX_ERROR) and changes to other components are still reported in that scan.
Weakened policies (DMARC reject to none, SPF -all to ~all) are high, and an MTA-STS
downgrade is medium; strengthened ones are info. An unknown policy ranks with none:
reject or -all becoming unknown is high, and none becoming unknown is info. A policy
read again after an unknown stretch is ranked against the policy last actually read before it,
not against unknown: the same policy coming back (even +all or a malformed DMARC record) is
info, while quarantine coming back as none, or -all as +all, is high. A move away from
unknown with no policy read before it is info. In email, score is null until a scan in
which every lookup answered or its failure was accepted as persistent. unverified lists
components not yet established: no lookup has answered them and their failure has not been
accepted as persistent (DMARC and SPF behind redirect= follow the rules above). lookupsFailing
lists those whose latest lookups failed (their fields are the last known values, or the current
reading once the failure was accepted as persistent), so an empty unverified does not mean every
lookup answered: an accepted failure is listed in lookupsFailing. States stored by earlier
versions are re-baselined silently on their next scan, with no change records: an organizational
DMARC policy or its findings stored for a subdomain whose own lookup failure had been accepted, an
SPF policy stored as none behind an accepted failed redirect= target, and a stored score from
before SPF void-lookup and circular-include permerrors cost points, when the findings stored with
that score and the scan that next rewrites it both carry such a permerror (a permerror that first
appears after the upgrade is reported as a score_drop as usual). When a scan sets a DMARC or SPF
baseline but cannot rewrite the score because another lookup failed, the next scan that rewrites
the score re-baselines it silently.
Bulk checks: start_bulk_check, get_bulk_check, list_bulk_checks, cancel_bulk_check
{ "check": "email", "domains": ["acme.com", "https://www.globex.com/about", "jane@initech.com"] }
check is email (verify_email_infrastructure), tech (inspect_tech_stack) or audit
(comprehensive_audit). The job is queued at once and worked through in the background by a
small pool shared fairly by every job, so one large list cannot starve a small one; jobs survive
restarts. start_bulk_check returns the job id, the inputs that were skipped (invalid, private,
or a duplicate of an earlier site) and what was charged. Poll get_bulk_check with the job id: it
returns status, per-status counts and one page of results (offset, limit up to 500,
optional status filter such as failed). Each result is a compact summary: score, grade, MX
provider, SPF and DMARC policy, DKIM found and high risks for email; reachability, HTTP status,
security grade, CMS, e-commerce, CDN and technology names for tech; the four scores and top
highlights for audit. In email summaries the SPF and DMARC policy can also read missing (no
record) or unknown (the lookup failed, a redirect= target's lookup failed, or a subdomain's own
_dmarc lookup failed), the DMARC policy invalid (a record without a valid policy), and the MX
provider unknown (the MX lookup failed) or null MX (no mail). Jobs are private to the caller
that submitted them, so bulk checks (and monitoring) need an identified caller (an API key, a
prepaid session, or a stdio server's own local identity): anonymous callers on a server with
STACKDNS_REQUIRE_AUTH=false share an address-based identity and are refused.
Billing happens once, when the job is accepted: prepaid callers pay the single-domain price per
accepted domain (email 1, tech 1, audit 2 credits), and plan keys spend one unit of their daily
quota per domain, all or nothing. Items that fail for a retryable reason (a timeout, a resolver
outage) and items cancelled before they ran are refunded when the job finishes. A definitive
answer such as DOMAIN_NOT_FOUND is a completed check and is charged. The polling, listing and
cancelling tools are free and never touch the daily quota. Limits: STACKDNS_BULK_MAX_DOMAINS
per job (default 1000) and STACKDNS_BULK_MAX_ACTIVE_JOBS unfinished jobs per caller (default 3).
A list may carry up to twice STACKDNS_BULK_MAX_DOMAINS raw entries; a longer list, or one with
more distinct domains than the maximum, is refused with LIMIT_EXCEEDED (details.maxDomains),
over MCP as over REST. Only a list over max(50000, 2 x STACKDNS_BULK_MAX_DOMAINS) entries fails
start_bulk_check's input schema first, which gives the plain-text -32602 result. A list with more
invalid or duplicate entries than max(100, accepted domains) is refused with INVALID_ARGUMENT,
since skipped entries are stored too. A prepaid session whose balance a job spent to zero can still read
and cancel its jobs, even when its client keeps re-sending the payment header it paid with; only
paid calls and new job submissions (POST /v1/jobs or start_bulk_check) answer with a new
payment challenge.
Monitoring: watch_domain, get_alerts, list_watches, unwatch_domain
{ "domain": "acme.com", "checks": ["email", "tech"], "webhookUrl": "https://hooks.example.com/stackdns" }
A watched domain is re-checked every STACKDNS_MONITOR_INTERVAL_HOURS (default 24), once per
domain however many callers watch it. A check that fails for a retryable reason (a timeout, a
resolver outage) is retried after 30 minutes (or the interval, if shorter) and its prepaid charge
is refunded; a definitive answer such as NXDOMAIN is charged, raises one domain_not_found alert
and waits a full interval. Every change the scan store records (see
domain_history) becomes an alert for each watcher: DMARC or SPF weakened, a DKIM key lost, the
MX provider changed, a technology added or removed, a score drop, or the domain disappearing from
DNS. Changes found by any scan reach watchers, including a direct call or a bulk job. A new watch
only hears about changes from then on. Unwatching and watching again keeps the existing schedule,
so it never forces an early check.
get_alerts returns unread alerts oldest first and marks them read (markRead: false to peek,
sinceId and nextSinceId to page). With a webhookUrl (https only, a fully qualified public host
name or public address, no redirects followed) each batch is also POSTed as JSON:
{ "type": "domain.changes", "watchId": "watch_...", "domain": "acme.com", "sentAt": "...",
"alerts": [{ "id": 42, "kind": "email", "change": "dmarc_policy", "subject": "dmarc", "oldValue": "reject", "newValue": "none", "severity": "high", "at": "..." }] }
The request carries x-stackdns-signature: t=<unix seconds>,v1=<hex>, where v1 is the
HMAC-SHA256 of <t>.<raw body> keyed with the webhookSecret returned once when the watch is
created. The timestamp is taken when the request is sent, so verify it and reject timestamps
older than a few minutes. A delivery that fails is retried after 1 minute, 5 minutes, 30 minutes,
2 hours and 12 hours, then marked failed; the alert stays readable through get_alerts either
way, with a short reason ("timed out", "DNS lookup failed", "HTTP 503"). Deliveries run beside the
scheduled checks, at most one request at a time per account, so a slow endpoint delays only its
own owner's alerts, and accounts take turns watch by watch within each delivery round, so neither
one account's backlog nor a few slow receivers hold up everyone else's webhooks.
Plans include a number of watched domains (free 5, pro 100, enterprise 2000, payg 500,
override with STACKDNS_MONITOR_WATCH_LIMITS_JSON). Prepaid callers pay
STACKDNS_MONITOR_CREDITS_PER_CHECK (default 1) per scheduled check. When the balance runs out
the watch pauses with a watch_paused alert, and it resumes by itself once the balance covers a
check again. Self-serve accounts' watches follow the subscription: they pause (plan_inactive)
while it is cancelled or unpaid, the newest beyond a downgraded plan's limit pause
(over_plan_limit), and they resume when the plan allows them again. When the subscription is
deleted for good (including after its key was already suspended for failed payments), its
watches are removed, up to 200 per round across all such accounts, since no key can manage them
any more; until then they stay paused and their pending webhook deliveries are cancelled, even a
delivery that is in flight at that moment. Watching, listing and reading alerts are free.
Monitoring needs an identified caller (an API key, a prepaid session, or a stdio server's own
local identity); watches that anonymous callers created on earlier versions are removed by the
schema upgrade.
Reverse lookup: find_domains_using, list_technologies
{ "technology": "Klaviyo", "activeOnly": true, "seenWithinDays": 90, "limit": 50 }
Returns domains that run a technology, most recently seen first, with the detected version and
first-seen and last-seen dates, plus nextCursor for the next page. technology is a signature
id or name; an ambiguous name returns INVALID_ARGUMENT with the matching candidates in
details.candidates. list_technologies browses the catalogue (filter by query or category)
with how many domains currently use each technology.
The searchable universe is the crawl list, not the internet. An operator points
STACKDNS_CRAWL_SEED_FILE at a file of domains (one per line, or the first column of a CSV,
re-read when it changes), and a background crawler works through it at
STACKDNS_CRAWL_RATE_PER_MINUTE (default 20), re-visiting each domain every
STACKDNS_CRAWL_REFRESH_DAYS (default 30). For each domain it:
- reads
https://<domain>/robots.txtfirst and skips the site when the group for our user agent (StackAndDnsBot, also matched when a group names it with a version such asStackAndDnsBot/1.0), or the*group when no group names it, does not allow/(RFC 9309: a group naming us overrides*, the rule with the most octets wins, rules and paths are compared in one percent-encoded form, soDisallow: /über/also covers/%C3%BCber/, and a literal*or$is named by its escape,%2Aor%24); - fetches only the homepage, with the same transparent User-Agent, 3-second per-attempt deadline
(6 s at most) and SSRF guard as
inspect_tech_stack, and checks every URL the fetch requests (a redirect, thehttp://fallback) against the robots.txt of that URL's origin first; each origin's file is downloaded once per crawl, and the time spent reading it counts neither against the homepage's deadline nor against the time left for the insecure-TLS retry and thehttp://fallback; when HTTPS fails and robots.txt refuses thehttp://fallback, the domain is recorded as unreachable and its last error names both the HTTPS failure (its first 300 characters) and the refusal; - records the result like any other scan, so crawled domains also build up
domain_historyand can raise monitoring alerts; - treats a robots.txt that answers 5xx or 429, or cannot be fetched, as "not now": the site is skipped, keeps its data and is retried on the failure schedule (after 1 hour, doubling);
- waits a full refresh before retrying a domain that does not exist, and removes a site's data
from the reverse lookup (and from
universe.scanned) when its robots.txt opts out or the domain stops existing.
The reverse lookup answers only from what the crawler itself observed, for domains on the crawl
list. Customers' calls, bulk jobs and monitor scans never appear there (not even their timing for
a crawl-list domain), so the tool cannot reveal anyone's prospect list. A changed seed file is
applied once it has stopped changing for one round, so a file being rewritten is never read half
written, and in small batches with pauses between them, so even a list of millions of domains
neither stalls the server nor holds up other processes' writes to the database. The list is parsed
into a private in-memory SQLite database and compared with the crawl targets there. That database
is attached only while the sync runs (about 200 MB for 5 million domains) and freed when it ends;
the list is never held in the JavaScript heap. At most STACKDNS_CRAWL_MAX_TARGETS distinct valid
domains are taken from the file, in file order. universe.domains and universe.scanned in each
answer say how large the searchable set is; GET /healthz reports crawl progress. Without a seed
file the two tools are not offered.
Error codes
| Code | Retry? | Meaning |
|---|---|---|
INVALID_DOMAIN | no | Input is not a resolvable hostname (IP literal, single label, bad characters) |
PRIVATE_TARGET | no | Reserved TLD or the host resolves to loopback / private / link-local / metadata space |
DOMAIN_NOT_FOUND | no | NXDOMAIN |
NETWORK_ERROR | yes | DNS resolution failed at the resolver level |
RATE_LIMITED | yes | Plan quota exceeded; details.retryAfterMs says when (a concurrency rejection carries details.maxConcurrent instead; retry when an in-flight call finishes) |
INSUFFICIENT_CREDITS | after payment | Prepaid balance exhausted |
UNAUTHORIZED | no | Server requires an API key, or a bulk-check or monitoring tool was called anonymously (they need an identified caller: an API key, a prepaid session, or a stdio server's own local identity) |
TOOL_TIMEOUT | yes | Tool exceeded STACKDNS_TOOL_TIMEOUT_MS |
CANCELLED | yes | The client cancelled the request before it completed; nothing was charged |
INVALID_ARGUMENT | no | A bulk, monitoring, reverse-lookup or REST argument is malformed (no valid domains, unknown check, bad webhook URL, an unknown or ambiguous technology with details.candidates, a cursor not returned by a previous call, an unknown ?status) |
NOT_FOUND | no | No bulk check with that id exists for this caller |
LIMIT_EXCEEDED | when retryable | A plan or server limit was reached: too many domains in one job or too many watches (no), or too many unfinished jobs (yes, once one finishes) |
INTERNAL_ERROR | yes | Unexpected failure (logged server-side) |
These codes come from the tools themselves. Arguments that fail a tool's input schema (an empty or
whitespace-only domain included) never reach it: the SDK answers with a plain-text
MCP error -32602: ... result that carries no code, so such input never yields INVALID_DOMAIN.
Payment redemption over HTTP answers 503 payment_recording_failed when a payment verified (or
settled) but could not be recorded yet; retrying the same credential is safe and never pays twice.
For x402 the 503 carries X-Session-Token (and sessionToken in the body) for the principal the
credits will land on: keep it. Any process sharing the database finishes the recording in the
background, and until then that token is answered 503, never a new payment demand. A retry of
the same X-PAYMENT header completes the recording too, but it never hands out a token again: a
settled authorization is public on-chain, so presenting it proves nothing about who paid. The
503's principal gets nothing when the credits go to another presentation of the same
authorization (with or without from) instead: one that settled earlier, even one first answered
unconfirmed, or one a sibling process recorded first because it could not see this settlement
(see Any Node host). A retry of that 503's header
then answers 409 payment_already_redeemed.
Documentation truncated — see the full README on GitHub.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Rea
Freeby Morluto · Developer Tools
Reverse engineer anything from your terminal or agent with one CLI and MCP server.
Paperclip
Freeby Paperclipai · Developer Tools
Trending hip-hop artist momentum scores across four cultural dimensions.
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
