# MCP server

> Ten tools over Streamable HTTP, with a bearer key. The connection block for every client, then a reference for every tool.

Domduck's MCP server is one of the three surfaces the product ships: a
dashboard for people, an MCP server for agents, and an embeddable badge for
other people's pages.

- **Endpoint** `POST https://domduck.com/api/mcp`
- **Transport** Streamable HTTP. Every answer is the response to a POST; this
  server never opens a stream.
- **Authentication** `Authorization: Bearer ddk_...`. Create a key at
  [https://domduck.com/settings/api-keys](https://domduck.com/settings/api-keys).
- **Rate limit** 240 requests a minute, per key.
- **Scope** Every tool answers for the account that owns the key, and only for
  domains that account already tracks.

There is also a [public server](https://domduck.com/docs/mcp-public) with three read-only tools
and no key at all.

## Connect

### Claude Code

```bash
claude mcp add --transport http domduck https://domduck.com/api/mcp \
  --header "Authorization: Bearer ddk_YOUR_KEY"
```

### Cursor, VS Code, and any client with an mcp.json

```json
{
  "mcpServers": {
    "domduck": {
      "type": "http",
      "url": "https://domduck.com/api/mcp",
      "headers": { "Authorization": "Bearer ddk_YOUR_KEY" }
    }
  }
}
```

### Claude Messages API

```json
{
  "mcp_servers": [
    {
      "type": "url",
      "url": "https://domduck.com/api/mcp",
      "name": "domduck",
      "authorization_token": "ddk_YOUR_KEY"
    }
  ],
  "tools": [{ "type": "mcp_toolset", "mcp_server_name": "domduck" }]
}
```

Send the beta header `anthropic-beta: mcp-client-2025-11-20` with that request.

### Raw JSON-RPC

```bash
curl -s https://domduck.com/api/mcp \
  -H "Authorization: Bearer ddk_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

## Protocol

This server answers both protocol eras on one endpoint.

| Era | Revisions | Shape |
|---|---|---|
| Modern | `2026-07-28` | Stateless. Every request declares its version in `params._meta["io.modelcontextprotocol/protocolVersion"]` and carries the matching `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers. |
| Legacy | `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05` | The `initialize` handshake. |

Which era a request is in is decided by the body, not the header. A request
with no `_meta` version is legacy even when it sends
`MCP-Protocol-Version`, because that header existed in 2025-06-18 too.

`server/discover` is implemented and answers on both paths, with or without
`_meta`, so a probe does not have to know the era first.

Modern requests get the status codes that revision requires: `400` with
`-32022` for a version this server does not implement, `400` with `-32020`
when a header disagrees with the body, and `404` with `-32601` for an unknown
method. Legacy requests answer `200` throughout.

## Errors

A tool that fails returns a normal result with `isError: true` and a plain
sentence, not a JSON-RPC error. That is deliberate: a model can fix its own
arguments and retry, and it cannot fix a protocol error. Read the message.

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "stripe.com is not tracked by this account. Use add_domain first, or list_domains to see what is."
      }
    ],
    "isError": true
  }
}
```

A domain this account does not track is always reported as untracked, never as
non-existent. The distinction would turn this endpoint into a way to ask what
other people are watching.

## Tools

| Tool | Writes | What it does |
|---|---|---|
| [`list_domains`](#list-domains) | no | Every domain this account tracks, with its latest Domain Rating, Tranco rank, tags and observation count. |
| [`get_domain`](#get-domain) | no | The latest reading from every source for one tracked domain: Domain Rating, Tranco rank, registration date, TLS certificate, DNS, AI crawler policy. |
| [`get_history`](#get-history) | no | One reading per day for a domain and source, oldest first. |
| [`get_ai_crawler_policy`](#get-ai-crawler-policy) | no | Which of the eight tracked AI crawlers a domain's robots.txt admits, and whether it publishes an llms.txt. |
| [`add_domain`](#add-domain) | yes | Add a domain to this account and immediately read every cheap source once, so it has values rather than dashes. |
| [`remove_domain`](#remove-domain) | yes | Take a domain off this account. |
| [`check_domain`](#check-domain) | yes | Re-read every cheap source for one tracked domain right now, instead of waiting for the daily sweep. |
| [`set_domain_flags`](#set-domain-flags) | yes | Change how a tracked domain is filed. |
| [`list_tags`](#list-tags) | no | This account's tags with the number of domains carrying each. |
| [`list_sources`](#list-sources) | no | What Domduck reads and what each source means, including the eight AI crawlers whose robots.txt policy is tracked. |

### A worked call

```bash
curl -s https://domduck.com/api/mcp \
  -H "Authorization: Bearer ddk_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"get_history",
                 "arguments":{"domain":"stripe.com","source":"ahrefs_dr","days":30}}}'
