URL: /docs/mcp/tools

---
title: Tools
description: Reference for the nine tools the Revised MCP server exposes — arguments, return shapes and refusal codes.
---

Nine tools, listed in the order an agent should reach for them. Only `reveal_domain` can spend; everything else is free within the [rate limit](/docs/api/rate-limits).

Tool results are JSON, returned as a single text content block. A tool that ran and **refused** returns a result with `isError: true` and this shape:

```json
{
  "error": {
    "code": "QUOTA_EXHAUSTED",
    "message": "Your reveals for this month are used up.",
    "resets_at": "2026-10-01T00:00:00.000Z"
  }
}
```

`code` is UPPER_SNAKE_CASE and is what to branch on. Transport-level failures — `rate_limited`, `insufficient_scope`, `invalid_token` — arrive instead as an HTTP status with no tool result at all; see [MCP authentication](/docs/mcp/authentication#failures).

## `whoami`

Read-only. Free. The right first call in any session: everything else is bounded by these numbers.

Takes no arguments.

<ResponseField name="plan" type="string">`free`, `pro` or `business`. The **owner's** plan, resolved on every request — a lapsed subscription reads as `free` without anything having revoked the credential.</ResponseField>
<ResponseField name="auth" type="string">`oauth` if the person signed in through their browser, `api_key` if a key was configured.</ResponseField>
<ResponseField name="client_id" type="string | null">The OAuth client, on an OAuth session.</ResponseField>
<ResponseField name="key_prefix" type="string | null">The key's public prefix, on a key session.</ResponseField>
<ResponseField name="reveals" type="object">`limit` (null means no ceiling), `remaining`, `used_this_month`, `resets_at`, and `unlimited` — the unambiguous form of `limit: null`, because `null` reads as "unknown" to a careful model and as zero to a careless one.</ResponseField>
<ResponseField name="rate_limit" type="object">`per_minute`, `remaining_this_minute`, and `resets_at` — when the current **fixed** minute window rolls over.</ResponseField>
<ResponseField name="holds" type="object">`limit` (0 means the plan cannot hold) and `window_hours`.</ResponseField>
<ResponseField name="sync_feed" type="boolean">Whether the REST `since` change feed is included on this plan.</ResponseField>

```json Example result
{
  "plan": "pro",
  "auth": "oauth",
  "client_id": "client_2xK9",
  "key_prefix": null,
  "reveals": {
    "limit": 500,
    "remaining": 483,
    "used_this_month": 17,
    "resets_at": "2026-10-01T00:00:00.000Z",
    "unlimited": false
  },
  "rate_limit": {
    "per_minute": 60,
    "remaining_this_minute": 59,
    "resets_at": "2026-09-22T01:48:00.000Z"
  },
  "holds": { "limit": 3, "window_hours": 48 },
  "sync_feed": false
}
```

The rate-limit window is fixed, not sliding, so `remaining_this_minute` can read the same before and after a burst simply because the minute turned over.

## `list_categories`

Read-only. Free, and cached. The right call before the first search.

Takes no arguments.

<ResponseField name="categories" type="array">One entry per code: `code`, `label`, `listings` (how many you can see in it) and `open` (how many of those are on the open tier, whose names cost no reveal).</ResponseField>
<ResponseField name="count" type="integer">Number of codes returned.</ResponseField>

```json Example result
{
  "count": 20,
  "categories": [
    { "code": "saas", "label": "SaaS", "listings": 412, "open": 138 },
    { "code": "education", "label": "Education", "listings": 297, "open": 96 },
    { "code": "fitness", "label": "Fitness", "listings": 88, "open": 31 }
  ]
}
```

The vocabulary is not a closed list and the counts move, so call the tool rather than caching what it returned. The codes are Revised's own and are not guessable from a person's wording: a task about "marketing" or "software" maps onto codes you have to read off this list. `search_domains` **refuses** an unknown code rather than answering with an empty shelf, so a guess costs a round trip and teaches nothing.

Some listings carry no category at all. They are in the directory and every other filter reaches them, but no category search returns them, so they are in none of these counts.

<Note>
  There is no REST equivalent. The REST API has no `/api/v1/categories` endpoint; read codes off the `category` field of a page of results. See [Filters](/docs/api/filters#category).
</Note>

## `search_domains`

Read-only. Free and unlimited within the rate limit. It never spends a reveal, and a listing the caller is not entitled to name comes back fully populated with its metrics and a masked `domain`.

### Arguments

<ParamField path="query" type="string">
Free text over the masked name hint, keyword tags, category and blurb. Maximum 80 characters. **Does not search real domain names** — searching for a name you already have in mind will not find it. The tags and blurb it reads are not returned on a row, so a match cannot be explained from the response.
</ParamField>
<ParamField path="category" type="string">
Category code from `list_categories`, e.g. `saas`. Comma-separate for several. An unknown code is refused, not answered empty.
</ParamField>
<ParamField path="tld" type="string">
Exact TLD without the dot — `com`, `org`, `com.au`. Comma-separate for several.
</ParamField>
<ParamField path="tier" type="string">
One of `open`, `regular`, `featured`. Comma-separate for several. `open` names are published on the Revised website, cost no reveal and may be republished — the cheapest place to start. `regular` and `featured` are disclosed under the API terms and may not be republished.
</ParamField>
<ParamField path="spam" type="string">
Spam band ceiling: `low` or `moderate`.
</ParamField>
<ParamField path="min_revised_score" type="integer">
0 to 100, compared against the **low** end of each listing's published range. Every listing has one, so this excludes nothing for being unknown.
</ParamField>
<ParamField path="min_age_years" type="integer">
0 to 100. Listings whose age is unknown never match; absence is not a zero.
</ParamField>
<ParamField path="min_citability" type="integer">
0 to 100 — how often sources that AI assistants cite link to this domain. Listings with no citability row never match.
</ParamField>
<ParamField path="recommended" type="boolean">
Only listings that clear Revised's own heuristic: a category of any kind (including the literal `other`), at least one recognisable linking site, and a Revised Score floor.
</ParamField>
<ParamField path="sort" type="string" default="featured">
One of `age`, `availability`, `backlinks`, `citability`, `da`, `featured`, `newest`, `price-asc`, `price-desc`, `rd`, `rs`, `snapshots`, `spam`, `value`.

`featured` is the curated shelf. `rs` Revised Score, `rd` referring domains, `da` Domain Authority, `newest` most recently listed, `availability` most recently re-checked, `spam` cleanest first. `value` and `price-desc` are the same sort — estimated value, highest first — and `price-asc` is its reverse. Rows with a null in the sorted field go last. Estimated value and the metric bands each take few distinct values, so expect wide ties; they break on listing id, which is stable but arbitrary.
</ParamField>
<ParamField path="fields" type="string" default="summary">
`summary` returns the fields a shortlist is ranked on and costs roughly a third of a full row — use it for every first pass. `full` returns every field, which at the maximum limit is a five-figure token response.
</ParamField>
<ParamField path="limit" type="integer" default="20">
1 to 50. **Clamped at both ends** rather than refused, so 0 reads as 1 and 500 as 50. Budget roughly 60 tokens a summary row and 230 a full one.
</ParamField>

An argument outside the values listed here is refused with `INVALID_ARGUMENT`, naming the argument and the legal set. Nothing is silently dropped, so a filter that was accepted was applied.

### Result

<ResponseField name="listings" type="array">The matching rows, summary or full per `fields`.</ResponseField>
<ResponseField name="count" type="integer">How many rows you were handed.</ResponseField>
<ResponseField name="total" type="integer">How many matched.</ResponseField>
<ResponseField name="truncated" type="boolean">`count < total`. Stated rather than implied, so a short page is not read as "that is everything".</ResponseField>
<ResponseField name="fields" type="string">Which mode produced these rows.</ResponseField>

```json Example result (summary fields)
{
  "count": 2,
  "total": 91,
  "truncated": true,
  "fields": "summary",
  "listings": [
    {
      "id": "00aLaXEG",
      "domain": "greenfieldfarms.com",
      "name_disclosed": true,
      "tier": "open",
      "tld": "com",
      "revised_score": "61-70",
      "referring_domains": "100-500",
      "domain_authority": "20-30",
      "citability": 47,
      "age_years": 14,
      "category": "education",
      "spam": "low",
      "availability_checked_at": "2026-09-19T08:02:14.771Z",
      "metrics_missing": []
    },
    {
      "id": "X00w46xl",
      "domain": "t▓▓▓▓▓▓labs",
      "name_disclosed": false,
      "tier": "regular",
      "tld": "com",
      "revised_score": "64-70",
      "referring_domains": "50-100",
      "domain_authority": null,
      "citability": null,
      "age_years": null,
      "category": "saas",
      "spam": "low",
      "availability_checked_at": "2026-09-20T11:40:02.118Z",
      "metrics_missing": ["age_years", "agent_citability", "domain_authority"]
    }
  ]
}
```

<Warning>
  **There is no paging.** A large `total` means narrow the filters or change the sort — there is no next page. For a full pass over the directory or for change polling, use [`GET /api/v1/domains`](/docs/api/pagination).
</Warning>

On a summary row, `domain` carries the **public partial mask** when `name_disclosed` is false. `name_disclosed` is the test, not the presence of a mask: a mask is on every row whether or not anything is masked. A mask is something to show a person, never something to hand a registrar.

## `get_domain`

Read-only. Free. One listing, every field — how you read closely the handful of rows a summary search shortlisted.

<ParamField path="id" type="string">
A listing id from a `search_domains` result, e.g. `k3mq7x2p`. Eight characters, case-sensitive.
</ParamField>
<ParamField path="name" type="string">
A registrable domain name, e.g. `example.com`. Resolves **only** for a name you are already entitled to: an open-tier listing, one this account has already revealed, or — on a plan with no reveal ceiling — any listing.
</ParamField>

Any other name reports not-found whether or not it is in the directory: Revised will not confirm that a masked name is listed. A masked row's `domain` is a mask, so passing one back here will not resolve it.

Returns the full listing — the same field set the REST [`GET /api/v1/domains/{id}`](/docs/api/introduction#endpoints) returns, including `backlinks`, `snapshots`, `linked_by`, `agent_citability`, `estimated_value_cents`, `hold_state`, `hold_expires_at`, `mask_hint`, `discovered_at` and `updated_at`.

## `reveal_domain`

<Warning>
  **This is the only tool that spends.** It costs one reveal from the monthly allowance, and the grant is permanent. Check `whoami` and the listing's `tier` before calling it.
</Warning>

Idempotent: calling it again for the same listing is free and returns the same `revealed_at`. It also **records** the reveal, which is what `hold_domain` requires.

<ParamField path="id" type="string" required>
The listing id.
</ParamField>

### Result

The full listing with its real name, plus:

<ResponseField name="revealed_at" type="string">When this account first revealed it.</ResponseField>
<ResponseField name="was_free" type="boolean">Whether this call spent anything.</ResponseField>
<ResponseField name="reason" type="string | null">Present when `was_free` is true: `open_tier` (the name is already public), `already_revealed` (bought earlier) or `unlimited_plan` (the plan has no reveal ceiling).</ResponseField>
<ResponseField name="remaining_reveals" type="integer | null">What is left this month. `null` on a plan with no ceiling.</ResponseField>

```json Example result
{
  "id": "00aLaXEG",
  "domain": "greenfieldfarms.com",
  "name_disclosed": true,
  "tier": "open",
  "revealed_at": "2026-09-22T01:47:10.334Z",
  "was_free": true,
  "reason": "open_tier",
  "remaining_reveals": 483
}
```

The response reports the cost rather than leaving it to be inferred. The REST equivalent does not: `POST /api/v1/domains/{id}/reveal` returns the listing plus `revealed_at` only.

### Refusals

| `code` | Meaning |
| --- | --- |
| `QUOTA_EXHAUSTED` | The month's allowance is gone. The refusal carries `resets_at`; retrying before then will not work. |
| `FEATURED_REQUIRES_PRO` | A free plan against a curated listing. Search with `tier: "open"` instead. |
| `HELD_BY_OTHER` | Another account holds it. The refusal carries no timestamp — read `hold_expires_at` off the listing for when it frees up. Who holds it is never disclosed. |
| `NOT_FOUND` | No such listing, or one that has left the directory. |

## `hold_domain`

Reserve a listing inside Revised so no other account can reveal or take it while someone decides. Costs no reveal, but **requires** that this account has already revealed the listing — a name disclosed by an unlimited plan is not a reveal on record.

<ParamField path="id" type="string" required>
The listing id.
</ParamField>

Returns `hold` (`id`, `listing_id`, `held_at`, `expires_at`, `can_extend`, `extend_hours`) and `holds` (`active`, `limit`, `hold_hours`, `plan`).

```json Example result
{
  "hold": {
    "id": "h_01K6M2QX",
    "listing_id": "00aLaXEG",
    "held_at": "2026-09-22T01:50:00.000Z",
    "expires_at": "2026-09-24T01:50:00.000Z",
    "can_extend": true,
    "extend_hours": 24
  },
  "holds": { "active": 1, "limit": 3, "hold_hours": 48, "plan": "pro" }
}
```

<Warning>
  A hold reserves the listing **in this directory and nowhere else**. It does not register the name, and it does not stop anyone outside Revised registering it. Read `availability_checked_at` and register at a registrar if the name is actually wanted.
</Warning>

Use it when shortlisting for someone else to approve: it is cheaper than revealing repeatedly, and it is the only way to keep a candidate from under another account.

### Refusals

`HOLDS_REQUIRE_PRO` (the plan allows no holds), `REVEAL_REQUIRED` (reveal it first), `HELD_BY_OTHER`, `HOLD_CAP_REACHED` (release one first), `HOLD_COOLDOWN` (your own hold on this listing ended in the last 30 days; `available_at` says when — anybody else may hold it now), `NOT_FOUND`.

## `list_holds`

Read-only. Free. This **account's** holds, never anybody else's.

Takes no arguments.

<ResponseField name="active" type="array">Holds running now. Each carries its `domain`, because holding a listing required revealing it first.</ResponseField>
<ResponseField name="ended" type="array">The most recent holds that have ended, each with `ended_at` and an `outcome` of `expired`, `released` or `registered`.</ResponseField>
<ResponseField name="holds" type="object | null">`active`, `limit`, `hold_hours`, `plan`.</ResponseField>

Holds are account-scoped, not credential-scoped: every key and every OAuth session on the account sees, extends and can end the same holds.

## `extend_hold`

Push one of your holds' expiry back by `extend_hours`, the window the plan allows. Free, and it takes no slot against the cap — the hold is already yours.

<ParamField path="hold_id" type="string" required>
A hold id from `list_holds` or `hold_domain`.
</ParamField>

Returns `hold` and `holds`, as `hold_domain` does.

**Once per hold.** `can_extend` says whether the extension is still on the table; once taken it is false and `ALREADY_EXTENDED` is the answer to asking again. The time is added to `expires_at`, not to now, so an extension taken early loses nothing.

<Note>
  Releasing and re-holding is not an alternative to extending. Your own 30-day cooldown starts the moment a hold ends, and anybody else may take the listing immediately.
</Note>

Refusals: `ALREADY_EXTENDED`, `HOLD_OVER`, `HOLD_NOT_FOUND`, `HOLDS_REQUIRE_PRO`.

## `release_hold`

Destructive. End one of your holds early and give the listing back. Free, and it frees a slot against the cap immediately.

<ParamField path="hold_id" type="string" required>
A hold id from `list_holds` or `hold_domain`.
</ParamField>
<ParamField path="outcome" type="string" default="released">
`released` if you are giving it back, `registered` if you have registered the name yourself. `expired` is not accepted — that outcome belongs to the clock.
</ParamField>

<ResponseField name="ended_at" type="string">When the hold ended.</ResponseField>
<ResponseField name="outcome" type="string">`released` or `registered`, as passed.</ResponseField>
<ResponseField name="registration" type="string | null">Only on a `registered` release, and only when the re-check answered: `available`, `taken` or `unknown`. `unknown` is not the same as available.</ResponseField>
<ResponseField name="holds" type="object">The quota after the release.</ResponseField>

Say `registered` when you have: it triggers a fresh availability check and retires the listing if the name now resolves, which keeps it from being offered to anybody else.

<Warning>
  Irreversible in one respect: once your hold ends you cannot hold **that** listing again for 30 days, while anybody else may take it straight away. Release when the decision is made, not to tidy up — reach for `extend_hold` if you simply need longer.
</Warning>

## Argument refusals

Every tool refuses an out-of-range or out-of-enum argument with `INVALID_ARGUMENT`, naming the argument and listing the legal values, and `INVALID_REQUEST` when a required argument is missing.

```json
{
  "error": {
    "code": "INVALID_ARGUMENT",
    "message": "`tier` must be one of: featured, open, regular.",
    "argument": "tier"
  }
}
```

Nothing is filtered, guessed or silently dropped, so fix the value and call again. A filter that was accepted was applied — which is the point: a wrong answer indistinguishable from a right one is the worst shape a tool result can take.
