# MCP server

Connect Claude, Cursor or any MCP client: the Streamable HTTP endpoint, the stdio bridge, and the nine tools.

Arettic is an MCP server. Claude Desktop, Claude Code, Cursor or your own agent can ask which tool works for a task, buy the result, and read the receipt through nine tools. This page covers the two ways to connect, the client configs, every tool with its arguments, a typical session, test mode, errors, and the server card.

> Before launch, agent keys go to design partners; everyone else can [join the waitlist](/waitlist). The `@arettic/mcp` npm package is published at launch. The hosted endpoint and the tools on this page are the ones the package will forward to.

## What you need

An agent key. Test keys start with `sk_test_` and are free: they buy from mock tools and use no credits. Live keys start with `sk_live_` and buy real results with your org's credits. Org keys (`ok_…`) are for the org API and don't work here, because the MCP tools act as an agent.

To get a key, sign in to the dashboard, create an org, and add an agent in test mode. The key is shown once. The [quickstart](/docs/quickstart) walks through it, and [Authentication and keys](/docs/authentication) explains the three kinds of credential.

## Two ways to connect

The hosted server speaks Streamable HTTP at `https://api.arettic.com/mcp`. A client that supports Streamable HTTP with a custom header connects to it directly. A client that only runs local stdio servers uses the bridge, `@arettic/mcp`, a small Node program that forwards every `tools/list` and `tools/call` to the hosted server.

Both paths give the same tools with the same names, descriptions and schemas, because the bridge defines none of its own. Pick the hosted endpoint when you can send a header; pick the bridge when your client wants a command to run.

## The hosted endpoint

Send JSON-RPC messages as `POST https://api.arettic.com/mcp`. Three headers matter:

