# Machine-readable surfaces

How to read Midnight Scan as a program or a model. Every surface below is
generated from the same indexed tables the pages render and states the block
it is current to; none of them is used by the site's own browser UI (which
keeps its live SSE connection to the indexer). All of them are read-only, open
to any origin, and need no key.

| Surface | Where | For |
| --- | --- | --- |
| Markdown twins | `/<page>.md`, or `Accept: text/markdown` on a page | A model reading one page |
| REST API | `/api/v1/…` (JSON), `/api/v1/openapi.json` | Scripts, dashboards, typed clients |
| MCP server | `POST /mcp` | Claude, Cursor and other MCP clients |
| Resolver | `/go?q=…`, `/api/v1/search?q=…` | Turning any identifier into the right page |
| Documents | `/docs`, `/docs/<name>.md`, `/llms.txt`, `/llms-full.txt` | Reference: units, ticks, data model, chains |

Conventions that apply everywhere: amounts are **units** (1 unit = 1 smallest
unit of the loan token, due at settlement) unless a column says otherwise;
prices are **WAD** (1e18 = par); hex identifiers are lowercase and
`0x`-prefixed; block numbers are plain digits; timestamps are unix seconds (in
JSON `meta`, ISO-8601). The glossary (`/docs/glossary.md`) defines every term.

## Markdown twins

