# Ktrl MCP Server

Model Context Protocol server exposing Ktrl marketing analytics to LLM clients such as Claude Code and Cursor.

- Endpoint: `https://api.kohort.io/api/v1/mcp`
- Transport: Streamable HTTP. Every call is an ordinary HTTP POST with a JSON-RPC 2.0 body — there is no session to open or keep alive, so each request stands on its own. GET and DELETE return 405. The 2025 protocol revisions are served to every client; clients that declare the MCP Apps extension (`io.modelcontextprotocol/ui`) in their requests are served protocol 2026-07-28 as well.
- Rate limit: 60 requests per minute per company, shared with the REST API.
- Every tool is read-only; the server cannot change anything in Ktrl.

## Authentication

- **API key** — Send `Authorization: Bearer kht_<your-api-key>` on every request. Create a key in the Ktrl platform under Settings → API Keys.

Your Ktrl user is connected to your company the first time you log in. Every tool call only returns data for the apps that company can access.

## Required headers

| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | yes | `Bearer kht_<your-api-key>`. |
| `Content-Type` | yes | application/json |
| `Accept` | yes | Must include both application/json and text/event-stream. Sending only one is the most common reason the connection fails. |

## Client setup

### Claude Code

Run this once — Claude Code remembers the server and your key.

```bash
claude mcp add --transport http ktrl https://api.kohort.io/api/v1/mcp --header "Authorization: Bearer kht_<your-api-key>"
```

### Cursor

Add this to ~/.cursor/mcp.json, then reload Cursor.

```json
{
  "mcpServers": {
    "ktrl": {
      "url": "https://api.kohort.io/api/v1/mcp",
      "headers": {
        "Authorization": "Bearer kht_<your-api-key>"
      }
    }
  }
}
```

### MCP Inspector

The official tool for trying tools out before wiring them into a client. Start it, choose the Streamable HTTP transport, paste in the endpoint URL, then add an `Authorization: Bearer kht_<your-api-key>` header.

```bash
npx @modelcontextprotocol/inspector
```

### Any client (curl)

Nothing here is specific to one client — anything that can POST with headers works. This example lists the available tools.