- `Authorization: Bearer sk_test_…` (or `sk_live_…`). Without it the answer is HTTP 401 with the error code [unauthenticated](/docs/errors#unauthenticated). An org key is refused the same way.
- `Content-Type: application/json`.
- `Accept: application/json, text/event-stream`. The MCP transport requires both media types; without them the answer is HTTP 406.

The server is stateless: it builds a fresh session for every POST. There is no `Mcp-Session-Id` header, no `initialize` handshake is needed before `tools/call`, and every request carries the key. Responses are plain JSON, never a stream. `GET /mcp` and `DELETE /mcp` answer HTTP 405 with `Allow: POST` and the error code [method_not_allowed](/docs/errors#method_not_allowed), because there are no server-initiated streams and no sessions to end.

Each key can make 600 requests a minute to `/mcp`. Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`; over the limit you get HTTP 429 with `Retry-After`. See [Rate limits and quotas](/docs/rate-limits).

**curl**

```bash
curl https://api.arettic.com/mcp \
  -H "Authorization: Bearer $ARETTIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"recommend","arguments":{"task_type":"find_email"}}}'
```

**Response (example)**

```json
{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\n  \"task_type\": \"find_email\",\n  \"region\": \"GLOBAL\",\n  \"sort\": \"score\",\n  \"test_mode\": true,\n  …\n}"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 1
}
```

Every tool answers with one `text` content item, and that text is JSON: the same body the matching REST endpoint returns. Parse it. When the API refused the call, the result also carries `isError: true` and the text is the API's error body (see [Errors and retries](#errors)). `tools/list` returns the nine tools with their JSON Schemas.

## The stdio bridge

`@arettic/mcp` runs as a child process of your MCP client. It reads your key from the `ARETTIC_API_KEY` environment variable, connects to the hosted server, and forwards every `tools/list` and `tools/call`. It needs Node 18 or newer. The package is published to npm at launch; until then the command below has nothing to download.

**The command a client runs**

```bash
ARETTIC_API_KEY=sk_test_… npx -y @arettic/mcp
```

Environment variables the bridge reads

| Variable | Required | Default | What it does |
|---|---|---|---|
| ARETTIC_API_KEY | yes |  | An agent key: `sk_test_…` or `sk_live_…`. |
| ARETTIC_API_URL | no | `https://api.arettic.com` | The API host. The bridge talks to `/mcp` on it. |

If the key is missing, is an org key, or the API refuses it, the bridge writes one line to stderr saying what to do and exits with code 1. It never prints the key. If the API is merely unreachable at startup, the bridge still starts and retries on the first call. All logging goes to stderr; stdout carries only MCP messages.

### Use the bridge from code

The package exports `createStdioProxy`, so you can embed the bridge in your own process or serve it on another MCP transport. Options: `apiKey` (required), `baseUrl` (default `https://api.arettic.com`), `timeoutMs` (default 5 minutes per request), `log` (a function; default stderr) and `fetch`.

**TypeScript**

```ts
import { createStdioProxy } from "@arettic/mcp";

const proxy = createStdioProxy({ apiKey: process.env.ARETTIC_API_KEY! });
await proxy.start(); // serves on stdin/stdout; pass any MCP Transport to serve elsewhere
```

## Client configs

Each block below carries a placeholder key; paste your own. The stdio blocks need Node on the machine that runs the client. A test key is the safe default: swap in a live key when you're ready to spend credits.

### Claude Desktop

Open Settings, then Developer, then Edit Config, and add this to `claude_desktop_config.json`. Claude Desktop starts the bridge for you.

**claude_desktop_config.json**

```json
{
  "mcpServers": {
    "arettic": {
      "command": "npx",
      "args": [
        "-y",
        "@arettic/mcp"
      ],
      "env": {
        "ARETTIC_API_KEY": "sk_test_…"
      }
    }
  }
}
```

### Claude Code

**Terminal**

```bash
# The stdio bridge
claude mcp add arettic --env ARETTIC_API_KEY=sk_test_… -- npx -y @arettic/mcp

# Or the hosted endpoint directly
claude mcp add --transport http arettic https://api.arettic.com/mcp --header "Authorization: Bearer sk_test_…"
```

Or commit a `.mcp.json` to your project with the same `mcpServers` block as the Claude Desktop config, so the whole team gets the server.

### Cursor

Add the server to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project). Either the bridge:

**mcp.json (bridge)**

```json
{
  "mcpServers": {
    "arettic": {
      "command": "npx",
      "args": [
        "-y",
        "@arettic/mcp"
      ],
      "env": {
        "ARETTIC_API_KEY": "sk_test_…"
      }
    }
  }
}
```

or the hosted endpoint:

**mcp.json (hosted endpoint)**

```json
{
  "mcpServers": {
    "arettic": {
      "url": "https://api.arettic.com/mcp",
      "headers": {
        "Authorization": "Bearer sk_test_…"
      }
    }
  }
}
```

### Other clients

Any client that supports Streamable HTTP with a custom header can use `https://api.arettic.com/mcp` with `Authorization: Bearer <agent key>`; most accept a `url` plus `headers` block like the Cursor one. A client that can only launch local servers uses the `command` block: the bridge works with any host that starts stdio servers.

## Call it from code

Your own agent can talk to the hosted endpoint with any MCP client library, or with plain HTTP. Both samples below run recommend, then execute, then fetch the receipt when there is one. If you'd rather skip MCP, the [SDKs](/docs/sdks) call the REST API directly.

**TypeScript (@modelcontextprotocol/sdk)**

```ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(new URL("https://api.arettic.com/mcp"), {
  requestInit: { headers: { Authorization: `Bearer ${process.env.ARETTIC_API_KEY}` } },
});
const mcp = new Client({ name: "my-agent", version: "1.0.0" });
await mcp.connect(transport);

// Every tool answers with one text item holding JSON.
const body = (r: { content?: unknown }) => JSON.parse((r.content as { text: string }[])[0].text);

const rec = await mcp.callTool({ name: "recommend", arguments: { task_type: "find_email" } });
const top = body(rec).options.find((o: { purchasable: boolean }) => o.purchasable);

const bought = await mcp.callTool({
  name: "execute",
  arguments: {
    tool_id: top.tool_id,
    input: { first_name: "Emily", last_name: "Carter", domain: "example.com" },
  },
});
const outcome = body(bought);
if (bought.isError) throw new Error(`${outcome.error.code}: ${outcome.error.message}`);
console.log(outcome.status, outcome.result); // passed { email: "emily.carter@example.com", … }

if (outcome.receipt_id) {
  // Live keys only: test purchases write no receipt.
  const receipt = await mcp.callTool({
    name: "get_receipt",
    arguments: { receipt_id: outcome.receipt_id },
  });
  console.log(body(receipt));
}
await mcp.close();
```

**Python (standard library)**

```python
import json
import os
import urllib.request

API = "https://api.arettic.com"
KEY = os.environ["ARETTIC_API_KEY"]


def call_tool(name, arguments, rpc_id=1):
    body = json.dumps({
        "jsonrpc": "2.0",
        "id": rpc_id,
        "method": "tools/call",
        "params": {"name": name, "arguments": arguments},
    }).encode()
    req = urllib.request.Request(
        f"{API}/mcp",
        data=body,
        headers={
            "Authorization": f"Bearer {KEY}",
            "Content-Type": "application/json",
            "Accept": "application/json, text/event-stream",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=300) as res:
        result = json.load(res)["result"]
    data = json.loads(result["content"][0]["text"])
    if result.get("isError"):
        raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
    return data


rec = call_tool("recommend", {"task_type": "find_email"})
top = next(o for o in rec["options"] if o["purchasable"])
bought = call_tool("execute", {
    "tool_id": top["tool_id"],
    "input": {"first_name": "Emily", "last_name": "Carter", "domain": "example.com"},
}, rpc_id=2)
print(bought["status"], bought.get("result"))
if bought.get("receipt_id"):  # live keys only
    print(call_tool("get_receipt", {"receipt_id": bought["receipt_id"]}, rpc_id=3))
```

In the Python sample an HTTP-level refusal (401, 405, 406, 429) raises `urllib.error.HTTPError`; a tool error comes back inside the result and raises the `RuntimeError`. The MCP SDK client behaves the same way: transport failures throw, tool errors come back as results with `isError`.

## The nine tools

Tool names, argument names and descriptions come from the hosted server, and `tools/list` returns them with JSON Schemas. Each tool runs the same code as its REST endpoint, so its answer has the same fields; the pages linked below describe those fields one by one. Money is in credits: 1 credit is $0.001, and every amount comes back as `{ "credits": "38", "usd": "0.038" }`.

The nine tools and their REST equivalents

| Tool | What it does | Same as |
|---|---|---|
| list_task_types | The task types, each with its pass rule, input and output. | `GET /v1/task-types` |
| recommend | Ranked tools for a task: score, every score input, price per success, pass rule. | `POST /v1/recommend` |
| get_tool | One tool in detail: scores by region, history, latest benchmark, schemas. | `GET /v1/tools/{id}` |
| execute | Run a tool (one input, or up to 1,000 as inputs; over 25 runs as a job); charged only if the result passes its check. Test keys use mock providers. | `POST /v1/execute` |
| get_job | Status and per-item results of a batch of more than 25 items. | `GET /v1/jobs/{id}` |
| get_receipt | The receipt for a purchase: cost, check, outcome and refund. | `GET /v1/receipts/{id}` |
| open_dispute | Dispute a charged item within 7 days (by receipt_id and item_index). Decided within 48 hours against the stored result; upheld means a refund. | `POST /v1/disputes` |
| get_approval | Status of a purchase waiting for approval. | `GET /v1/approvals/{id}` |
| get_balance | The agent's budget and the org's credit balance. | `GET /v1/balance` |

### list_task_types

No arguments. Returns `task_types`, one entry per task type with `task_type`, `pass_rule` (for example `find_email@v1`), `passes_if`, `input` and `output`, plus the open-data `license`. The same list is on [Pass rules](/docs/pass-rules).

### recommend

Ranked tools for one task. Send `task_type` or a plain-language `task`. Ranking is by score, then price when scores are within 2 points; whether a tool can be bought never changes its rank, it's the `purchasable` field. Up to 10 options come back. Every field is described on [Recommend](/docs/recommend).

Arguments of recommend

| Argument | Type | Required | Notes |
|---|---|---|---|
| task_type | string | one of task_type or task | One of `find_email`, `verify_email`, `enrich_company`, `enrich_person`, `web_search`, `extract_url`. |
| task | string, up to 500 characters | one of task_type or task | Plain language, mapped to a task type by a keyword classifier. The answer says what it chose in `mapped_from_task`. If nothing matches, the error is [unknown_task_type](/docs/errors#unknown_task_type). Each free-text call uses one of your plan's daily free-text lookups. |
| region | string, up to 10 characters | no | A region code such as `US`; default `GLOBAL`. Tools that cover `GLOBAL` always match. |
| max_price | number | no | Most credits per success you'll accept. Costlier tools are left out. |
| min_score | number, 0 to 100 | no | Tools scoring below this are left out. |
| sort | string | no | `score` (default), `price`, `value` or `latency`. Within 2 points of score, price breaks the tie. |

**MCP tool call**

```json
{ "name": "recommend", "arguments": { "task_type": "find_email" } }
```

**Response (example, test key)**

```json
{
  "task_type": "find_email",
  "region": "GLOBAL",
  "sort": "score",
  "test_mode": true,
  "formula_version": "v1",
  "ranking": "score, then price when scores are within 2 points; purchasability never changes rank",
  "options": [
    {
      "tool_id": "mock-find-email",
      "name": "Mock Email Finder",
      "provider": "Arettic Mock Provider",
      "score": 56.53,
      "score_week": "2026-09-28",
      "score_inputs": { "A": 0.4902, "S": 0.5958, "P": 0.4902, "R": 0.7225, "L": 1, "D": 0 },
      "sample_size": 10,
      "price_per_success": { "credits": "38", "usd": "0.038" },
      "success_rate": 0.5958,
      "p50_latency_ms": 5,
      "pass_rule": "find_email@v1",
      "purchasable": true,
      "regions": ["GLOBAL", "US"]
    }
  ]
}
```

With a `task` instead of a `task_type`, the answer adds `mapped_from_task` with `from`, `confidence` and `alternatives`. A live key sees real tools here; a test key sees the mock ones.

### get_tool

One tool in detail. The only argument is `tool_id` (a string of up to 80 characters: the id from `recommend`, such as `mock-find-email`). Returns `tool` (name, provider, task type, regions, `purchasable`, `price_per_success`, `prices_by_plan`, the `pass_rule` with its text, the `input` and `output` schemas, `test_mode`, `live_calls_available`), `scores` by region with every score input, `score_history`, and `latest_benchmark`. Unknown ids answer [unknown_tool](/docs/errors#unknown_tool). See [Public data](/docs/public-data) and [Scores](/docs/scores).

### execute

Buys a result. The tool is run, the result is checked against the task type's published pass rule, and you're charged only if it passes. The full flow, every status and every field are on [Execute](/docs/execute).

Arguments of execute

| Argument | Type | Required | Notes |
|---|---|---|---|
| tool_id | string, up to 80 characters | yes | From `recommend`. |
| input | object | one of input or inputs | One input in the task type's input shape (see `list_task_types`). |
| inputs | array of objects, up to 1,000 | one of input or inputs | Up to 25 run now and answer with per-item results. More than 25 answer `status: "queued"` with a `job_id` for `get_job`. With a test key every size runs now. |
| max_price | number | no | The most you'll pay in total, in credits. If the price (price per success times inputs) is above it, nothing runs: [price_above_max](/docs/errors#price_above_max). |
| fallback | boolean | no | If an item fails, try the next-ranked tool once. Both attempts go on the receipt. |
| approval_id | string, up to 40 characters | no | From a `status: "approval_required"` answer, once an owner has approved it. |
| idempotency_key | string, up to 200 characters | no | The same key with the same request returns the same answer and never charges twice. The bridge adds one (`mcp-…`) when you don't send one. |

**MCP tool call**

```json
{
  "name": "execute",
  "arguments": {
    "tool_id": "mock-find-email",
    "input": { "first_name": "Emily", "last_name": "Carter", "domain": "example.com" }
  }
}
```

**Response (example, test key, passed)**

```json
{
  "test_mode": true,
  "tool_id": "mock-find-email",
  "task_type": "find_email",
  "check_version": "v1",
  "charged": { "credits": "0", "usd": "0.000" },
  "status": "passed",
  "result": { "email": "emily.carter@example.com", "verification_status": "valid" },
  "would_have_charged": { "credits": "38", "usd": "0.038" }
}
```

**Response (example, test key, failed)**

```json
{
  "test_mode": true,
  "tool_id": "mock-verify-email",
  "task_type": "verify_email",
  "check_version": "v1",
  "charged": { "credits": "0", "usd": "0.000" },
  "status": "failed",
  "reason": "status:unknown",
  "would_have_charged": { "credits": "0", "usd": "0.000" }
}
```

The `status` of one input is `passed`, `partial` (charged pro rata, with a `reason`) or `failed` (a `reason`, no `result`, nothing charged). A live key also gets `execution_id`, `receipt_id`, `refunded` and the real `charged` amount. A batch of up to 25 answers `status: "completed"` with a `summary` and per-item `items`. Two answers ask you to come back later: `status: "queued"` (with `job_id`, `items`, `max_charge`, `poll` and a `message`) when the batch is over 25 items, and `status: "approval_required"` (with `approval_id`, `reason`, `amount`, `expires_at` and a `message`) when the purchase is over the agent's approval threshold or monthly budget.

### get_job

The only argument is `job_id`. Returns `job_id`, `status` (`queued`, `running`, `done`, `failed` or `expired`), `items`, `progress`, `created_at`, `started_at` and `finished_at`. Once the job has its receipt the answer also carries `receipt_id`, `tool_id`, `task_type`, `check_version`, `charged`, `refunded`, `summary` and the per-item `items` with results. A job you didn't start answers [not_found](/docs/errors#not_found). See [Batches and jobs](/docs/jobs).

### get_receipt

The only argument is `receipt_id`. Returns the receipt: what was bought, from which provider, what it cost, the result's hash, the check and its version, the outcome and the refund, per item. Any agent of the org can read the org's receipts. Receipts never change; refunds from upheld disputes are added on top. Every field is on [Receipts](/docs/receipts).

### open_dispute

Disputes a charged item. Only charged items can be disputed, within 7 days of the purchase, one dispute per item. Arettic decides within 48 hours against the stored copy of the result; a dispute not decided in time is upheld, and an upheld dispute refunds the item's charge. Test keys can't dispute: there was no charge. See [Disputes](/docs/disputes).

Arguments of open_dispute

| Argument | Type | Required | Notes |
|---|---|---|---|
| receipt_id | string, up to 40 characters | yes | The receipt the item is on. |
| item_index | integer, 0 or more | no | Which item on the receipt. Default 0. |
| reason | string | yes | `wrong_result`, `invalid_result`, `stale_result`, `duplicate_charge` or `other`. |
| evidence | string, up to 4,000 characters | when reason is other | What's wrong with the result. |

Returns `dispute_id`, `status` (`open` at first), `receipt_id`, `item_index`, `execution_id`, `tool_id`, `reason`, `evidence`, `charged`, `refunded`, `opened_at`, `decide_by` and `decided_at`. An item that wasn't charged answers [not_disputable](/docs/errors#not_disputable); one older than 7 days answers [dispute_window_closed](/docs/errors#dispute_window_closed).

### get_approval

The only argument is `approval_id`, from an `approval_required` answer. Returns `approval_id`, `status` (`pending`, `approved`, `rejected`, `expired` or `used`), `reason` (`over_threshold` or `over_budget`), `agent`, `tool`, `items`, `amount`, `expires_at`, `decided_at` and `decision_note`. Approvals expire 24 hours after they're requested. Poll it until the status changes, then call `execute` again with the same `tool_id` and input plus the `approval_id`. See [Budgets and approvals](/docs/budgets-and-approvals).

### get_balance

No arguments. Returns the org's balance and the agent's monthly budget.

**Response (example)**

```json
{
  "org_balance": {
    "paid": { "credits": "0", "usd": "0.000" },
    "trial": { "credits": "0", "usd": "0.000" },
    "total": { "credits": "0", "usd": "0.000" }
  },
  "agent_spent_month": { "credits": "0", "usd": "0.000" },
  "agent_budget": { "credits": "50000", "usd": "50.000" },
  "agent_budget_left": { "credits": "50000", "usd": "50.000" }
}
```

## A typical session

Three calls: `recommend` to pick a tool, `execute` to buy the result, `get_receipt` to see what happened. A test key covers the first two exactly as shown; the receipt needs a live key.

1. Call `recommend` with the task type. Take the first option whose `purchasable` is true and keep its `tool_id` and `price_per_success`.
2. Call `execute` with that `tool_id` and one `input`. Read `status`: `passed` gives `result` and `charged`; `failed` gives `reason` and charges nothing. If the answer is `approval_required`, poll `get_approval` until it's `approved`, then call `execute` again with the `approval_id`. If it's `queued`, poll `get_job` with the `job_id`.
3. With a live key the answer includes `receipt_id`. Call `get_receipt` with it. The receipt and `get_balance` agree: the balance drops by exactly `charged`.

**Step 1: MCP tool call**

```json
{ "name": "recommend", "arguments": { "task_type": "verify_email" } }
```

**Step 2: MCP tool call**

```json
{
  "name": "execute",
  "arguments": { "tool_id": "mock-verify-email", "input": { "email": "jason@acme.com" } }
}
```

**Response (example, test key)**

```json
{
  "test_mode": true,
  "tool_id": "mock-verify-email",
  "task_type": "verify_email",
  "check_version": "v1",
  "charged": { "credits": "0", "usd": "0.000" },
  "status": "passed",
  "result": { "email": "jason@acme.com", "status": "valid" },
  "would_have_charged": { "credits": "6", "usd": "0.006" }
}
```

With a live key, `recommend` ranks the real verify_email tools and `execute` charges the price of the one you chose. The answer then carries the ids you need for the receipt:

**Response (example, live key)**

```json
{
  "status": "passed",
  "execution_id": "0f6a9d2c-5b1e-4c7a-9e3d-2a8b7c6d5e4f",
  "receipt_id": "7c2e4b9a-1d3f-4a5b-8c6d-9e0f1a2b3c4d",
  "tool_id": "hunter-verify-email",
  "task_type": "verify_email",
  "check_version": "v1",
  "charged": { "credits": "7", "usd": "0.007" },
  "refunded": { "credits": "0", "usd": "0.000" },
  "result": { "email": "jason@acme.com", "status": "valid" }
}
```

**Step 3: MCP tool call**

```json
{ "name": "get_receipt", "arguments": { "receipt_id": "7c2e4b9a-1d3f-4a5b-8c6d-9e0f1a2b3c4d" } }
```

**Response (example, live key)**

```json
{
  "receipt_id": "7c2e4b9a-1d3f-4a5b-8c6d-9e0f1a2b3c4d",
  "created_at": "2026-09-29T10:15:07.412Z",
  "tool_id": "hunter-verify-email",
  "job_id": null,
  "idempotency_key": "mcp-2b6d0f2e-0c4b-4a8e-9d1f-3c5e7a9b1d2f",
  "request_hash": "…",
  "check_version": "v1",
  "charged": { "credits": "7", "usd": "0.007" },
  "refunded": { "credits": "0", "usd": "0.000" },
  "summary": { "items": 1, "passed": 1, "partial": 0, "failed": 0 },
  "items": [
    {
      "index": 0,
      "execution_id": "0f6a9d2c-5b1e-4c7a-9e3d-2a8b7c6d5e4f",
      "tool_id": "hunter-verify-email",
      "provider": "hunter",
      "outcome": "captured",
      "charged": { "credits": "7", "usd": "0.007" },
      "refunded": { "credits": "0", "usd": "0.000" },
      "pricing_mode": "per_success",
      "input_hash": "…",
      "result_hash": "…",
      "check_version": "v1",
      "settled_at": "2026-09-29T10:15:07.398Z"
    }
  ]
}
```

The three hashes are SHA-256 hex digests of the request, the item's input and the result, shortened here. The `idempotency_key` is the one the bridge generated; a call over the hosted endpoint without a key shows `null` there. Ids, times and prices are examples; live prices are on [Pricing](/pricing).

## Test mode

With a test key, `recommend` and `execute` see the mock tools: one per task type, ids starting with `mock-`. No provider is called and no credits move. The answer carries `test_mode: true`, `charged` of zero, and `would_have_charged`: what a live purchase of the same result would have cost. The input decides the outcome, so you can rehearse both paths:

Inputs that make a mock tool fail on purpose

| Task type | Input | Outcome |
|---|---|---|
| any | any field contains `provider-error` | Provider error: `status: "failed"`, `reason: "provider_error"`, nothing charged. |
| any | any field contains `nomatch` | No result: fails. |
| find_email | `domain` ends in `.catchall.test` | Catch-all address: fails with `reason: "catch_all"`. |
| verify_email | `email` starts with `unknown` | Unknown status: fails with `reason: "status:unknown"`. |
| enrich_company | `domain` is `missing-fields.test` | Industry missing: fails. |
| enrich_person | `company_domain` contains `nocontact` | No contact field: fails. |
| web_search | `query` contains `few results` | 2 of 5 results: `partial`, charged pro rata. |
| extract_url | `url` contains `blocked` | Captcha page: fails. |

Everything else passes. For example, `mock-verify-email` passes `jason@acme.com` and fails `unknown@acme.com`; `mock-find-email` returns `emily.carter@example.com` for Emily Carter at `example.com`. Batches work in test mode too and always run inline, up to 1,000 inputs, so a test key never gets a `job_id`. Test purchases write no receipt, so there is no `receipt_id` to look up, and `open_dispute` answers [invalid_input](/docs/errors#invalid_input) ("Test-mode purchases are free; there's nothing to dispute"). Approvals only happen for live keys, so `get_approval` has nothing to show either.

To go live, create a live agent in the dashboard and swap the key. Nothing else changes. The full test-mode reference, including how mock tools are scored, is on [Test mode](/docs/test-mode).

## Errors and retries

### Tool errors

When the API refuses a call, the tool result has `isError: true` and its text is the API's error body: `code`, `message`, `doc_url` and `retryable`. `doc_url` points at the entry for that code on [Error codes](/docs/errors); `retryable` says whether trying again can help. A rate limit adds `retry_after_seconds`.

**Response (example, isError: true)**

```json
{
  "error": {
    "code": "unknown_task_type",
    "message": "Couldn't map that task to a task type. Send task_type as one of: find_email, verify_email, enrich_company, enrich_person, web_search, extract_url",
    "doc_url": "https://arettic.com/docs/errors#unknown_task_type",
    "retryable": false
  }
}
```

The codes you'll meet most through MCP: [invalid_input](/docs/errors#invalid_input) (a field is missing or malformed; the message names it), [unknown_task_type](/docs/errors#unknown_task_type), [unknown_tool](/docs/errors#unknown_tool), [not_found](/docs/errors#not_found) (a receipt, job or approval that isn't yours), [insufficient_credits](/docs/errors#insufficient_credits), [over_budget](/docs/errors#over_budget), [price_above_max](/docs/errors#price_above_max), [tool_paused](/docs/errors#tool_paused), [provider_error](/docs/errors#provider_error) (nothing charged; retry or use `fallback: true`), [request_in_progress](/docs/errors#request_in_progress) (retry in a few seconds with the same `idempotency_key`) and [trial_limit](/docs/errors#trial_limit).

### Protocol errors

A call to a tool that doesn't exist, or arguments that don't match the schema, is answered by the MCP layer rather than the API. The result still has `isError: true`, but its text is a plain sentence such as `MCP error -32602: Tool nope not found` or `MCP error -32602: Input validation error: Invalid arguments for tool recommend: …`. Nothing ran and nothing was charged; fix the call. The bridge passes these through unchanged.

### HTTP errors

HTTP-level answers from the hosted endpoint

| Status | When | Body |
|---|---|---|
| 401 | No key, an org key, or a revoked key. | [unauthenticated](/docs/errors#unauthenticated) |
| 403 | The agent has an IP allowlist and this address isn't on it. | [ip_not_allowed](/docs/errors#ip_not_allowed) |
| 405 | GET or DELETE on `/mcp`. | [method_not_allowed](/docs/errors#method_not_allowed), with `Allow: POST` |
| 406 | The `Accept` header lacks `application/json` or `text/event-stream`. | A JSON-RPC error from the transport |
| 415 | The `Content-Type` header isn't `application/json`. | A JSON-RPC error from the transport |
| 429 | Over 600 requests a minute on this key. | [rate_limited](/docs/errors#rate_limited), with `Retry-After` |

### What the bridge does for you

- If the API can't be reached, the tool result is `isError: true` with the code [connection_failed](/docs/errors#connection_failed). If the API doesn't answer within 5 minutes, the request is aborted and the code is [timeout](/docs/errors#timeout); the purchase may still be running, so it isn't retried.
- If the connection drops during a call, the bridge reconnects and retries that call once. Reads are always retried.
- An `execute` sent without an `idempotency_key` gets one generated (`mcp-…`), so the retry replays the first purchase instead of buying twice. If the call still fails, the error body includes that `idempotency_key` so you can retry it yourself.
- `open_dispute` is retried only if the first attempt never reached the API, since a repeat would just report that the item already has a dispute.
- A rate limit (429) isn't retried; the error carries `retry_after_seconds`.
- Send your own `idempotency_key` on `execute` to make your own retries safe too.

**Response (example, bridge, isError: true)**

```json
{
  "error": {
    "code": "connection_failed",
    "message": "Couldn't reach the Arettic API at https://api.arettic.com (fetch failed). To retry without paying twice, call execute again with idempotency_key \"mcp-2b6d0f2e-0c4b-4a8e-9d1f-3c5e7a9b1d2f\".",
    "retryable": true,
    "idempotency_key": "mcp-2b6d0f2e-0c4b-4a8e-9d1f-3c5e7a9b1d2f"
  }
}
```

## The server card

`GET https://api.arettic.com/.well-known/mcp.json` describes the server for clients and directories: the endpoints, how to authenticate, where to get a key, the tools, and where the docs and the OpenAPI document are. No key is needed, and it allows cross-origin reads.

**curl**

```bash
curl https://api.arettic.com/.well-known/mcp.json
```

**Response (example)**

```json
{
  "name": "arettic",
  "title": "Arettic",
  "version": "0.3.0",
  "description": "Ask which data tool works for a task, then buy the result through one key and pay only if it passes a published check.",
  "endpoints": {
    "streamable_http": "https://api.arettic.com/mcp",
    "stdio": {
      "command": "npx",
      "args": [
        "-y",
        "@arettic/mcp"
      ],
      "env": [
        "ARETTIC_API_KEY"
      ],
      "status": "published at launch"
    }
  },
  "auth": {
    "type": "bearer",
    "header": "Authorization",
    "format": "Bearer sk_test_… or sk_live_…",
    "get_a_key": "https://arettic.com/docs"
  },
  "tools": [
    {
      "name": "list_task_types",
      "status": "available",
      "description": "The task types, each with its pass rule, input and output."
    },
    {
      "name": "recommend",
      "status": "available",
      "description": "Ranked tools for a task: score, every score input, price per success, pass rule."
    },
    {
      "name": "get_tool",
      "status": "available",
      "description": "One tool in detail: scores by region, history, latest benchmark, schemas."
    },
    {
      "name": "execute",
      "status": "available",
      "description": "Run a tool (one input, or up to 1,000 as inputs; over 25 runs as a job); charged only if the result passes its check. Test keys use mock providers."
    },
    {
      "name": "get_job",
      "status": "available",
      "description": "Status and per-item results of a batch of more than 25 items."
    },
    {
      "name": "get_receipt",
      "status": "available",
      "description": "The receipt for a purchase: cost, check, outcome and refund."
    },
    {
      "name": "open_dispute",
      "status": "available",
      "description": "Dispute a charged item within 7 days (by receipt_id and item_index). Decided within 48 hours against the stored result; upheld means a refund."
    },
    {
      "name": "get_approval",
      "status": "available",
      "description": "Status of a purchase waiting for approval."
    },
    {
      "name": "get_balance",
      "status": "available",
      "description": "The agent's budget and the org's credit balance."
    }
  ],
  "docs": "https://arettic.com/docs",
  "openapi": "https://api.arettic.com/openapi.json"
}
```

`endpoints.stdio.status` says `published at launch` until the npm package is out. `version` is the hosted server's version, the same one `initialize` reports in `serverInfo`.

## Where next

- [Quickstart](/docs/quickstart): from zero to a receipt, including how to get a key.
- [Test mode](/docs/test-mode): the mock tools in full.
- [Recommend](/docs/recommend) and [Execute](/docs/execute): every field of the two calls that matter most.
- [Receipts](/docs/receipts), [Disputes](/docs/disputes), [Batches and jobs](/docs/jobs) and [Budgets and approvals](/docs/budgets-and-approvals): the rest of the tools, field by field.
- [SDKs](/docs/sdks): the same calls without MCP.
- [Error codes](/docs/errors): every code, what it means and what to do.

Updated 2026-09-29. This page as HTML: https://arettic.com/docs/mcp · Markdown: https://arettic.com/docs/mcp.md · JSON: https://arettic.com/docs/mcp.json