Every page listed in `/llms.txt` under *Markdown twins* has a plain-text
version with the same facts in the same order as the HTML page: header
identity, badges, stat tiles, key-value grids, each table (capped — "latest
50" — and saying so).

Request it either way:

```
GET /market/8453/0x<marketId>.md
GET /market/8453/0x<marketId>        Accept: text/markdown
```

The home page's twin is `/index.md`. The response:

| Header | Value |
| --- | --- |
| `Content-Type` | `text/markdown; charset=utf-8` |
| `Link` | `<https://midnightscan.org/market/…>; rel="canonical"` — the HTML page, so a crawler credits the page |
| `Vary` | `Accept` — the same URL answers HTML or Markdown by Accept header |
| `Cache-Control` | `public, max-age=10, s-maxage=10` |
| `Access-Control-Allow-Origin` | `*` |

Every document has the same shape, so a model that has read one knows how to
read the rest:

```
# <Title>
> <one-sentence summary>

- Source: https://midnightscan.org/<page>
- Generated: 2026-10-27T21:46:30Z
- Indexed through: Base block 48824989 (2026-10-27T21:46:12Z); Ethereum block …
- Machine-readable: [JSON](https://midnightscan.org/api/v1/…)

## <Section mirroring the page>
…

## See also
- [related page](…)

---
Conventions: amounts are face value at maturity — 1 unit = 1 smallest unit of the loan token … Reference: /llms.txt · Glossary: /docs/glossary.md · API: /api/v1/openapi.json · MCP: /mcp
```

Only the pages in that list have a twin: any other page's `.md` URL is the
site's HTML not-found page, and `Accept: text/markdown` on it returns the
HTML page. On a twin, a missing entity answers `404` and an indexer timeout
`502` — both in Markdown, never HTML.

## REST API

Base path `/api/v1`. `GET /api/v1` lists every endpoint; `GET
/api/v1/openapi.json` is the OpenAPI 3.1 description of all of them (every
path, parameter and response schema — generate a client from it).

### Envelope

Every 2xx response is

```json
{
  "data": { … },
  "meta": {
    "generatedAt": "2026-10-27T21:46:30.123Z",
    "source": "https://midnightscan.org/api/v1/markets/8453/0x…",
    "indexed": [
      { "chainId": 8453, "chain": "Base", "block": "48824989", "timestamp": "2026-10-27T21:46:12.000Z", "basis": "indexer-status" }
    ],
    "encoding": "bigint-as-decimal-string"
  }
}
```

- `meta.indexed` is one stamp per chain the payload spans: the block the
  numbers are current to. `basis` is `indexer-status` (the indexer's own
  head) or `latest-event` (the newest indexed event — a lower bound, used when
  the status endpoint is not reachable).
- **Every on-chain integer is a decimal string** — units, prices, timestamps,
  blocks, raw fee values. They overflow a double; parse them as big integers.
  `chainId`, `decimals`, counts and array indexes are JSON numbers.
- Addresses and 32-byte ids are lowercase hex strings. Nullable columns are
  `null`; fields the source never set are omitted.

### Errors

Errors are RFC 9457 `application/problem+json`:

```json
{ "type": "about:blank", "status": 404, "title": "not_found", "detail": "Unknown chainId \"99\". See /api/v1/chains." }
```

| Status | `title` | When |
| --- | --- | --- |
| 400 | `bad_request` | A malformed parameter (an address that is not 40 hex, an empty `q`) |
| 404 | `not_found` | An unknown chainId, or an entity the indexer has no row for |
| 502 | `indexer_unavailable` | The indexer did not answer within 8s — retry shortly |
| 500 | `internal_error` | Anything else (the message is hidden in production) |

### Headers

`Access-Control-Allow-Origin: *` on every response, `OPTIONS` answers `204`
with the allowed methods (`GET, POST, OPTIONS`) and headers — a browser page
on any origin may call the API directly. Data responses carry a short
`Cache-Control` — `public, max-age=10, s-maxage=10` unless an endpoint says
otherwise (the data moves once per indexed block); errors are `no-store`. An
optional `errors` member carries structured detail when there is any.

### Endpoints

List endpoints take `chainId` (optional; an unknown id is a 404 pointing at
`/api/v1/chains`) and `limit` (default and maximum documented per endpoint in
the OpenAPI file, never above 200). Cursors follow the page's own pagination
where it has one.

| Endpoint | Returns |
| --- | --- |
| `GET /api/v1` | Index of every endpoint |
| `GET /api/v1/openapi.json` | OpenAPI 3.1 |
| `GET /api/v1/chains` | The chain registry: ids, names, Midnight / Mempool / Morpho Blue addresses, Tenor factories, start blocks, public RPCs |
| `GET /api/v1/status` | Indexed-through block and time per chain |
| `GET /api/v1/overview` | The home page: protocol totals, largest markets, latest activity and broadcasts |
| `GET /api/v1/markets` | Every Midnight market (one per maturity) with live state — `chainId`, `loanToken`, `status=live\|matured\|all`, `limit` ≤ 200 |
| `GET /api/v1/markets/{chainId}/{marketId}` | One market: terms, totals, fees, collateral configs, latest takes, positions, live offers |
| `GET /api/v1/tenor-markets` | Markets grouped by Tenor market id, as on /markets — `chainId`, `loanToken`, `collateral`, `minBorrowedUsd`, `status=active\|inactive\|all`, `limit` ≤ 200, `offset` |
| `GET /api/v1/tenor-markets/{chainId}/{tenorMarketId}` | One Tenor market and each of its maturities |
| `GET /api/v1/address/{address}` | One address across every chain: positions, offers, fills, liquidations, summary |
| `GET /api/v1/address/{address}/positions` | Borrow and lend positions with collateral |
| `GET /api/v1/address/{address}/activity` | One history feed, paged — `feed=events\|maker-fills\|liquidations-performed\|liquidations-suffered\|broadcasts`, `chainId`, `limit` ≤ 200, `before` |
| `GET /api/v1/tx/{chainId}/{hash}` | One transaction's Midnight/Mempool events and position/market state diff |
| `GET /api/v1/activity` | Latest protocol events — `type=take\|repay\|withdraw\|collateral\|liquidation\|all`, `chainId`, `marketId`, `address`, `router`, `minUsd`, `limit` ≤ 200, `before` (response carries `nextCursor`) |
| `GET /api/v1/users` | Borrowers and lenders ranked by debt and credit |
| `GET /api/v1/risk` | Borrow positions closest to liquidation: LTV against LLTV |
| `GET /api/v1/mempool/broadcasts` | Latest offer broadcasts with decode status and offer counts |
| `GET /api/v1/mempool/offers` | The order book: offers takeable now (open or partly filled), grouped by market, best quote first — `status`, `kind`, `chainId`, `marketId`, `loanToken`, `maker`, `limit`. Built from the newest broadcasts; `summary.truncated` says when older matches may exist, so narrow by `marketId` for a complete view of one market |
| `GET /api/v1/broadcasts/{chainId}/{eventId}` | One broadcast and the offers inside it (`eventId` URL-encoded) |
| `GET /api/v1/offers/{chainId}/{offerHash}` | One offer: terms, maker, market, broadcast history, fills, consumption |
| `GET /api/v1/vaults` | Tenor vaults: assets, supply, share price |
| `GET /api/v1/vaults/{chainId}/{address}` | One vault: totals, share price, holders |
| `GET /api/v1/search?q=` | Resolve any identifier (see *Resolver*) |

Example — one market, with the numbers to read as units of its loan token:

```
curl -s https://midnightscan.org/api/v1/markets/8453/0x<marketId> | jq '.data | {loanToken: .loanToken.symbol, maturity, totalUnits: .totals.totalUnits, withdrawable: .totals.withdrawable, lossFactor: .totals.lossFactor}'
```

## MCP server

`POST /mcp` is a Model Context Protocol server over **Streamable HTTP**,
**stateless**: each request is a self-contained JSON-RPC 2.0 message, no
session id to keep, no SSE stream to hold open (a client that sends
`Accept: application/json, text/event-stream` gets a JSON reply). CORS is open
and the MCP headers (`Mcp-Session-Id`, `Mcp-Protocol-Version`) are allowed, so
browser-based clients work too.

Protocol versions: `2025-11-25`, `2025-06-18`, `2025-03-26` and `2024-11-05`.
`initialize` answers with the client's version when it is one of these, else
the newest; a request whose `MCP-Protocol-Version` header names any other
version is refused with `400` (no header is fine). Every tool is read-only and
says so (`annotations.readOnlyHint`). A tool that fails — bad arguments, an
unknown entity, a slow indexer — returns a result with `isError: true` and the
reason as text; an unknown tool or method is a JSON-RPC error.

Results are sized for a model's context: the Markdown is cut at about 80 KB
(at a line break, with a note saying how to narrow the call), and the
structured copy is left out once it would pass about 60 KB — the same facts
are then at the REST endpoint the result's provenance links. A batch (still
accepted from clients on older protocol versions) holds at most 20 messages.

