# MCP server

> Model Context Protocol server reference for querying proxy logs with AI assistants.

Apoxy exposes an [MCP](https://modelcontextprotocol.io/) server that lets AI assistants query your Envoy proxy access logs using [PRQL](https://prql-lang.org/). Any MCP-compatible client (Claude Code, Claude Desktop, Cursor, etc.) can connect and use the tools described below.

For Claude Code users, the [Apoxy Claude plugin](https://github.com/apoxy-dev/claude-plugin) provides the MCP connection plus a skill that teaches Claude effective query patterns. See the [integration guide](/docs/guides/mcp-claude-code.md) for setup instructions.

## Endpoint

```
https://api.apoxy.dev/mcp
```

The server uses the [StreamableHTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport.

## Authentication

All requests require an API key in the `X-Apoxy-API-Key` header:

```
X-Apoxy-API-Key: <api-key>
```

The `Authorization: Bearer <api-key>` header is also accepted for backwards compatibility, but `X-Apoxy-API-Key` is preferred because some MCP clients interpret the `Authorization` header as an OAuth trigger.

To create an API key, open the [Apoxy console](https://dashboard.apoxy.dev), click your avatar, go to **Account Settings > API Keys**, and create a new key. Keys are scoped to your project. You can have multiple active keys and set expiration dates.

Requests without a valid token receive `401 Unauthorized`.

## Tools

### `describe_schema`

List all available log fields, their types, sources, and descriptions. Call this first to discover what you can query.

**Parameters:** None.

**Response fields:**

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Field name to use in PRQL queries (e.g., `http.response.status_code`). |
| `type` | string | One of `string`, `integer`, `float`, `uuid`, `timestamp`. |
| `source` | string | Where the field lives: `LogAttributes`, `ResourceAttributes`, or empty for top-level columns. |
| `description` | string | Human-readable description. |

### `query`

Execute a PRQL query against your proxy access logs.

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `prql` | string | Yes | PRQL query string. The table source is optional — omit it and start with `filter`, `select`, `derive`, `group`, `sort`, or `take`, or write `from otel_logs` explicitly. |
| `start_time` | string | No | Start of time range (RFC 3339). Defaults to 24 hours ago. |
| `end_time` | string | No | End of time range (RFC 3339). Defaults to now. |
| `cursor` | string | No | Pagination cursor from a previous response. Both `start_time` and `end_time` are required when using a cursor. |

Queries without `take` default to **1000 rows**. The maximum is **10000 rows**; requests above that cap are silently truncated to 10000. Use cursor pagination for larger result sets.

**Response fields:**

| Field | Type | Description |
|-------|------|-------------|
| `rows` | array | Result rows, each containing a `fields` map of column name to value. |
| `columns` | array | Column metadata (`name`, `type`) for each column in the result. |
| `next_cursor` | string | Pagination cursor. Empty if no more results. |
| `total_rows` | integer | Number of rows returned in this page. |
| `compiled_sql` | string | The SQL that was executed (useful for debugging). |

## Log fields

The logs are Envoy access log entries collected via OpenTelemetry. Fields fall into three categories:

**Top-level columns** — Standard OpenTelemetry log fields:

| Field | Type | Description |
|-------|------|-------------|
| `Timestamp` | timestamp | Event timestamp with nanosecond precision. |
| `TimestampTime` | timestamp | Timestamp truncated to seconds — use for grouping to limit cardinality. |
| `Body` | string | Log message body. |
| `ServiceName` | string | Service name. |
| `SeverityText` | string | Log severity text. |
| `SeverityNumber` | integer | Log severity number. |
| `TraceId` | string | Trace correlation ID. |
| `SpanId` | string | Span correlation ID. |
| `TraceFlags` | integer | Trace flags. |
| `EventName` | string | Event name. |

**LogAttributes** — Envoy access log fields (use backtick-quoted names in PRQL):

| Field | Type | Description |
|-------|------|-------------|
| `` `http.request.method` `` | string | HTTP request method. |
| `` `http.response.status_code` `` | integer | HTTP response status code. |
| `` `url.path` `` | string | Request URL path. |
| `` `client.address` `` | string | Client IP:port. |
| `` `upstream.address` `` | string | Upstream host address. |
| `` `upstream.cluster` `` | string | Envoy upstream cluster name. |
| `` `http.request.duration_ms` `` | float | Request duration in milliseconds. |
| `` `http.request.body.size` `` | integer | Request body bytes. |
| `` `http.response.body.size` `` | integer | Response body bytes. |
| `` `http.request.id` `` | uuid | Request UUID. |
| `` `user_agent.original` `` | string | User-Agent header. |
| `` `network.protocol.name` `` | string | Protocol (HTTP/1.1, HTTP/2). |
| `` `url.host` `` | string | Request host header. |
| `` `envoy.response_flags` `` | string | Envoy response flags. |

**ResourceAttributes** — Infrastructure metadata:

| Field | Type | Description |
|-------|------|-------------|
| `` `cluster_name` `` | string | Cluster identifier. |
| `` `log_name` `` | string | Log source name. |
| `` `project_id` `` | uuid | Project UUID. |
| `` `node_name` `` | string | Node UUID. |
| `` `service.name` `` | string | Service name. |
| `` `zone_name` `` | string | Zone identifier. |

Additional fields may be discovered dynamically from the data. Use `describe_schema` to see the current full list.

### How field rewriting works

Log attribute and resource attribute fields are stored as strings internally. The query engine automatically rewrites backtick-quoted field names to the appropriate storage-level access expressions and aliases them back to clean names in your results:

- `` `url.path` `` → accessed from log attributes, returned as `url.path`
- `` `cluster_name` `` → accessed from resource attributes, returned as `cluster_name`

Integer and float fields are automatically cast to their numeric types, so you can use numeric comparisons and aggregations directly. String literals in filter expressions (e.g., `"/api/users"`) are left untouched.

The engine also injects a WHERE condition for project filtering automatically — it appears in `compiled_sql` but doesn't need to be written in your query. Time-range filtering is handled separately; see [Time range](#time-range).

<Callout type="warn">
Reference dotted attributes (`` `http.response.status_code` ``, `` `url.host` ``, etc.) **directly** in `group`, `aggregate`, and similar transforms. Aliasing them — for example ``group {status = `http.response.status_code`} (aggregate {n = count this})`` — defeats the field rewriter, and the query fails at execution with a missing-column error. If you need a friendlier output name, alias the aggregate result instead (`aggregate {requests = count this}`) and keep the dotted name on the grouping key.
</Callout>

## Time range

Control the query window with the `start_time` and `end_time` parameters (RFC 3339). The engine injects `Timestamp >= start_time AND Timestamp <= end_time` into the compiled SQL automatically — you'll see it in `compiled_sql`.

Don't filter `Timestamp` in PRQL with `now` or duration literals. Queries like `filter Timestamp >= (now - 1hours)` are rejected at compile time:

```text
explicit Timestamp filters with 'now' or duration literals are not supported;
use the start_time/end_time query parameters to control the time window
```

Time-range control lives in the API parameters, not in PRQL. That way the window shown in `compiled_sql` always matches what you asked for, and a typo in a relative-time expression can't silently drop the filter.

Static-timestamp filters with literal RFC 3339 values still parse in PRQL, but the injected window also applies, so they only narrow further. In practice, use the parameters.

## Aggregate functions

In a `group {...} (aggregate {...})` query, only these aggregates are supported: `count`, `count_distinct`, `sum`, `avg`, `min`, `max`, `quantile`. Anything else fails at compile time:

```text
unsupported aggregate function "percentile"
```

Common substitutions:

- `average` — use `avg`.
- `percentile`, `median` — use `quantile <p> <col>`, e.g. `quantile 0.5 duration_ms` for the median or `quantile 0.95 duration_ms` for p95. Compiles to ClickHouse `quantile(<p>)(<col>)`. The probability must be a number literal in `(0, 1)`.
- `stddev`, `variance` — not available; pull the rows and compute client-side.

Grouped queries reject unsupported aggregates at compile time with a clear error. Non-grouped queries may instead surface the failure as a runtime error when the query executes.

```text
# p50 / p95 / p99 request latency per minute
filter `http.response.status_code` != null
group {time_bucket Timestamp "1 minute"} (
  aggregate {
    p50 = quantile 0.5 `http.request.duration_ms`,
    p95 = quantile 0.95 `http.request.duration_ms`,
    p99 = quantile 0.99 `http.request.duration_ms`
  }
)
sort {bucket}
```

## Time bucketing

Use `time_bucket` as a group key to aggregate over time intervals. The syntax is:

```text
time_bucket <column> "<N> <unit>"
```

Supported units: `second`, `minute`, `hour`, `day`, `week`, `month` (singular or plural). The result column is always named `bucket`.

```text
# Request count per minute
group {time_bucket Timestamp "1 minute"} (
  aggregate {count = count this}
)
sort {bucket}
```

```text
# Error rate per 5-minute window
filter `http.response.status_code` >= 500
group {time_bucket Timestamp "5 minutes"} (
  aggregate {errors = count this}
)
sort {-bucket}
```

```text
# Requests per hour broken down by path
group {time_bucket Timestamp "1 hour", `url.path`} (
  aggregate {count = count this}
)
sort {bucket}
```

`time_bucket` compiles to ClickHouse's `toStartOfInterval` function. Use `Timestamp` (nanosecond precision) or `TimestampTime` (second precision) as the column argument. `TimestampTime` reduces cardinality but limits minimum bucket granularity to 1 second.

## Numeric coercion

Numeric fields are stored as strings and parsed at query time. Values that don't parse as a number are coerced to `0` — not NULL, not an error. Comparisons like `` filter `http.response.status_code` >= 400 `` work as expected for valid data, but aggregations over a column that contains non-numeric values silently include those zeros. If a numeric column might contain garbage, filter on a well-typed field first.

## Cursor pagination

When a response includes a `next_cursor`, pass it back with the same `prql`, `start_time`, and `end_time` to fetch the next page. The cursor is bound to all three: the engine computes `SHA256(prql + start_time + end_time)[:12]` and rejects any cursor whose hash doesn't match the request.

```text
cursor does not match this query
```

Implications:

- Editing the PRQL — including whitespace — invalidates the cursor.
- `start_time` and `end_time` must be supplied explicitly on every paginated request. Defaulting them produces a different hash on each call and the cursor will be rejected.
- Pagination uses the `Timestamp` column with a tie-breaker for rows that share a timestamp. The tie-break ordering is not stable across pages, so high-throughput data with many identical timestamps can produce small gaps or duplicates at page boundaries. Use `TimestampTime` or another grouping key if you need exact-once iteration.

## Field discovery

`describe_schema` returns the static field list plus any Map keys observed in `LogAttributes`, `ResourceAttributes`, or `ScopeAttributes`. Discovery samples the **last 24 hours** and reads at most **10000 distinct keys per attribute group**. Fields that appear only in older data won't be listed, but they remain queryable by name — discovery affects what's catalogued, not what's accessible.

## PRQL quick reference

Queries are pipelines of transforms. The table source (`otel_logs`) is optional — start directly with transforms, or write `from otel_logs` if you prefer to be explicit.

```text
# Filter 5xx errors in the last hour
filter `http.response.status_code` >= 500
select {Timestamp, `url.path`, `http.response.status_code`}
sort {-Timestamp}
```

```text
# Count requests by status code
group {`http.response.status_code`} (aggregate {count = count this})
```

```text
# Compute a derived column
derive {is_error = `http.response.status_code` >= 400}
select {Timestamp, `url.path`, is_error}
```

```text
# Limit results
sort {-Timestamp}
take 10
```

```text
# Request count per minute over time
group {time_bucket Timestamp "1 minute"} (aggregate {count = count this})
sort {bucket}
```

See the [PRQL language reference](https://prql-lang.org/book/) for the full syntax.

## Rate limits

Rate limits apply per project. Contact support if you need higher limits.
