# MCP server (/docs/mcp)

quarry is a [Model Context Protocol](https://modelcontextprotocol.io) server named
`quarry`, served over Streamable HTTP. Setup for each client is in
[Connect your tool](/docs/connect); this page is the reference.

## Addresses and sign-in [#addresses-and-sign-in]

quarry's two addresses, both on `https://quarry.market`:

| Address          | Sign-in  | Use                                                                                                                                                                       |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/mcp`       | Optional | The free address. With no `Authorization` header, the caller is anonymous. With `Authorization: Bearer` and an API key or an OAuth token, it acts as that quarry account. |
| `/api/agent/mcp` | Required | The sign-in address. The same tools, signed in from the first request, plus the older tool names below.                                                                   |

* An invalid or revoked token gets HTTP 401 with a `WWW-Authenticate` challenge whose
  `resource_metadata` points to `/.well-known/oauth-protected-resource/api/mcp` (or
  `…/api/agent` on the sign-in address). Clients use it to start OAuth sign-in.
* On the free address, calling an account tool without a token also gets that 401,
  with `scope="openid offline_access"`, so a client can sign in when it first needs to.
  Claude does: added with Sign in when needed, it asks the user to connect their quarry
  account at that call.
* An [API key](/docs/subscriptions#api-keys) is sent as `Authorization: Bearer qk_...`.

## Tools [#tools]

| Tool                   | Access                                   | Read-only | What it does                                                          |
| ---------------------- | ---------------------------------------- | --------- | --------------------------------------------------------------------- |
| `search`               | Free                                     | Yes       | Finds the datasets that answer a question, across the catalog         |
| `get_dataset`          | Free                                     | Yes       | One dataset's details, prices, and how to pay                         |
| `ask`                  | Free, subscription, or paid per question | No        | The passages of one dataset that answer a question, with citations    |
| `quote_access`         | Account                                  | Yes       | Whether a question on a dataset is included in your subscription      |
| `get_my_access`        | Account                                  | Yes       | Your subscriptions, what's left this month, and your latest purchases |
| `request_subscription` | Account                                  | No        | Prepares a Stripe Checkout link for a monthly offer                   |
| `x402_payment_guide`   | Free                                     | Yes       | How to pay per question with a USDC wallet                            |

Read-only tools are also idempotent. `ask` isn't: without a reused `request_id`, each
call is a new question. `request_subscription` isn't read-only, but calling it again
returns the same Checkout link until it's paid or expires.

### `search` [#search]

| Input      | Type              | Limits                                     |
| ---------- | ----------------- | ------------------------------------------ |
| `query`    | string, required  | The question or topic, 1 to 500 characters |
| `category` | string, optional  | One category, up to 40 characters          |
| `limit`    | integer, optional | 1 to 10, default 5                         |

The result is `{ results, available_categories, next_step }`. Datasets are ranked by
meaning and wording across the whole catalog, then reordered by a reranking model.
Each result has:

* `username`, `slug`, `name`, `description` (first 300 characters), `category`, and
  `score` (higher is better; `null` when only the word search found the dataset);
* `price_usdc` as a string, or `null` when the dataset only sells subscriptions, and
  `free` (`true` when a question costs nothing);
* `subscription_offer` (or `null`), `documents` (number of files), `listed_at`,
  `content_updated_at`;
* `listing_url`, `endpoint`, and `next`, the `ask` call to make for this dataset;
* `snippet`: `{ text, filename, page }`, a free preview of the best-matching passage,
  at most 200 characters.

Only the top five results can carry a snippet. It's `null` when the seller turned
previews off, a preview limit was reached, the preview took more than 4 seconds, or no
passage matched. A missing preview never fails the search.

### `get_dataset` [#get_dataset]

Inputs: `username` (1 to 60 characters) and `slug` (1 to 80). The result is the
[dataset contract](/docs/api) plus:

* `how_to_pay`: `null` for a subscription-only dataset, `{ free: true, summary }` for a
  free one, or the x402 details (price, network, steps, a JavaScript example);
* `subscription_offer`, when the dataset has a monthly offer;
* `your_access`, the `quote_access` result, when you're signed in;
* `stats` (`total_calls`, `last_query_at`) and `next_step`;
* `response`: the shape of what `ask` returns (below), and `http`: the contract's
  `request` and `response` for the [HTTP route](/docs/api/query), which differ.

### `ask` [#ask]

| Input              | Type              | Limits                                                      |
| ------------------ | ----------------- | ----------------------------------------------------------- |
| `username`, `slug` | string, required  | The dataset, as returned by `search`                        |
| `query`            | string, required  | The question, 1 to 2,000 characters                         |
| `top_k`            | integer, optional | Passages to return, 1 to 20, default 5                      |
| `request_id`       | string, optional  | 1 to 128 characters: letters, digits, `_`, `.`, `:`, or `-` |

`ask` answers the first way that applies:

1. **A free dataset** answers directly, with no account or payment.
2. **Your subscription**, when you're signed in and it has questions left. A reused
   `request_id` returns the saved answer for 24 hours, even once the month's questions
   have run out, so a retry after a timeout is never counted twice. Without a
   `request_id`, every call is a new question.
3. **An x402 payment** sent with the call: see
   [Pay per question inside MCP](#pay-per-question-inside-mcp).
4. Otherwise, a payment-required result (below).

A successful answer:

```json
{
  "dataset": {
    "name": "Market outlook",
    "username": "alice",
    "slug": "market-outlook",
    "listing_url": "https://quarry.market/explore/alice/market-outlook",
    "updated_at": "2026-09-20T10:00:00Z"
  },
  "query": "Which segment grew fastest?",
  "results": [
    {
      "chunk_id": "3f6c2a9e-8d41-4b7a-9c0e-5a1d2b3c4d5e",
      "content": "Self-serve revenue grew 41% year over year, the fastest of any segment.",
      "relevance": 0.83,
      "citation": {
        "filename": "q3-outlook.pdf",
        "page_start": 12,
        "page_end": 12,
        "rows": null,
        "heading_path": ["Results", "Segments"],
        "document_updated_at": "2026-09-20T10:00:00Z",
        "listing_url": "https://quarry.market/explore/alice/market-outlook"
      }
    }
  ],
  "how_to_cite": "Cite as: <filename>, p. <page_start> (or rows <rows.start>–<rows.end>) — Market outlook (https://quarry.market/explore/alice/market-outlook)",
  "terms": "https://quarry.market/terms",
  "paid_with": "subscription"
}
```

Results come best first. `relevance` runs from 0 to 1, higher is better: the
reranking model's score, or the similarity of meaning when reranking wasn't available
(see [Ask over HTTP](/docs/api/query#result-fields)). Compare it between the passages
of one answer, not across datasets. `chunk_id` is the passage's stable id, the same one
the HTTP route returns.
`paid_with` is `free`, `subscription`, or `x402`. Subscription answers also carry
`quota` (`quota`, `used`, `reserved`, `remaining`, `resets_at`, and ids) and
`replayed`. An answer to an agent signed in to a quarry account (connected, or with an
API key) carries `purchase_url`, this question's page on the account's
[purchases](/docs/purchases). Cite the file name, the page or rows, and the listing link.
`terms` is the address of the [terms](/terms#using-passages), which say what you may
do with the passages.
`rows` is set for passages of CSV, TSV, JSON, and JSON Lines files, as `{ start, end }`:
spreadsheet rows (the header is row 1), JSON Lines lines, or JSON array elements counted
from 1. It's `null` for other files.
`document_updated_at` is when the file changed on quarry, not when the source was
written: rely on dates stated in the passage when timing matters.

When no passage answers the question, `ask` returns `isError: true` with the error
`NO_MATCH`, on every path: no x402 payment settles (the same signed payment can be
sent again with another question to the dataset), and no subscription question is
used. Rephrase the question, or try another dataset.

When `ask` can't answer without payment, it returns `isError: true` with:

* `structuredContent`: the x402 `PaymentRequired` object (`x402Version`, `resource`,
  and `accepts` with scheme, network, asset, amount, and `payTo`);
* a second text block, `{ quarry: { status, dataset_id, price_usdc, subscription_offer,
  subscribe_url, how_to_access, web_payment, next, terms } }`. `status` is
  `payment_required` or `quota_exhausted_or_not_subscribed`, and `next` names the
  `request_subscription` call when a monthly offer exists. `web_payment` is the request
  (`method`, `url`, `body`) for a wallet that pays web requests but can't sign inside an
  MCP call, such as Coinbase's: see [Give your assistant a wallet](/docs/wallet).
  From an agent signed in to a quarry account, its `body` carries an `account_token`
  that lists the purchase on that account: see
  [Ask over HTTP](/docs/api/query#pay-per-question).

A dataset that only sells subscriptions returns the error `SUBSCRIPTION_REQUIRED`
instead, with the same `quarry` object in the same text block and no
`structuredContent`.

### Account tools [#account-tools]

These need a signed-in account or an API key (see
[Addresses and sign-in](#addresses-and-sign-in)).

* `quote_access` (`username`, `slug`) returns `{ dataset_id, dataset, status, access,
  subscription_offer, x402 }`, without using a question. The statuses are listed in
  the [Subscription API](/docs/api/subscriptions#quote_access-statuses).
* `get_my_access` (no input) returns `{ subscriptions, latest_purchases, purchases_url }`.
  Each subscription has its status, offer, dataset, renewal state, Checkout link if
  payment is pending, and `access`. `latest_purchases` holds the 10 newest questions
  asked from the account, each with `asked_at`, `dataset`, `listing_url`, `paid_with`,
  `amount_usdc` for a wallet payment, and `purchase_url`, but never the question itself:
  another agent or a teammate's key may have asked it. `purchases_url` is the full list.
* `request_subscription` (`dataset_id`, `offer_id`, both from `quote_access`) returns
  `{ status, subscription_id, url, next_step }`. `url` is a Stripe Checkout link for
  the person to review and pay; nothing is charged until they do. Clients that support
  it are asked to open the link.

### `x402_payment_guide` [#x402_payment_guide]

Takes no input and returns a Markdown guide to paying per question with a USDC wallet,
over MCP and HTTP.

### Older tool names [#older-tool-names]

The sign-in address also serves the tools of quarry's first MCP server:

* `search_datasets` (`query` up to 200 characters, `limit` 1 to 50, default 20) returns
  `{ datasets }`;
* `query_dataset` takes the `ask` inputs with a required `request_id` and returns the
  raw [HTTP result](/docs/api/query#response), not citations. It only uses subscriptions.

## Pay per question inside MCP [#pay-per-question-inside-mcp]

quarry follows the [x402 MCP transport](https://github.com/coinbase/x402/blob/main/specs/transports-v2/mcp.md). Call `ask` without
payment, sign one of the `accepts` entries of the returned `PaymentRequired` with your
wallet, and call `ask` again with the identical arguments and the payment in
`_meta["x402/payment"]`:

```js
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { x402Client } from "@x402/core/client";
import { registerExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";

const mcp = new Client({ name: "my-agent", version: "1.0.0" });
await mcp.connect(new StreamableHTTPClientTransport(new URL("https://quarry.market/api/mcp")));

const payer = new x402Client();
registerExactEvmScheme(payer, { signer: privateKeyToAccount(process.env.AGENT_PRIVATE_KEY) });

const args = { username: "alice", slug: "market-outlook", query: "Which segment grew fastest?" };
const unpaid = await mcp.callTool({ name: "ask", arguments: args });
if (unpaid.isError && unpaid.structuredContent?.accepts) {
  const payment = await payer.createPaymentPayload(unpaid.structuredContent);
  const paid = await mcp.callTool({ name: "ask", arguments: args, _meta: { "x402/payment": payment } });
  if (paid.isError) console.log("Not paid:", paid.structuredContent?.error ?? paid.content[0].text);
  else console.log(JSON.parse(paid.content[0].text), paid._meta?.["x402/payment-response"]);
} else {
  // Free dataset, subscription-only dataset, or an error: print what came back.
  console.log(unpaid.content.map((c) => c.text).join("\n"));
}
await mcp.close();
```

Install the packages with `npm install @modelcontextprotocol/sdk @x402/core @x402/evm viem`
and run the file as an ES module, for example `node agent.mjs`. `AGENT_PRIVATE_KEY` is the private key of a wallet holding USDC on the network in
`accepts`. The x402 client refuses to sign more than $1 per payment by default; for a
pricier dataset, call `payer.setSpendControls({ maxAmountPerPayment: "$5" })` with your
own cap.

What comes back:

* **Paid:** the passages, with `paid_with: "x402"`. `_meta["x402/payment-response"]`
  holds the settlement: `success`, `transaction` (the transaction hash), `network`, and
  `payer`. Without a quarry account the answer also carries `purchases`, a sentence on
  where questions asked from an account are listed; with one, `purchase_url`.
* **Refused:** a payment that doesn't match, fails verification, or is refused at
  settlement returns `PaymentRequired` again, with the reason in `error`, and no
  passages. An unfunded wallet is refused at verification: `error` names the balance
  or a reverted transfer.
* **In flight:** a signed payment already being used by another call returns
  `PAYMENT_IN_FLIGHT`. Wait for that call's result.
* **Not charged:** you pay only when the search succeeds. A failed search returns
  `SEARCH_FAILED`, and the payment stays unspent.

For HTTP instead of MCP, see [Paying with x402](/docs/x402).

## Errors and limits [#errors-and-limits]

A tool that fails returns `isError: true` and a text block with
`{ "error": { "code", "message", "details" } }` (`details` is optional). Check
`isError` as well as the HTTP status: a failed tool call still answers HTTP 200. Every
code is in [Error codes](/docs/api/errors#mcp-tool-results), and rate limits are in
[Limits](/docs/limits).

Arguments outside the limits above never reach the tool. The result is still
`isError: true`, but its text is plain, starting with `MCP error -32602`, not JSON:
check for it before parsing.
