Apoxy

MCP Server

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

Apoxy exposes an MCP server that lets AI assistants query your Envoy proxy access logs using PRQL. 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 provides the MCP connection plus a skill that teaches Claude effective query patterns. See the integration guide for setup instructions.

Endpoint

https://api.apoxy.dev/mcp

The server uses the StreamableHTTP 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, 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:

FieldTypeDescription
namestringField name to use in PRQL queries (e.g., http.response.status_code).
typestringOne of string, integer, float, uuid, timestamp.
sourcestringWhere the field lives: LogAttributes, ResourceAttributes, or empty for top-level columns.
descriptionstringHuman-readable description.

query

Execute a PRQL query against your proxy access logs.

Parameters:

ParameterTypeRequiredDescription
prqlstringYesPRQL 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_timestringNoStart of time range (RFC 3339). Defaults to 24 hours ago.
end_timestringNoEnd of time range (RFC 3339). Defaults to now.
cursorstringNoPagination 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:

FieldTypeDescription
rowsarrayResult rows, each containing a fields map of column name to value.
columnsarrayColumn metadata (name, type) for each column in the result.
next_cursorstringPagination cursor. Empty if no more results.
total_rowsintegerNumber of rows returned in this page.
compiled_sqlstringThe 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:

FieldTypeDescription
TimestamptimestampEvent timestamp with nanosecond precision.
TimestampTimetimestampTimestamp truncated to seconds — use for grouping to limit cardinality.
BodystringLog message body.
ServiceNamestringService name.
SeverityTextstringLog severity text.
SeverityNumberintegerLog severity number.
TraceIdstringTrace correlation ID.
SpanIdstringSpan correlation ID.
TraceFlagsintegerTrace flags.
EventNamestringEvent name.

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

FieldTypeDescription
`http.request.method`stringHTTP request method.
`http.response.status_code`integerHTTP response status code.
`url.path`stringRequest URL path.
`client.address`stringClient IP:port.
`upstream.address`stringUpstream host address.
`upstream.cluster`stringEnvoy upstream cluster name.
`http.request.duration_ms`floatRequest duration in milliseconds.
`http.request.body.size`integerRequest body bytes.
`http.response.body.size`integerResponse body bytes.
`http.request.id`uuidRequest UUID.
`user_agent.original`stringUser-Agent header.
`network.protocol.name`stringProtocol (HTTP/1.1, HTTP/2).
`url.host`stringRequest host header.
`envoy.response_flags`stringEnvoy response flags.

ResourceAttributes — Infrastructure metadata:

FieldTypeDescription
`cluster_name`stringCluster identifier.
`log_name`stringLog source name.
`project_id`uuidProject UUID.
`node_name`stringNode UUID.
`service.name`stringService name.
`zone_name`stringZone 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.

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.

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:

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. Anything else fails at compile time:

unsupported aggregate function "percentile"

Common substitutions:

  • average — use avg.
  • median, percentile, stddev, variance — not available; sort and inspect, or 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.

Time bucketing

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

time_bucket <column> "<N> <unit>"

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

# Request count per minute
group {time_bucket Timestamp "1 minute"} (
  aggregate {count = count this}
)
sort {bucket}
# Error rate per 5-minute window
filter `http.response.status_code` >= 500
group {time_bucket Timestamp "5 minutes"} (
  aggregate {errors = count this}
)
sort {-bucket}
# 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.

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.

# Filter 5xx errors in the last hour
filter `http.response.status_code` >= 500
select {Timestamp, `url.path`, `http.response.status_code`}
sort {-Timestamp}
# Count requests by status code
group {`http.response.status_code`} (aggregate {count = count this})
# Compute a derived column
derive {is_error = `http.response.status_code` >= 400}
select {Timestamp, `url.path`, is_error}
# Limit results
sort {-Timestamp}
take 10
# Request count per minute over time
group {time_bucket Timestamp "1 minute"} (aggregate {count = count this})
sort {bucket}

See the PRQL language reference for the full syntax.

Rate limits

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

On this page