```

The result's `content[0].text` is JSON as a string, which is how MCP carries a
structured answer:

```json
{
  "domain": "stripe.com",
  "source": "ahrefs_dr",
  "days": 30,
  "higherIsBetter": true,
  "points": [
    { "day": "2026-07-05", "value": 95 },
    { "day": "2026-07-06", "value": null },
    { "day": "2026-07-07", "value": 95 }
  ]
}
```

**`null` is a day with no reading, and it is not a zero.** Nobody looked that
day. A zero is a value a domain can hold, and on a rank series where lower is
better, reading a gap as zero turns every gap into the best result ever
recorded. Draw a gap, not a point.

### list_domains

Every domain this account tracks, with its latest Domain Rating, Tranco rank, tags and observation count. The place to start: most other tools take a domain from this list.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `tag` | `string` | no | Only domains carrying this tag. |
| `starred` | `boolean` | no | Only starred domains. |
| `sort` | `name`, `dr`, `rank`, `observations`, `added` | no | Sort key. Defaults to name. |
| `dir` | `asc`, `desc` | no |  |

### get_domain

The latest reading from every source for one tracked domain: Domain Rating, Tranco rank, registration date, TLS certificate, DNS, AI crawler policy. Includes how many days of history exist per source and any recent source failures.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |

### get_history

One reading per day for a domain and source, oldest first. This is what the product is for: the series cannot be obtained anywhere else and cannot be backfilled. A null value is a day with no reading, which is a real state and not a zero.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |
| `source` | `ahrefs_dr`, `tranco`, `openpagerank`, `cloudflare_radar`, `robots_txt` | yes | Which series. ahrefs_dr is Domain Rating 0-100, tranco is popularity rank where lower is better, robots_txt is the count of blocked AI crawlers. |
| `days` | `integer` | no | Window length. Defaults to 90. |

### get_ai_crawler_policy

Which of the eight tracked AI crawlers a domain's robots.txt admits, and whether it publishes an llms.txt. Three states per agent: allowed, disallowed, unspecified. Unspecified is NOT allowed, because a catch-all Disallow blocks an agent the file never names, so read `effective` for what is actually in force. Reads the last stored reading; it does not fetch the file.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |

### add_domain

Add a domain to this account and immediately read every cheap source once, so it has values rather than dashes. Unlimited domains, no tier cap. Re-adding one already tracked is a no-op that still refreshes it.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |
| `tags` | `array of string` | no | Optional labels. Tags, not folders, so a domain can carry several. |

### remove_domain

Take a domain off this account. Its observation history is NOT deleted: it keeps accumulating and comes back intact if the domain is added again. Only the list changes.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |

### check_domain

Re-read every cheap source for one tracked domain right now, instead of waiting for the daily sweep. Use it after a change that should move a number, not as a polling loop: the metrics move over weeks.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |

### set_domain_flags

Change how a tracked domain is filed. Pausing keeps the domain and its history but drops it out of the daily sweep. Omitted fields are left alone.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `domain` | `string` | yes | A domain name. Scheme, www and trailing dots are stripped. |
| `starred` | `boolean` | no |  |
| `paused` | `boolean` | no | Paused domains keep their history but stop being re-read. |
| `addTags` | `array of string` | no |  |
| `removeTags` | `array of string` | no |  |

### list_tags

This account's tags with the number of domains carrying each. Tags are per-user and a domain can hold several.
Takes no arguments.

### list_sources

What Domduck reads and what each source means, including the eight AI crawlers whose robots.txt policy is tracked. Useful for interpreting get_domain output without guessing at field names.
Takes no arguments.