```bash
curl -X POST "https://api.kohort.io/api/v1/mcp" \
  -H "Authorization: Bearer kht_<your-api-key>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

## Tools

### `ktrl_list_apps` — List apps

Lists the live apps in your Ktrl account. Use the returned 'id' as the appId argument for the other ktrl tools. In hosts that show interactive views, it also shows the apps as cards the user can pick from.

Takes no arguments.

### `ktrl_get_recommendations` — Get spend recommendations

Latest marketing spend recommendations for an app. Each row has an action (SCALE, HOLD or REDUCE), daily spend, installs, CPI, confidence score and current vs target ROAS per campaign (or per campaign+country). Rows are capped by limit; the response says if it was truncated. In hosts that show interactive views, it also shows the recommendations as cards like the Ktrl web app; picking a card asks for the cohort report of that campaign, and each card links to Ktrl where the chat allows it.

| Argument | Type | Required | Description | Default |
| --- | --- | --- | --- | --- |
| `appId` | integer | yes | App id. Get it from ktrl_list_apps | — |
| `granularity` | 'campaign' \| 'campaign_geo' | no | 'campaign' = one row per campaign with all geos aggregated; 'campaign_geo' = one row per campaign+country (can return many rows) | `campaign` |
| `countries` | string[] | no | Country code filter, e.g. ['US', 'GB']. Only valid with granularity 'campaign_geo' | — |
| `platforms` | string[] | no | Platform filter: 'IOS' and/or 'ANDROID' | — |
| `networks` | string[] | no | Ad network filter, e.g. Facebook, Google | — |
| `limit` | integer | no | Maximum number of rows to return. Rows are sorted by spend, so the top rows matter most | `100` |

### `ktrl_get_report` — Get cohort report

Cohort performance report for an app: spend, installs, CPI, LTV and ROAS per install cohort, broken down by campaign (optionally by country). Rows are capped by limit; the response says if it was truncated — narrow the date range or filters to see the rest. In hosts that show interactive views, it also shows the rows as a chart the user can switch between metrics, filter and download.

| Argument | Type | Required | Description | Default |
| --- | --- | --- | --- | --- |
| `appId` | integer | yes | App id. Get it from ktrl_list_apps | — |
| `granularity` | 'campaign' \| 'campaign_geo' | no | 'campaign' = one row per install cohort + campaign; 'campaign_geo' also splits by country (can return many rows) | `campaign` |
| `metrics` | ('spend' \| 'installs' \| 'cpi' \| 'ltv' \| 'roas')[] | no | Metrics to include. 'ltv' and 'roas' add one column per roasType x revenueType x dsiValue. cpi and ltv are null when installs are 0, and roas is null when spend is 0: undefined, not 0 | `["spend","installs","cpi"]` |
| `roasTypes` | ('PAID' \| 'INCREMENTAL_BLENDED' \| 'FULL_BLENDED')[] | no | PAID = paid traffic only; INCREMENTAL_BLENDED / FULL_BLENDED include organic attribution | `["PAID"]` |
| `revenueTypes` | ('GROSS' \| 'NET')[] | no | GROSS is before fees; NET is net of fees, taxes and refunds | `["GROSS"]` |
| `dsiValues` | integer[] | no | Days-since-install horizons for ltv/roas metrics (max 5). Only used and validated when metrics include 'ltv' or 'roas'; invalid values are rejected with the list of available ones | `[7,365]` |
| `startDate` | string | no | Install cohort start date, YYYY-MM-DD. Default: 30 days before endDate | — |
| `endDate` | string | no | Install cohort end date, YYYY-MM-DD. Default: the app's latest data date | — |
| `countries` | string[] | no | Country code filter, e.g. ['US', 'GB']. Only valid with granularity 'campaign_geo' | — |
| `platforms` | string[] | no | Platform filter: 'IOS' and/or 'ANDROID' | — |
| `networks` | string[] | no | Ad network filter, e.g. Facebook, Google | — |
| `campaigns` | string[] | no | Campaign name filter (exact match, case-sensitive) | — |
| `installGrouping` | 'DAILY' \| 'WEEKLY' \| 'MONTHLY' | no | How install cohorts are bucketed. Default: WEEKLY for apps that require weekly aggregation, DAILY otherwise | — |
| `limit` | integer | no | Maximum number of rows to return | `100` |

### `ktrl_export_report` — Export report file

Builds the full cohort report as a CSV or Parquet file, with no row limit (the same file the REST /v1/report endpoint returns), and replies with a download link that works for 15 minutes. Use it when the user wants the report as a file, and give them the link. For rows to read in the conversation, use ktrl_get_report.

| Argument | Type | Required | Description | Default |
| --- | --- | --- | --- | --- |
| `appId` | integer | yes | App id. Get it from ktrl_list_apps | — |
| `granularity` | 'campaign' \| 'campaign_geo' | no | 'campaign' = one row per install cohort + campaign; 'campaign_geo' also splits by country (can return many rows) | `campaign` |
| `metrics` | ('spend' \| 'installs' \| 'cpi' \| 'ltv' \| 'roas')[] | no | Metrics to include. 'ltv' and 'roas' add one column per roasType x revenueType x dsiValue. cpi and ltv are null when installs are 0, and roas is null when spend is 0: undefined, not 0 | `["spend","installs","cpi"]` |
| `roasTypes` | ('PAID' \| 'INCREMENTAL_BLENDED' \| 'FULL_BLENDED')[] | no | PAID = paid traffic only; INCREMENTAL_BLENDED / FULL_BLENDED include organic attribution | `["PAID"]` |
| `revenueTypes` | ('GROSS' \| 'NET')[] | no | GROSS is before fees; NET is net of fees, taxes and refunds | `["GROSS"]` |
| `dsiValues` | integer[] | no | Days-since-install horizons for ltv/roas metrics (max 5). Only used and validated when metrics include 'ltv' or 'roas'; invalid values are rejected with the list of available ones | `[7,365]` |
| `startDate` | string | no | Install cohort start date, YYYY-MM-DD. Default: 30 days before endDate | — |
| `endDate` | string | no | Install cohort end date, YYYY-MM-DD. Default: the app's latest data date | — |
| `countries` | string[] | no | Country code filter, e.g. ['US', 'GB']. Only valid with granularity 'campaign_geo' | — |
| `platforms` | string[] | no | Platform filter: 'IOS' and/or 'ANDROID' | — |
| `networks` | string[] | no | Ad network filter, e.g. Facebook, Google | — |
| `campaigns` | string[] | no | Campaign name filter (exact match, case-sensitive) | — |
| `installGrouping` | 'DAILY' \| 'WEEKLY' \| 'MONTHLY' | no | How install cohorts are bucketed. Default: WEEKLY for apps that require weekly aggregation, DAILY otherwise | — |
| `format` | 'parquet' \| 'csv' | no | CSV opens in spreadsheets; Parquet is smaller and keeps column types, better for large files | `csv` |

### `ktrl_get_roas_history` — Get ROAS history

How each install cohort's predicted ROAS changed as the cohort aged — a proxy for model stability. Each row is one cohort (per campaign or campaign+country at finer granularities) with roas_age_N = predicted ROAS at the requested DSI when the cohort was N days old; ages with no data are omitted. Start at 'app' granularity, then filter to specific campaigns or countries. Rows are capped by limit and response size; the response says if it was truncated. For bulk data use the /v1/roas-history file export.

| Argument | Type | Required | Description | Default |
| --- | --- | --- | --- | --- |
| `appId` | integer | yes | App id. Get it from ktrl_list_apps | — |
| `granularity` | 'app' \| 'campaign' \| 'campaign_geo' | no | 'app' = one row per install cohort (start here); 'campaign' / 'campaign_geo' split by campaign (and country), only for campaigns in the latest successful recommendation run, and can return many rows | `app` |
| `dsi` | integer | no | Days-since-install horizon the ROAS is measured at. Default: the app's target DSI. Invalid values are rejected with the list of available ones | — |
| `roasType` | 'PAID' \| 'INCREMENTAL_BLENDED' \| 'FULL_BLENDED' | no | PAID = paid traffic only; INCREMENTAL_BLENDED / FULL_BLENDED include organic attribution. Default: the app's target ROAS type | — |
| `revenueType` | 'GROSS' \| 'NET' | no | GROSS is before fees; NET is net of fees, taxes and refunds. Default: the app's target revenue type | — |
| `installGrouping` | 'DAILY' \| 'WEEKLY' \| 'MONTHLY' | no | How install cohorts are bucketed. DAILY is rejected for apps that require weekly aggregation | `WEEKLY` |
| `startDate` | string | no | Install cohort start date, YYYY-MM-DD. Default: 90 days before endDate. Max range 455 days | — |
| `endDate` | string | no | Install cohort end date, YYYY-MM-DD. Default: the app's latest data date | — |
| `countries` | string[] | no | Country code filter, e.g. ['US', 'GB']. Not valid with granularity 'campaign' | — |
| `platforms` | string[] | no | Platform filter: 'IOS' and/or 'ANDROID' | — |
| `networks` | string[] | no | Ad network filter, e.g. Facebook, Google | — |
| `campaigns` | string[] | no | Campaign name filter (exact match, case-sensitive) | — |
| `limit` | integer | no | Maximum number of rows to return. Large responses are also cut by size | `50` |

### `ktrl_get_alerts` — Get alerts

Alerts Ktrl raised about an app's campaigns — what changed and what to do about it. Each row has the alert title, a one-line summary, the campaign, network, platform and country it concerns, the daily spend affected, a fuller explanation and a recommended action. Returns the latest alert run by default; pass startDate for history. Rows are capped by limit; the response says if it was truncated.

| Argument | Type | Required | Description | Default |
| --- | --- | --- | --- | --- |
| `appId` | integer | yes | App id. Get it from ktrl_list_apps | — |
| `alertGroups` | ('BIDS' \| 'ANALYTICS')[] | no | Alert group filter. 'BIDS' = live bid more than ±5% off the paced recommendation; 'ANALYTICS' = performance, confidence and custom-rule alerts. Omit for both | — |
| `countries` | string[] | no | Country codes, e.g. ['US', 'GB'], or 'GLOBAL' for campaign-level alerts (all analytics alerts, plus bid alerts not split by country). Omit for all | — |
| `platforms` | string[] | no | Platform filter: 'IOS', 'ANDROID' and/or 'WEB' | — |
| `networks` | string[] | no | Ad network filter, e.g. Facebook, Google | — |
| `campaigns` | string[] | no | Campaign name filter, exact match | — |
| `startDate` | string | no | Start of the alert date range (YYYY-MM-DD, UTC, inclusive). Omit both dates to get only the latest run, as shown in the Ktrl inbox | — |
| `endDate` | string | no | End of the alert date range (YYYY-MM-DD, UTC, inclusive). Defaults to today; requires startDate | — |
| `limit` | integer | no | Maximum number of rows to return. Rows are sorted newest run first, then by daily spend, so the top rows matter most | `100` |

### `ktrl_get_profile` — Get profile

Identifies the Ktrl account these credentials belong to: a stable id, plus the name and email of the signed-in user when connected through a login rather than an API key.

Takes no arguments.

## Interactive views

In Claude and ChatGPT, three tools also show an interactive view next to the answer. `ktrl_list_apps` shows your apps as cards: pick one to ask for its spend recommendations. `ktrl_get_recommendations` shows each recommendation as a card, as in the Ktrl web app: pick one to see the cohort report of its campaign, or open it in Ktrl where your client opens links; full screen adds filters. `ktrl_get_report` shows the report as a line chart: switch the metric and day, and in full screen filter by campaign, network or platform, then download the rows shown or the full report where your client supports downloads. Clients without interactive views get the same data as text.

## Row limits

Tools that return rows take a `limit` — 100 by default (50 for `ktrl_get_roas_history`), 500 at most. `ktrl_get_report` and `ktrl_get_roas_history` also cut large responses by size. If more rows matched than you got back, the response sets `truncated: true`. When that happens, narrow the date range or add filters rather than raising the limit.

## Errors

| Status | Meaning | Resolution |
| --- | --- | --- |
| 401 | Your key is missing, malformed, or expired. | Check you are sending `Authorization: Bearer kht_<your-api-key>`. |
| 403 | Your key is valid, but it cannot reach the app you asked for — or your user is not connected to a company yet. | Call ktrl_list_apps to see which apps this key can read. |
| 405 | You sent a GET or DELETE to the MCP endpoint. | Use POST — every MCP call is a POST. |
| 429 | You have gone over the rate limit. | Wait for the current minute to pass and retry. The limit is 60 requests per minute. |
| isError result | The call reached the tool, but the tool rejected one of your arguments — an unsupported dsiValue, for example, or a countries filter used with campaign granularity. | Read the error message. It names the argument at fault and, where it can, lists the values you can use instead. |