### Connecting

Add it as a remote (URL) server:

- **Claude Code**: `claude mcp add --transport http midnightscan https://midnightscan.org/mcp`
- **Claude Desktop / claude.ai**: Settings → Connectors → *Add custom
  connector* → URL `https://midnightscan.org/mcp` (no authentication).
- **Cursor**: in `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):

  ```json
  { "mcpServers": { "midnightscan": { "url": "https://midnightscan.org/mcp" } } }
  ```

- **Anything else**: the server speaks the standard protocol; point the client
  at the URL with the Streamable HTTP transport. A raw call:

  ```
  curl -s https://midnightscan.org/mcp \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_market","arguments":{"chainId":8453,"marketId":"0x…"}}}'
  ```

### Tools

Each tool returns the same Markdown the page's twin serves (what a model
reads) plus the same facts as structured data (`structuredContent`, bigints as
decimal strings) and the indexed-through stamps. Tool descriptions say what
comes back, in what units, and when to prefer a sibling tool.

| Tool | Arguments | Returns |
| --- | --- | --- |
| `list_chains` | — | The chain registry |
| `get_status` | — | Indexed-through block and time per chain |
| `get_overview` | — | Protocol totals, largest markets, latest activity and broadcasts |
| `list_tenor_markets` | `chainId?`, `loanToken?`, `collateral?`, `minBorrowedUsd?`, `status?`, `limit?`, `offset?` | Markets grouped by Tenor market, largest first |
| `list_markets` | `chainId?`, `loanToken?`, `status?`, `limit?` | Every Midnight market (one per maturity) with live state |
| `get_market` | `chainId`, `marketId`, `limit?` | One market (terms, totals, fees, takes, positions, offers) |
| `get_tenor_market` | `chainId`, `tenorMarketId` | One Tenor market and its maturities |
| `get_address` | `address`, `feed?`, `before?`, `chainId?`, `limit?` | One address across every chain |
| `get_address_positions` | `address`, `chainId?` | Its borrow and lend positions with collateral |
| `get_transaction` | `chainId`, `hash` | One transaction's events and state diff |
| `list_activity` | `chainId?`, `type?`, `marketId?`, `address?`, `router?`, `loan?`, `collat?`, `minUsd?`, `limit?`, `before?` | Latest protocol events, filterable by type, chain, market, address, router and USD size; paged |
| `list_users` | `role?`, `chainId?`, `limit?`, `tags?` | Borrowers and lenders ranked by debt and credit |
| `list_risk` | `chainId?`, `status?`, `mode?`, `minUsd?`, `dust?`, `limit?` | Positions closest to liquidation |
| `list_broadcasts` | `chainId?`, `kind?`, `route?`, `minUsd?`, `limit?`, `before?` | Latest offer broadcasts with their offers rolled up |
| `list_mempool_offers` | `chainId?`, `status?`, `kind?`, `loanToken?`, `marketId?`, `maker?`, `maturity?`, `limit?` | The order book: offers takeable now, grouped by market, best quote first |
| `get_broadcast` | `chainId`, `eventId` | One broadcast and its offers |
| `get_offer` | `chainId`, `offerHash` | One offer with fills and consumption |
| `list_vaults` | `chainId?`, `period?` | Tenor vaults |
| `get_vault` | `chainId`, `address` | One vault: totals, share price and holders |
| `search` | `q` | Resolve any identifier to its page(s) — use when the kind of an id is unknown |

### Resources

- `midnightscan://docs/<name>.md` — the documents under `/docs` (glossary,
  architecture, decoder, contracts, api).
