ctscout
# ctscout-mcp-server
MCP server for [ctscout.dev](https://ctscout.dev) — **named-entity attribution from Certificate Transparency logs (OV/EV only)**, with optional multi-signal corroboration on Pro. For mapping legal-entity digital footprints, sibling-domain discovery, and SAN-cohort analysis from LLM-driven workflows.
DV-only infrastructure (Let's Encrypt, ZeroSSL, cloud-native shops) is invisible to ctscout by design. See [LIMITATIONS.md](LIMITATIONS.md) for what that means in practice.
Seven tools:
- **`ctscout_search_company`** — find apex domains attributed to an organization by name
- **`ctscout_search_company_batch`** — the same, for up to 10 organization names in one call
- **`ctscout_lookup_domain`** — reverse-lookup the organization attributed to one or more domains
- **`ctscout_submit_deep_dive`** — Pro only: queue an asynchronous multi-signal deep dive (see [Deep dives](#deep-dives-pro-async))
- **`ctscout_get_job`** — poll a deep dive and read its result
- **`ctscout_lookup_lei`** — one LEI's record, or the LEIs under a legal name (see [Research product](#research-product-lei-and-vendor-tools))
- **`ctscout_vendor_customers`** — a vendor's customer counts, and with a key the customer enumeration
The first three work over the public ctscout.dev `/scan` API (the batch tool wraps `/scan/batch`); the deep-dive pair wraps `/jobs`; the last two read the research product objects at `/lei` and `/vendors`. Free tier requires an API key (no email, no signup). A Pro key gets up to 25 rows, a 12-month window and 3,000 lookups a month on `/scan`, and can submit deep-dive jobs, which return a `confidence_band` per attribution with the named signals behind it (DNS brand tokens, RDAP, IP/ASN, homepage metadata, favicon). Visual brand verification (VLM) is not part of v1.
**Not a cyber-risk-scoring tool.** See [LIMITATIONS.md](LIMITATIONS.md) for what ctscout is and isn't, the DV-cert coverage gap, and the corrections path.
Release history: see [CHANGELOG.md](CHANGELOG.md).
---
## Install
For Claude Code, Claude Desktop, Cursor, or any other MCP client. **Two ways to connect:** hosted (recommended, no install) or local npm (this package).
### 1. Get a free API key
Visit [ctscout.dev](https://ctscout.dev) and click "Get a free API key". Solve the Turnstile captcha. Copy the key (you can't recover it later — save it now).
### 2a. Hosted endpoint (recommended — zero install)
All seven tools are hosted at `https://ctscout.dev/mcp`, including `ctscout_lookup_lei` and `ctscout_vendor_customers`. Nothing to install — point your MCP client at the URL with your API key as the `X-API-Key` header.
**Claude Code (CLI)**:
```bash
claude mcp add \
--scope user \
--transport http \
--header "X-API-Key: YOUR_KEY_HERE" \
ctscout https://ctscout.dev/mcp
```
This stores the user-scoped connection in `~/.claude.json`. Run
`claude mcp get ctscout`, then open `/mcp` inside Claude Code to check that
ctscout is connected. See the [official Claude Code MCP guide](https://code.claude.com/docs/en/mcp).
**Cursor** — add the `ctscout` entry to `mcpServers` in
`~/.cursor/mcp.json`, preserving any existing servers and other settings:
```json
{
"mcpServers": {
"ctscout": {
"url": "https://ctscout.dev/mcp",
"headers": { "X-API-Key": "YOUR_KEY_HERE" }
}
}
}
```
Check that ctscout is enabled and connected in Cursor's MCP settings. See
[Cursor's MCP documentation](https://cursor.com/docs/mcp). Clients that only
support legacy SSE can use `https://ctscout.dev/sse` with the same header.
**Claude Desktop** — use the local command configuration below. Desktop's
remote connectors are a separate setup mechanism; this HTTP JSON is not a
`claude_desktop_config.json` entry. See the
[official remote connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp).
### 2b. Claude Desktop or local stdio clients
Claude Desktop can launch this published package as a local stdio process.
It still calls `https://ctscout.dev`, so your network must allow that origin.
Install the current [Node.js LTS](https://nodejs.org/en/download) (Node.js 24 LTS
is supported), including npm. The package supports Node.js 20 or newer.
Verify both commands in a terminal:
```bash
node --version
npm --version
```
You do not need `npm login` or to clone this repository to use the public
package. In Desktop, open **Settings → Developer → Edit Config** and add
`ctscout` under `mcpServers`, preserving existing entries:
```json
{
"mcpServers": {
"ctscout": {
"command": "npx",
"args": ["-y", "ctscout-mcp-server"],
"env": { "CTSCOUT_API_KEY": "YOUR_KEY_HERE" }
}
}
}
```
The Desktop config is at
`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or
`%APPDATA%\Claude\claude_desktop_config.json` on Windows. Fully quit and
restart Desktop, then check that the ctscout tools appear. If Desktop cannot
find `npx`, use its full installed path for `command`; see the
[official local-server setup and troubleshooting guide](https://modelcontextprotocol.io/docs/develop/connect-local-servers).
For Claude Code's local stdio fallback:
```bash
claude mcp add --scope user --env CTSCOUT_API_KEY=YOUR_KEY_HERE \
ctscout -- npx -y ctscout-mcp-server
```
The hosted endpoint is the authoritative MCP contract and is the recommended
path. The stdio package is a compatibility adapter over the same ctscout.dev
API for clients that cannot connect to remote MCP servers yet.
Hosted and stdio expose the same seven public tool names and use the shared
implementation, including the names-only `ctscout_search_company_batch`
contract (1–10 organization names, ordered partial-failure results) and the
deep-dive pair. The hosted adoption shipped in
[ctscout-worker#417](https://github.com/minghsuy/ctscout-worker/pull/417), with
production verification of all seven tools. Hosted connects over HTTP using
`X-API-Key`; stdio runs a local process using `CTSCOUT_API_KEY`.
Both transports advertise an `outputSchema` on every tool and return
`structuredContent` on successful calls. Results include `snapshot` and
`snapshot_source`, except the `ctscout_submit_deep_dive` receipt: it is a job
handle, not a warehouse read, so those fields arrive on `ctscout_get_job`.
On scan and job results, `snapshot` is the warehouse sync date and
`snapshot_source` is
`"scan"` | `"unavailable"`; on `ctscout_lookup_lei` and
`ctscout_vendor_customers` it is the research export's version and the source
vocabulary is `"product"` | `"unavailable"` (see
[Research product](#research-product-lei-and-vendor-tools)) — the two are
different origins on different cadences, so they do not share a label. A failed
call (401, 429, timeout) is an `isError` result with no `structuredContent` at
all, so do not dereference `snapshot` on it.
Since `X-API-Version` 2026-09-05 every `/scan` and `/scan/batch` answer carries
`snapshot`, the warehouse sync date it was read from (the sync runs daily), so
the scan tools report `snapshot_source: "scan"` with that date; a deep-dive
result carries its own (above). `snapshot_source` is `"unavailable"` and
`snapshot` is `null` only when the API sent none, which it does before a fresh
deploy's first sync or when its lookup failed. There is deliberately no
client-side fallback (a separately fetched date is not tied to the scan's
generation). A client must treat a missing or null `snapshot` as unknown, never
as "current". The hosted-side change was
[ctscout-worker#343](https://github.com/minghsuy/ctscout-worker/issues/343).
The stdio package accepts both MCP protocol eras: current clients can discover
the server through the stateless 2026-07-28 `server/discover` flow, while
existing clients keep the 2025 `initialize` handshake.
### 3. Use it
Start with one concrete request in your connected client:
> Use ctscout to find domains attributed to Cloudflare. Show the organization,
> returned apex domains and snapshot date, and explain the OV/EV-only coverage.
Approve the tool call if prompted. The client should call
`ctscout_search_company` and return a tool result with attributed domains or
an explicit no-match result, plus snapshot information when available. Counts
and dates vary with the dataset; an empty result is not proof that an
organization has no domains. A 401 means the key needs checking; a 429 means
its quota or guard was reached. Neither is a successful lookup.
You can then ask who a domain is attributed to or request a batch of company
names. All seven tools listed above remain available through either transport.
---
## Free tier vs Pro tier
The authoritative definition is the product page, <https://ctscout.dev/#tiers>; this table mirrors it.
| | Free | Pro |
|---|---|---|
| Lookups | 10 / day | 3,000 / month included |
| Results | Top 5 | Top 25 |
| History window | Last 90 days | Up to 12 months |
| Deep-dive jobs (async) | — | 20 / day |
| Data | Daily snapshot | Daily snapshot |
| Customer lists | First 100 rows | Full list |
| Price | $0 | $49 / month — [subscribe](https://buy.stripe.com/cNifZg9lddom9rF8iLasg00), the key comes by email within a day |
Answer pages, research notes, open data and the LEI endpoints need no key at all. A lookup is one query to the API or the MCP tools; on both tiers the `snapshot` field names the daily sync date the answer was read from, and a deep-dive result carries `confidence_band` plus the named signals per attribution. The MCP server uses the same API key for both — your tier is determined by the key. If you hit the free quota, the tool returns a 429 error with an upgrade hint. Terms and privacy: <https://ctscout.dev/terms/>.
Pro is paid monthly by card through Stripe and the key is sent by hand to the email on the receipt. Need more than 3,000 lookups a month, or the whole dataset? Email pro@ctscout.dev.
### What the Pro response looks like
`/scan` returns the `(domain, organization, certs, subdomains)` table on both tiers; Pro gets more rows and a longer window. A deep-dive job result replaces it with a richer attribution table you can defend in a meeting:
```
| Domain | Attributed to | Band | Signals | Evidence |
|---|---|---|---|---|
| `coalition.com` | Coalition Inc | ✅ verified | dns_txt_brand_token, og_site_name_match, rdap_registrant_match, +1 | verified via google-site-verification, atlassian-domain-verification |
| `imposter.com` | Coalition Inc | ⚪ insufficient | dns_txt_brand_token | verified via google-site-verification |
```
Bands map to confidence intervals (`verified` ≥ multiple strong independent signals, down to `insufficient` = no signals or signals disagree). The `🚫VLM-veto` tag is reserved for visual brand verification overriding the positive signals; VLM does not run in v1, so it never appears yet. Full structured payload is available via `response_format: "json"`.
## Deep dives (Pro, async)
A deep dive is the full multi-signal attribution run executed by a batch worker
rather than inside the request. It is **asynchronous** and **Pro only**
([ctscout-worker#344](https://github.com/minghsuy/ctscout-worker/issues/344),
contract v1):
1. **`ctscout_submit_deep_dive`** (`POST /jobs`) takes `company_name` and/or
`seed_domain` (max 10, validated exactly like `/scan`) and returns a receipt
immediately: `{job_id, status: "queued", submitted_at, poll}`. Nothing is
attributed yet. A free key gets HTTP 403 with the API's upgrade text; the
quota is 20 submissions per key per day (HTTP 429 over). Submitting is not
idempotent — a retry queues a second job.
2. **`ctscout_get_job`** (`GET /jobs/{id}`) returns
`{job_id, kind, status, submitted_at, started_at, finished_at, result?, error?}`.
`status` is `queued` | `running` | `done` | `failed`; `result` is present
only when `done`, `error` only when `failed`. Polling is read-only and
debits no quota. Job ids are scoped to the submitting key, so an id another
key submitted answers 404 exactly like an unknown id.
**Poll with backoff:** wait about 30 s before the first poll, then back off
toward 5 min between polls. The batch worker picks up queued jobs every few
minutes and a deep dive can take several minutes to run.
**The result** is the deep-dive shape — `domains` with `attributed_to` and an
`enrichment` object per domain, plus `entity`, `run_metadata`, `source`,
`signals_degraded` — and three fields the batch worker adds: `snapshot` (the warehouse date the deep dive read from, present on
every deep-dive result because the worker sets it), `worker_version` and
`signals_attempted`. The markdown output renders the band / signals / evidence
table under the job's status lines (a `/scan` never carries one); `structuredContent`
carries the record with the top-level `snapshot` / `snapshot_source` resolved
from `result.snapshot` (`null` / `"unavailable"` until the job is done).
"Attributed" and "candidate" mean what they mean elsewhere in this package: an
attribution is what the evidence names for a domain, a candidate is a
name-similarity guess — deep dives report attributions with a confidence band,
never bare candidates.
**Not in v1:** visual brand verification (VLM) — `vlm_status` stays `pending`
or `skipped` and never vetoes a band; webhooks, cancellation, retries and
result expiry are follow-ups on the Worker side.
---
## Research product (LEI and vendor tools)
`ctscout_lookup_lei` and `ctscout_vendor_customers` read a different index from
the `/scan` warehouse: precomputed objects published by the ctscout-research
refresh and served by ctscout.dev at `/lei` and `/vendors`
([ctscout-worker#336](https://github.com/minghsuy/ctscout-worker/issues/336)).
The Worker classifies nothing on these routes — it reads one object and returns
it — so both tools are free and debit no quota.
### `ctscout_lookup_lei`
Takes **exactly one** of `lei` or `name`; passing both, or neither, is rejected
before any network call.
- **`{ lei }`** (`GET /lei/{lei}`, ISO 17442: 18 uppercase alphanumerics plus 2
check digits) returns the entity record: `legal_name` and `country` from
GLEIF, `isin_count` (ISINs mapped to the LEI), `apex_count` (apex domains
attributed to it), the `first_seen` / `last_seen` observation window,
`sample_domains` (a hash-chosen sample — **not** a ranking and not a complete
list; `apex_count` is the total. The size is whatever the research export
published: the product contract deliberately does not pin it, so read the
array rather than assuming a number, and the markdown says how many it listed) and `vendors_confirmed`, which is
a list of vendor **slugs** you can pass straight to
`ctscout_vendor_customers`.
- **`{ name }`** (`GET /lei?name=`) returns `{ query, name_match, leis,
lei_count, limit, truncated }`. `leis` is capped at `limit` (20) while
`lei_count` is the total before the cap, so on a truncated answer the two
disagree by design — report `lei_count`, not `leis.length`.
`name_match` is also the discriminator between the two answer shapes: it is
present on the by-name answer and absent from the record.
**`name_match: "none"` does not mean the company has no LEI.** The name index is
keyed by the research normalizer's form of the GLEIF legal name; the two
spellings the route tries (the lowercased, trimmed query and its locale-suffix
normalization) are not that normalizer, so a real entity can miss on a spelling.
Retry with the exact GLEIF legal name, or look it up by LEI. The markdown output
says this in place of the miss.
The answer also carries `normalizer_mismatch`: `null` when the API's normalizer
is the one the index was keyed with, or `{ index, lookup }` naming the two
versions when they differ — then the normalized spellings are not the index's
keys, and a `none` may be a spelling miss under this API rather than an absent
name. The markdown says so in one line above the result; the JSON carries the
field as sent, and the overflow envelope keeps the pair, bounded. An API that
predates the check omits it.
### `ctscout_vendor_customers`
Takes a vendor `slug` and, optionally, `enumerate`.
- **`enumerate: false`** (the default, `GET /vendors/{slug}`) returns the free
summary: `vendor_name`, `vendor_apex` (`null` when the vendor's brand token
matches no label it certifies), the `customers` split, `countries_top`,
`co_use` and `sample_customers` (a hash-chosen sample of the confirmed customers, sized by the export).
- **`enumerate: true`** (`GET /vendors/{slug}/customers`) returns the
enumeration: `confirmed` and `candidates` rows of
`{ apex, attributed_to, lei }`, plus `counts` and `capped`. This route needs
an active ctscout.dev API key (any tier); without one the tool returns a clear
401 message that also points back at the keyless summary.
Free enumeration returns up to 100 rows from each list. `free_slice` records
that allowance and `truncated` says whether the API shortened a list for the
key's tier; Pro returns `free_slice: null` and the full published lists. This
is separate from the research export's `capped` flag and MCP's
`truncation_note`. Both response formats preserve those distinctions, including
when a large answer collapses to its metadata envelope.
**Candidates and confirmed are two different claims and are never summed.** A
candidate is an apex the vendor certified a hostname for — fan-out alone, which
looks identical to an organization certifying hundreds of its own product sites.
Confirmed is the DNS-confirmed subset: *a vendor is confirmed when a hostname it
certified resolves onto a domain it certifies and the customer's own www does
not* (or another organization certifies the apex). Because confirmed is a subset
of candidates, adding the two double-counts; the markdown keeps them in separate
tables and the JSON in separate fields.
Two more fields that read wrong if taken at face value: `co_use[].confirmed`
counts *this* vendor's confirmed customers that the other vendor also certifies
— a candidate there, not a mutual confirmation — and `countries_top` counts
confirmed customers that resolved to an LEI only.
`counts` and `capped` describe the object the research build published: `capped:
true` means the build itself kept a subset of the candidates. If this MCP server
has to drop rows to stay under its own character limit, it writes a separate
`truncation_note` and leaves `counts` and `capped` untouched, so a trimmed list
never reads as a complete enumeration. The markdown and the `structuredContent`
are two renderings of **one** bounded record — same rows, same "N listed of M"
headings, one note — so the half you read can never describe a list the other
half does not show.
### Freshness and the 503
When a response is too large, it collapses through one envelope shared by every
product object kind. That envelope knows for each field whether a list is a
declared sample (partial against a total the record carries) or published
complete, names any list it shortens together with the length the API sent, and
never drops the provenance fields below — so a shortened list cannot read as a
complete one, and a fallback cannot cost you the per-source provenance.
Both tools carry `snapshot` (the export's `as_of`) and `snapshot_source`
(`"product"` when the API reported the version, `"unavailable"` when it did not
— then `snapshot` is `null` and freshness is unknown, never "current"), plus
`snapshot_dates`, the per-source provenance from the manifest (the dated GLEIF,
ISIN, ELF and Wikidata snapshots and the PSL bundle each join read). The markdown
renders both lines. This is a different clock from the `/scan` warehouse's daily
sync: the research export is republished by its own refresh, so these answers
move on that slower cadence.
### No API key needed for the free routes
`/lei` and `/vendors/{slug}` are unauthenticated on ctscout.dev, so
`ctscout_lookup_lei` and `ctscout_vendor_customers` without `enumerate` work
with no `CTSCOUT_API_KEY` set at all — the request carries no `X-API-Key` header
rather than an empty one. The server boots without a key and says on stderr
which tools still work; the other five return the usual key error per call, and
`enumerate: true` returns the same guidance it returns for an invalid key,
without a network round-trip.
Until the refresh has published its first product, `/lei` and `/vendors` answer
HTTP 503 with `{"detail": "Research product not yet published…"}`. Both tools
surface that as a plain "not published yet" error naming the API's own detail —
not as a server outage, and not as an empty result. A 503 carrying any other
body is a different thing entirely (a Worker, proxy or upstream failure) and is
reported as a temporary availability failure to retry shortly, never as an
unpublished product.
---
## What this is, and isn't
ctscout is a digital entity resolution tool — it maps apex domains to organizations attributed in their Certificate Transparency records, corroborated by DNS / RDAP / IP/ASN / homepage / favicon signals in a Pro deep dive.
**It is NOT a cyber-risk quantification platform.** It does not score security posture, predict breaches, or produce risk ratings. See [LIMITATIONS.md](LIMITATIONS.md) for the full disclaimer, coverage gaps, and corrections path.
## Coverage at a glance
ctscout's warehouse is built from OV/EV certificates only — the ones where the issuing CA validated the org's legal identity. **DV-only infrastructure (Let's Encrypt, ZeroSSL, ACME-defaulting cloud hosts) is invisible to the warehouse.**
The warehouse is strongest on: established US/EU enterprise, government, financial services, traditional infrastructure, defense, education.
The warehouse is weak on: modern cloud-native shops (most domains entirely behind Cloudflare/Vercel/Netlify), pre-launch / stealth-mode startups, anything that defaults to DV certs.
When `ctscout_lookup_domain` returns 0 results, the apex isn't in the warehouse — not necessarily that nobody owns it. See [LIMITATIONS.md](LIMITATIONS.md) for the full coverage discussion; current warehouse size is published live at https://ctscout.dev/stats.
---
## Local development
```bash
git clone https://github.com/minghsuy/ctscout-mcp.git
cd ctscout-mcp
npm install
npm run build
# Run the test suite (Vitest, no network)
npm test
# Maintainer-only, non-publishing release preflight
npm run release:check
# Run the server with no key: it boots and warns, and the free /lei and
# /vendors tools work; every other tool errors until a key is configured
node dist/index.js
# With a real key
CTSCOUT_API_KEY=your_key node dist/index.js
# Inspect with the official MCP inspector (browser UI)
npm run inspect
```
### Test the protocol handshake without a real key
```bash
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}},"id":1}' | \
CTSCOUT_API_KEY=fake node dist/index.js
```
Should respond with the server's capabilities + tool registration. (Tool calls themselves require a real key.)
---
## How it relates to ctscout.dev
This MCP server is a local stdio compatibility adapter over the public
ctscout.dev `/scan` API. It does no auth-handling magic or caching — it
translates MCP tool calls into HTTP requests and formats the response for an
LLM consumer. Hosted `https://ctscout.dev/mcp` is the authoritative MCP
surface.
If you're building your own integration in Python or another language, you can hit the same `/scan` endpoint directly. See [ctscout.dev](https://ctscout.dev) for `curl` examples.
---
## License
MIT. See [LICENSE](LICENSE).
The underlying ctscout service uses [domain-scout](https://github.com/minghsuy/domain-scout) (also MIT) for cert log analysis.
### Release CLI compatibility
Maintainer release scripts support npm10 and npm12; npm10.9.8 and npm12.0.2
are exercised with the exact packed-artifact gate. npm12 requires Node
`^22.22.2 || ^24.15.0 || >=26.0.0`; a fresh Node24 LTS installation meets that requirement.
The published MCP runtime still supports Node20+.
The scripts accept npm10's pack array and npm12's package-keyed object, plus
registry scalar strings or singleton arrays. They reject multiple results, unsafe
archive filenames, wrong packages/versions and missing or mismatched gitHead values.
These compatibility checks do not change release authentication or exact-commit
resume requirements. `scripts/release.sh --check <version>` expects an unpublished,
reviewed version; it does not publish, tag, or create a GitHub release.
TDQS
Scored across 7 tools
Each tool targets a clearly distinct resource and operation: forward domain search, reverse domain lookup, batch search, LEI records, vendor customer data, and the async submit/poll pair. The descriptions cross-reference each other with explicit 'Don't use when' guidance that resolves the only potentially confusable pairs (search_company vs lookup_domain, single vs batch, submit vs poll).
Six of seven tools follow the clean `ctscout_<verb>_<noun>` pattern with consistent lowercase verbs and the '_batch' modifier used sensibly. `ctscout_vendor_customers` is the one deviation, using a noun phrase rather than a verb, though it is still readable and unambiguous.
Seven tools is well-scoped for a domain-attribution data service covering forward search, reverse lookup, LEI entity records, vendor relationships, and the deep-dive job lifecycle. Each tool earns its place with no redundant entries and no missing surface that would require a bloated count.
The core workflows are fully covered: forward/reverse lookup, batch search, LEI and vendor research products, and the submit→poll→get-result deep-dive lifecycle. Minor gaps exist — there is no job cancellation or job listing, and vendors are only reachable by slug rather than searchable by name — but agents can work around them without dead ends.