Errors and the envelope
The response contract as a lookup: what a successful payload is shaped like, and what every failure an agent can provoke means.
Nothing on this page is written by hand. The envelope and the taxonomy below are the same generated sections that make up Conventions, re-presented for the moment you actually need them — when a call just came back and you have to decide what to do next. Read the conventions document end to end for the reasoning; read this page to look one thing up.
Two rules carry most of the weight. Branch on code, never on message text: the code is stable and the message is written for a human reading an agent transcript. And an empty result is not an error: a valid entity with nothing to report is a normal success with an empty list, which is why "no rows" and "no such entity" are things you can tell apart.
Every error code
One row per machine code, with the HTTP status the REST surface returns and the form the same failure takes over MCP. Read off the generated taxonomy below.
| Code | HTTP | Exception | MCP rendering |
|---|---|---|---|
| unauthorized | 401 | UnauthorizedError | emitted by auth middleware before tool dispatch |
| bad_parameter | 422 | BadParameterError | [bad_parameter] <message> |
| unknown_entity | 404 | UnknownEntityError | [unknown_entity] <message> (+ Did you mean: …? when suggestions set) |
| out_of_coverage | 404 | OutOfCoverageError | [out_of_coverage] <message> |
| out_of_license | 403 | OutOfLicenseError | [out_of_license] <message> |
| rate_limited | 429 | RateLimitedError | emitted by auth middleware before tool dispatch (+ Retry-After) |
| quota_exceeded | 429 | QuotaExceededError | emitted by auth middleware before tool dispatch (+ Retry-After, reset_at) |
| upstream_stale | 503 | UpstreamStaleError | [upstream_stale] <message> |
| upstream_rate_limited | 503 | UpstreamRateLimitedError | [upstream_rate_limited] <message> (+ Retry after Ns.) |
| unavailable | 503 | (infra: OperationalError) | [unavailable] database unreachable — … |
The same taxonomy in prose, with the reasoning behind each distinction, is in Conventions.
The envelope
Every payload is an Envelope[DataT, MetaT: Meta]
(conventions/envelope.py) with three standardized regions:
data— the answer, plus the identity/echo context an agent needs to confirm what it got (tickerin canonical form,cik,company_name, requestedperiod_type). Bounded (see below), newest-first for time-ordered results.meta— provenance uniform for the whole result. The baseMetacarriessource(a stable connector identifier, e.g.sec_edgar_companyfacts) andas_of(the freshest value-levelas_ofon the page;Nonewhen the page is empty). Per-toolMetasubclasses add the constant attribution fields —currency,unit,delay,non_redistributable— where they hold for every value in the result.pagination— aPagination(limit,has_more,next_cursor), ornullfor scalar tools that return a single object rather than a bounded list.
A concrete tool declares a named subclass so the schema keeps a stable model name for FastMCP output-schema and FastAPI response-model generation:
class IncomeStatementsResult(Envelope[IncomeStatementsData, Meta]): ...Per-value attribution (unit/currency/as_of/provenance such as
accession_number) still rides on the individual values inside data —
meta carries only what is constant across the result. Numeric values remain
Decimal serialized as JSON strings (exact, no float rounding; consumers
parse them), and absence is a missing key — never zero, never a
meaning-bearing null.
Multi-connector canonical datasets
Some tools serve a canonical dataset assembled from more than one connector.
The first case is corporate_actions: meta.source is the stable dataset
identifier (corporate_actions), while each value's source carries connector
attribution and is the licensing authority. The #80 redistribution gate must
evaluate per-value sources for these tools. meta.non_redistributable is true
when any value on the page is restricted, matching the conservative direction
used by stocks_get_financial_metrics (#25). Typed-event models serialize
inapplicable fields as null, FilingSummary-style; the "absence is a missing
key" rule governs the lines-dict numeric-value idiom, not typed-record fields.
Example (stocks_get_income_statements):
{
"data": {
"ticker": "AAPL",
"cik": 320193,
"company_name": "Apple Inc.",
"period_type": "annual",
"statements": [
{
"fiscal_year": 2024,
"fiscal_period": "FY",
"period_type": "annual",
"period_start": "2023-10-01",
"period_end": "2024-09-28",
"lines": {
"revenue": {
"value": "391035000000.000000",
"unit": "USD",
"currency": "USD",
"as_of": "2024-11-01T00:00:00Z",
"accession_number": "0000320193-24-000123"
}
}
}
]
},
"meta": { "source": "sec_edgar_companyfacts", "as_of": "2024-11-01T00:00:00Z" },
"pagination": { "limit": 4, "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjpbLi4uXX0" }
}Partial results for batch tools
Batch tools that accept multiple entities resolve each input independently
rather than discarding the whole response when one item is unknown. Unresolved
items are reported inside data — for line-item search, as
unresolved_tickers (including suggestions when available) and
unknown_line_items. Even an all-unresolved call is a successful envelope with
empty results and the complete per-item report (#256).
Errors
Every tool-surface failure an agent can provoke is one of a small, fixed set of
categories (conventions/errors.py), each with a stable machine code and an
agent-actionable message. The same exception hierarchy feeds both surfaces:
conventions.mcp.tool_error_boundary maps it to a FastMCP ToolError, and
conventions.http.register_error_handlers maps it to a REST response, so an
agent sees the same information whichever surface it called.
| Exception | code | HTTP | MCP rendering |
|---|---|---|---|
UnauthorizedError | unauthorized | 401 | emitted by auth middleware before tool dispatch |
BadParameterError | bad_parameter | 422 | [bad_parameter] <message> |
UnknownEntityError | unknown_entity | 404 | [unknown_entity] <message> (+ Did you mean: …? when suggestions set) |
OutOfCoverageError | out_of_coverage | 404 | [out_of_coverage] <message> |
OutOfLicenseError | out_of_license | 403 | [out_of_license] <message> |
RateLimitedError | rate_limited | 429 | emitted by auth middleware before tool dispatch (+ Retry-After) |
QuotaExceededError | quota_exceeded | 429 | emitted by auth middleware before tool dispatch (+ Retry-After, reset_at) |
UpstreamStaleError | upstream_stale | 503 | [upstream_stale] <message> |
UpstreamRateLimitedError | upstream_rate_limited | 503 | [upstream_rate_limited] <message> (+ Retry after Ns.) |
(infra: OperationalError) | unavailable | 503 | [unavailable] database unreachable — … |
Notes on the rows:
unauthorized,rate_limited, andquota_exceededare emitted by the API-key auth middleware on both REST and hosted streamable-HTTP MCP requests before the request reaches either surface's handlers. The body still uses the canonical{"error": {...}}envelope because outer middleware is not covered by the normal FastAPI exception handlers.unknown_entityis "well-formed identifier, nothing we hold" (wrong symbol);out_of_coverageis "entity known, but we don't ingest that class of data for it" — kept distinct so an agent can tell the two apart.suggestions(did-you-mean) lives on the base error and is reserved for entity search (#23).rate_limitedis our-surface throttling (per API key). It is distinct fromupstream_rate_limited, which means a data provider throttled one of our connectors.quota_exceededis a per-account request budget across all owned API keys and attributed OAuth traffic, not a per-key burst limit. Its window is the UTC calendar month;reset_atis the first instant of the next UTC month. It is distinct fromrate_limited, which controls short-lived per-key or per-subject request bursts.upstream_rate_limitedsubclassesupstream_staleand carriesretry_after(seconds); REST also emits aRetry-Afterheader, MCP appendsRetry after Ns.to the tool-error line.unavailablehas no exception class of its own — it is the code the REST handler stamps on an infrastructureOperationalError(database unreachable). It is translated for agents rather than masked because the stdio server and the REST app both start fine without a database, so a dead database first surfaces as a query-time error the agent can act on.
The REST error body is
{"error": {code, message, suggestions?, retry_after?, reset_at?}}
(serialized with exclude_none, so an error only shows the optional fields it
uses). MCP folds the same fields into the ToolError string via
render_tool_error — the code in brackets, then the message, then any
suggestions/retry_after/reset_at context.
Two carve-outs are deliberate:
- FastAPI's own request-schema 422s keep their native
{"detail": [...]}shape. They are rejected by the protocol layer before our handlers ever run — symmetric with MCP's schema layer rejecting the same class of input. - A service-raised
pydantic.ValidationErrorgets no handler and 500s loudly. A row failing its result model is a data-integrity failure, not caller error; the MCP boundary re-raisesValidationErrorbefore itsValueErrorarm could mask it (and the server'smask_error_details=Truekeeps internals out of the agent context). Integrity failures stay loud.
An empty result set for a valid entity (e.g. no TTM rows yet) is a normal 200/success with an empty list — honest, not an error.