- `midnightscan://llms.txt` — the site map for models.
- The OpenAPI document.

## Resolver

The search box as a URL. `GET /go?q=<anything>` classifies `q` and runs the
lookups the site's search page runs, then:

| Matches | Answer |
| --- | --- |
| exactly one | `302` to the page (`Location` is always a path on this site) |
| several | `300 Multiple Choices`, body a Markdown list of candidates (JSON with `Accept: application/json` or `?format=json`) |
| none | `404`, body explaining the accepted forms and linking `/search?q=` for people |

Accepted identifiers:

| Paste | Goes to |
| --- | --- |
| an address (`0x` + 40 hex, or bare 40 hex) | `/address/{address}` — no lookup needed |
| a 32-byte id (`0x` + 64 hex) | every match, in this order (one match → `302`; an id that matches on two chains or as two kinds → `300`): Midnight market → `/market/{chainId}/{marketId}`; Tenor market → `/markets/{chainId}/{tenorMarketId}`; offer (broadcast, then fill-only) → `/offer/{chainId}/{offerHash}`; transaction (any indexed Midnight/Mempool event) → `/tx/{chainId}/{hash}`; a broadcast with no activity row → `/message/{chainId}/{eventId}` |
| a vault market id (40 hex + 24 zeros) | `/tools/vaults/{chainId}/{vault}` when exactly one live registered vault has that address, else `/tools/market-id?id=` |
| a block number | each chain's block explorer, plus `/activity` — blocks are not site entities, so this is always a 300 |
| an ENS name (`name.eth`) | resolved on Ethereum (3s timeout) → `/address/{address}` |
| a Midnight Scan URL | its own path |
| `{chainId}:{id}` | a 32-byte id or a block number, scoped to one chain (an address keeps every chain; an ENS name or URL takes no prefix) |

`GET /api/v1/search?q=…` runs the same resolution and returns it as a JSON
envelope, always `200` (an unmatched id is a resolution with zero candidates
and a `notes` entry); `400` when `q` is empty. The `search` MCP tool is the
same call.

```json
{
  "data": {
    "query": "0x…",
    "kind": "hash32",
    "candidates": [
      { "url": "/market/8453/0x…", "label": "Midnight market on Base, maturity 2026-11-28T00:00:00Z", "chainId": 8453, "kind": "market" }
    ],
    "notes": []
  },
  "meta": { … }
}
```

`kind` is what the query looked like (`address`, `hash32`, `vault-market-id`,
`block`, `ens`, `site-url`, `empty`, `unknown`); each candidate's `kind` is
what it leads to (`market`, `tenor-market`, `offer`, `tx`, `broadcast`,
`vault`, `market-id-tool`, `address`, `block-explorer`, `activity`, `page`).
Candidate `url`s are site paths, except a block explorer's, which is absolute.

## llms.txt and the documents

- `/llms.txt` — the llmstxt.org site map: what the explorer covers, every
  page and entity URL, the machine-readable surfaces, the unit conventions,
  how ticks encode price, the data model (tables and columns with units), the
  chain registry with contract addresses, identifier patterns, example
  requests and crawler rules. Static, regenerated at each deploy from the same
  registries the pages read.
- `/llms-full.txt` — `/llms.txt` followed by the glossary, architecture,
  decoder and API documents inlined (contracts.md is linked, not inlined).
- `/docs` — the index of the documents; `/docs/<name>.md` serves each as
  `text/markdown`: `glossary.md`, `architecture.md`, `decoder.md`,
  `contracts.md`, `api.md` (this file).

`robots.txt` allows the pages, the documents, `/llms.txt`, `/llms-full.txt`
and `/api/v1/openapi.json`, and disallows `/search`, `/address/`, `/tools`,
`/go`, `/api/v1/`, `/mcp`, `/md/` (the twins' internal handler paths — the
`<page>.md` URLs are the public ones) and `/sql/` — the unbounded or
programmatic spaces.
That is crawl budget, not access: agents are welcome on every surface here.
