Skip to content

Errors & pagination

Reference

Greenlight’s MCP tools and webhooks use the same conventions for errors and pagination. One page; every surface works the same way.

Every error response — REST, MCP, webhook delivery failure — uses this flat shape:

{
"code": "app.slug_in_use",
"message": "An app with slug 'expense-tracker' already exists in this organization.",
"details": { "slug": "expense-tracker" },
"next_steps": "Pick a different slug, or ask the current owner to add you as a co-owner.",
"request_id": "req_01HXYZ4M2P"
}
  • code is stable across versions and uses <domain>.<reason> snake_case. Clients should switch on code, not on message.
  • message is human-readable, one sentence. Greenlight reserves the right to improve wording.
  • details (optional) carries structured, code-specific context — which field failed validation, which check failed in the policy gate, and so on.
  • next_steps (optional) is an actionable hint for the caller.
  • request_id is unique per request. Including it in a support ticket gets to the root cause fastest.

Over MCP the same body comes back as JSON text inside content, with isError: true and no structuredContent — a tool’s advertised output schema describes its success payload, so an error body (which doesn’t match that shape) never goes in structuredContent. Parse content[0].text as JSON to get code / message / next_steps / request_id.

HTTPWhen
400Input failed validation, or a connected-database query/response shape was rejected. details identifies ownership and retry safety for SQL calls.
401No valid session, bearer token, or required actor token. details.issuer identifies the configured identity provider when known. Code is auth.token_invalid (missing/malformed) or auth.token_expired (TTL elapsed — re-authenticate).
403Authenticated, but the caller isn’t authorized for this action.
404The target resource doesn’t exist or the caller can’t see it.
409The action conflicts with current state (e.g. a slug in use, or a stale Knowledge base_version).
429The caller’s bucket is exhausted. Retry-After indicates when to retry. details.retry_after_ms carries the same value in milliseconds.
413The explicit request-body cap was exceeded (validation.body_too_large).
499The caller disconnected while connected-database work was queued or active. This is a Greenlight response code for observability, not proof that a write rolled back.
500A platform error. The audit log captures these; report with the request_id.
502An external dependency or its network/auth path failed. Retry only when the code’s contract says the operation is replay-safe; SQL errors state this explicitly.
503A bounded platform resource is temporarily full. The SQL gateway returns proxy.query_capacity with Retry-After.

The most common stable codes. Not exhaustive; the full set lives in the platform’s shared error module.

CodeMeaning
app.slug_in_useAnother app in the org already owns this slug.
app.not_foundThe app doesn’t exist or the caller can’t see it.
app.not_ownedAuthenticated, but the caller isn’t an owner or co-owner of this app.
app.invalid_slugSlug doesn’t match the required pattern.
app.not_deployedThe app has no live deployment URL yet (e.g. a preview URL was requested before first deploy).
app.unreachablecurlApp could not complete an HTTP response from the deployed app. Check diagnostics and logs; details.hit_app says whether a response began before the failure.
permission.scope_deniedProxy call to an integration the app isn’t granted.
permission.pendingThe app used an integration whose grant is still in IT review.
proxy.actor_token_requiredA user-delegated integration call omitted the actor token.
proxy.query_failedA connected-database /query statement was rejected by the database itself (syntax, permission, cast); the message is the database’s own.
proxy.query_timeoutA connected-database /query statement exceeded the execution-time cap. Narrow it; do not replay a possible write based on this code alone.
proxy.query_result_unsupportedA query returned a second result grid, too many columns, or an unsafe/unbounded native type. Reshape the result or cast to a bounded scalar type.
proxy.query_capacityThe SQL execution bulkhead is full. The statement did not start; honor Retry-After.
proxy.query_canceledThe caller disconnected while SQL work was queued or active. Inspect execution_outcome before reasoning about a write.
proxy.upstream_unreachableThe connected database or another upstream could not be reached. SQL responses mask the driver message and carry structured ownership/outcome fields.
internal.audit_unavailableGreenlight could not persist the required SQL audit event. The database operation may already have committed.
internal.unexpectedUnexpected platform fault; internal detail is masked. Report the request_id.
ai.model_deniedThe AI request used a model alias not granted to the app.
auth.session_expiredThe session token expired or was revoked.
auth.token_invalidNo valid bearer token or the token is malformed/revoked.
auth.token_expiredThe bearer token’s TTL elapsed; re-authenticate via the agent plugin.
rate_limit.exceededThe caller’s bucket is exhausted.
env.reserved_nameenvSet tried to set a platform-owned name (e.g. DATABASE_URL).
env.policy_deniedOrg policy blocked the env operation.
validation.body_invalidRequest body failed validation; details carries the issue path.

Every Azure SQL gateway error adds a stable decision record under details:

FieldMeaning
fault_originThe responsible boundary: request (caller SQL/shape), policy (grant or database-role denial), connected_database (the upstream database), platform (Greenlight), or unknown when the boundary cannot prove responsibility.
retryableThe same logical operation could plausibly succeed later. This is not permission to replay it.
safe_to_retryReplaying the identical request cannot duplicate a side effect. Automatically retry only when this and retryable are both true.
execution_outcomenot_started or unknown. Once execution may have begun, Greenlight does not claim commit or rollback certainty.
database_codeThe SQL Server error number when available. Switch on this field, not database-message prose.

This is how a client distinguishes platform fault from its own fault: fault_origin: "platform" means Greenlight failed; "request" means the submitted query or result shape needs correction; "policy" means the operation is not allowed; and "connected_database" identifies the external database. "unknown" is intentional when a transport boundary cannot assign blame safely. The same request_id appears in the error body and X-Request-Id response header and correlates to Greenlight logs and audit.

Mutating MCP tools accept an idempotency_key. A retried call with the same key and payload returns the original response rather than performing the work twice. Use a fresh UUID v4 per logical operation, and reuse the same one only for retries of that operation.

List endpoints — REST and MCP — use opaque cursor pagination, not offset/limit. Offsets are unsafe under concurrent inserts (they double-count or skip) and get expensive at large offsets. They accept limit (default 20, max 100) and cursor; the response includes next_cursor, or null on the last page.

{
"items": [ /* … */ ],
"next_cursor": "eyJrIjoiMjAyNi0wMy0xMlQxNDowODoxMVoiLCJpZCI6IjhhM2IxYzRmIn0="
}

To paginate forward, pass next_cursor back as cursor. To start from the beginning, omit cursor. There is no offset parameter.

Cursors are opaque: they encode the position needed to resume the query, but the encoding is not part of the API contract. Do not parse a cursor; do not generate one.

Each list has a defined sort key — documented per endpoint in the platform API conventions — with a stable tiebreaker so an item appears exactly once during a sustained pagination even as others are added concurrently. Many lists are newest-first by creation time; the apps catalog is newest-deploy first (never-deployed apps last). The cursor encodes that position; you never sort client-side.