# UsherStats for AI agents (MCP)

<!-- docs-test setup: owner -->

UsherStats is a remote [Model Context Protocol](https://modelcontextprotocol.io) server. Point any MCP client --
Claude Code, Claude Desktop, Cursor, or your own agent -- at

```text
https://api.usherstats.com/mcp
```

with an API token, and the agent can do what the token can: list and create sites, add hostnames, read the install
snippet and status, manage keys, settings, tokens and team members. Every tool *is* an API route, called as your
token, so scopes, site restrictions and plan limits apply exactly as they do over HTTP. Analytics tools (stats, top
pages and sources, crawlers, journeys, experiments, bot rules, search data) appear here as soon as those API routes
ship, with no change on your side.

- **Endpoint:** `POST /mcp` (Streamable HTTP, JSON-RPC 2.0, one JSON answer per request, no sessions).
- **Protocol versions:** `2026-07-28` (stateless: no `initialize`; version in each request's `_meta` and the
  `MCP-Protocol-Version` header) and `2025-03-26`, `2025-06-18`, `2025-11-25` (opening with `initialize`).
- **Authentication:** `Authorization: Bearer us_live_...` only. Sign-in cookies are not accepted on `/mcp`.
- **The HTTP API** behind the tools is described at
  [https://api.usherstats.com/v1/openapi.json](https://api.usherstats.com/v1/openapi.json) (OpenAPI 3.1).

## 1. Make a token for the agent

Give the agent only what it needs. A token can be limited to some sites (`siteIds`) and given an expiry; it is shown
once. For an agent that reports and helps with setup but changes nothing:

```sh
curl https://api.usherstats.com/v1/tokens \
  -H "Authorization: Bearer $US_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "claude (read only)", "scopes": ["sites:read", "analytics:read", "members:read"], "expiresAt": "2027-06-30T00:00:00Z"}'
```
<!-- expect 201; READ_TOKEN=.token -->

For an agent that may also set sites up (create sites, add hostnames, change settings), add `sites:write`:

```sh
curl https://api.usherstats.com/v1/tokens \
  -H "Authorization: Bearer $US_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "claude (setup)", "scopes": ["sites:read", "sites:write", "analytics:read"], "expiresAt": "2027-06-30T00:00:00Z"}'
```
<!-- expect 201; AGENT_TOKEN=.token -->

You can also make tokens in the dashboard under **Settings > API tokens**. Revoke one with `DELETE /v1/tokens/{id}`;
it stops working at once.

## 2. Connect your client

Keep the token out of files you commit: each snippet below reads it from the environment variable `US_TOKEN`.

### Claude Code

```sh
claude mcp add --transport http usherstats https://api.usherstats.com/mcp --header "Authorization: Bearer $US_TOKEN"
```

Or share it with a project in `.mcp.json` (Claude Code expands `${US_TOKEN}` from the environment):

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

### Claude Desktop

Claude Desktop starts local servers from `claude_desktop_config.json`; the `mcp-remote` bridge connects it to a remote
server with a header. Put the token in `env`, not in the arguments:

```json
{
  "mcpServers": {
    "usherstats": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.usherstats.com/mcp", "--header", "Authorization:${US_AUTH}"],
      "env": { "US_AUTH": "Bearer us_live_..." }
    }
  }
}
```

### Cursor

In `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (one project):

```json
{
  "mcpServers": {
    "usherstats": {
      "url": "https://api.usherstats.com/mcp",
      "headers": { "Authorization": "Bearer ${env:US_TOKEN}" }
    }
  }
}
```

### Any other MCP client

Configure a **Streamable HTTP** (sometimes "HTTP" or "remote") server with the URL `https://api.usherstats.com/mcp`
and the request header `Authorization: Bearer <your token>`. The server does not use OAuth; a client that only
supports OAuth for remote servers can use the `mcp-remote` bridge as in the Claude Desktop example.

## 3. What to ask

- "Which of my sites have the UsherStats snippet installed, and when did each last send data?"
- "Create a site called Docs for docs.example.com in Europe/Bucharest and give me the snippet to paste."
- "Add shop.example.com as a hostname of my Shop site."
- "Mark 203.0.113.0/24 as our office network on every site so our own visits are not counted."
- "Add a goal named Signup on the path /welcome for the Shop site."
- "Who is on the team, and which invitations are still pending?"
- "List the API tokens nobody has used in the last 30 days."

The agent starts with `get_me` (what the token may do) and `list_sites`; every id it needs comes from a `list_` or
`get_` tool. When a call is refused, the tool error's first line says why and what to do -- for example
`This needs the "sites:write" scope, which this API token does not have` -- so the agent can tell you, rather than
guess.

**Retries.** Every tool that changes something takes an optional `idempotencyKey`. An agent that repeats a call with
the same key within 24 hours (after a timeout, say) gets the first result back instead of creating a second site or
token. The same key works over HTTP as the `Idempotency-Key` header.

**Limits.** A token makes up to 600 calls a minute, and every tool call counts, including each one inside a JSON-RPC
batch (protocol version 2025-03-26 only). A batch carries at most 20 messages.

## Tools

One tool per API route a token can call. The list is generated from the routes, so it is always exactly what the API
serves; `tools/list` returns each tool's input schema (JSON Schema) and, for tools that return data, its output schema.

| Tool | Scope |
|---|---|
| `get_me` | none |
| `get_workspace` | `members:read` |
| `update_workspace` | `members:write` |
| `list_members` | `members:read` |
| `create_member_invite` | `members:write` |
| `delete_member_invite` | `members:write` |
| `update_member` | `members:write` |
| `delete_member` | `members:write` |
| `list_tokens` | `tokens:read` |
| `create_token` | `tokens:write` |
| `delete_token` | `tokens:write` |
| `list_sites` | `sites:read` |
| `create_site` | `sites:write` |
| `get_site` | `sites:read` |
| `update_site` | `sites:write` |
| `delete_site` | `sites:write` |
| `create_site_hostname` | `sites:write` |
| `get_site_hostname` | `sites:read` |
| `verify_site_hostname` | `sites:write` |
| `delete_site_hostname` | `sites:write` |
| `list_site_keys` | `sites:read` |
| `create_site_key` | `sites:write` |
| `rotate_site_key` | `sites:write` |
| `delete_site_key` | `sites:write` |
| `get_site_snippet` | `sites:read` |
| `get_site_install_status` | `sites:read` |
| `update_site_settings` | `sites:write` |
| `get_stats_catalogue` | `analytics:read` |
| `get_site_stats` | `analytics:read` |
| `query_site_stats` | `analytics:read` |
| `batch_site_stats` | `analytics:read` |
| `get_site_summary` | `analytics:read` |
| `get_site_top` | `analytics:read` |
| `list_site_goals` | `sites:read` |
| `create_site_goal` | `sites:write` |
| `delete_site_goal` | `sites:write` |
| `get_site_goal_results` | `analytics:read` |
| `list_site_experiments` | `sites:read` |
| `create_site_experiment` | `sites:write` |
| `update_site_experiment` | `sites:write` |
| `delete_site_experiment` | `sites:write` |
| `get_site_experiment_results` | `analytics:read` |
| `get_site_journeys` | `analytics:read` |
| `get_site_bot` | `bot:read` |
| `update_site_bot` | `bot:write` |
| `get_site_bot_decisions` | `bot:read` |
| `get_site_search` | `search:read` |
| `update_site_search_google` | `search:write` |
| `update_site_search_bing` | `search:write` |
| `delete_site_search_google` | `search:write` |
| `delete_site_search_bing` | `search:write` |
| `get_site_search_weeks` | `search:read` |
| `internal_link_site` | `sites:write` |
| `rotate_site_internal_link` | `sites:write` |
| `export_site` | `export:read` |
| `get_billing` | `billing:read` |
| `checkout_billing` | `billing:write` |
| `portal_billing` | `billing:write` |
| `plan_billing` | `billing:write` |

Not tools: sign-up, sign-in, passwords, sessions and two-factor setup (a person does these; an agent uses a token),
accepting an invitation (it joins a signed-in person to a workspace), and the OpenAPI document (fetch it over HTTP).

## The protocol, by hand

What a client sends, for anyone writing their own. Each request is its own `POST /mcp`. With `2026-07-28`, the
version and client capabilities travel in `params._meta`, and the method (and, for `tools/call`, the tool name) are
mirrored in headers; a header that disagrees with the body is refused with `400` and error `-32020`.

Discover the server: supported versions, capabilities and instructions for the model.

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: server/discover" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
<!-- expect 200; VERSION=.result.supportedVersions[0] -->

List the tools:

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: $VERSION" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
<!-- expect 200; FIRST_TOOL=.result.tools[0].name -->

Call one. The result has the route's answer as JSON text in `content` and as `structuredContent`:

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: create_site" \
  -d '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "create_site", "arguments": {"name": "Docs", "hostnames": ["docs.example.com"], "idempotencyKey": "create-docs-1"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
<!-- expect 200; SITE_ID=.result.structuredContent.site.id -->

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: get_site_snippet" \
  --data @- <<EOF
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
 "params": {"name": "get_site_snippet", "arguments": {"siteId": "$SITE_ID"},
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
EOF
```
<!-- expect 200; SNIPPET=.result.structuredContent.html -->

A refusal is a tool result with `"isError": true` whose text starts with what to do. The read-only token cannot
create sites:

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $READ_TOKEN" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: create_site" \
  -d '{"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "create_site", "arguments": {"name": "Nope"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}'
```
<!-- expect 200; REFUSED=.result.isError -->

Without a token, `/mcp` answers `401` (as `application/problem+json`, with `WWW-Authenticate: Bearer`):

```sh
curl https://api.usherstats.com/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 6, "method": "tools/list"}'
```
<!-- expect 401 -->

A client on an earlier protocol version opens with `initialize`; no session id is issued, so every later request
stands alone (send `MCP-Protocol-Version` with the negotiated version):

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 7, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "my-agent", "version": "1.0"}}}'
```
<!-- expect 200; NEGOTIATED=.result.protocolVersion -->

```sh
curl https://api.usherstats.com/mcp \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: $NEGOTIATED" \
  -d '{"jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": {"name": "list_sites", "arguments": {}}}'
```
<!-- expect 200 -->

`GET /mcp` and `DELETE /mcp` answer `405`: there is no standalone event stream and no session to end.
