Shared contract

[10 codes]View as Markdown

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.

CodeHTTPExceptionMCP rendering
unauthorized401UnauthorizedErroremitted by auth middleware before tool dispatch
bad_parameter422BadParameterError[bad_parameter] <message>
unknown_entity404UnknownEntityError[unknown_entity] <message> (+ Did you mean: …? when suggestions set)
out_of_coverage404OutOfCoverageError[out_of_coverage] <message>
out_of_license403OutOfLicenseError[out_of_license] <message>
rate_limited429RateLimitedErroremitted by auth middleware before tool dispatch (+ Retry-After)
quota_exceeded429QuotaExceededErroremitted by auth middleware before tool dispatch (+ Retry-After, reset_at)
upstream_stale503UpstreamStaleError[upstream_stale] <message>
upstream_rate_limited503UpstreamRateLimitedError[upstream_rate_limited] <message> (+ Retry after Ns.)
unavailable503(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 (ticker in canonical form, cik, company_name, requested period_type). Bounded (see below), newest-first for time-ordered results.
  • meta — provenance uniform for the whole result. The base Meta carries source (a stable connector identifier, e.g. sec_edgar_companyfacts) and as_of (the freshest value-level as_of on the page; None when the page is empty). Per-tool Meta subclasses add the constant attribution fields — currency, unit, delay, non_redistributable — where they hold for every value in the result.
  • pagination — a Pagination (limit, has_more, next_cursor), or null for 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:

[ PYTHON ]
class IncomeStatementsResult(Envelope[IncomeStatementsData, Meta]): ...

Per-value attribution (unit/currency/as_of/provenance such as accession_number) still rides on the individual values inside datameta 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):

[ JSON ]
{
  "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.

ExceptioncodeHTTPMCP rendering
UnauthorizedErrorunauthorized401emitted by auth middleware before tool dispatch
BadParameterErrorbad_parameter422[bad_parameter] <message>
UnknownEntityErrorunknown_entity404[unknown_entity] <message> (+ Did you mean: …? when suggestions set)
OutOfCoverageErrorout_of_coverage404[out_of_coverage] <message>
OutOfLicenseErrorout_of_license403[out_of_license] <message>
RateLimitedErrorrate_limited429emitted by auth middleware before tool dispatch (+ Retry-After)
QuotaExceededErrorquota_exceeded429emitted by auth middleware before tool dispatch (+ Retry-After, reset_at)
UpstreamStaleErrorupstream_stale503[upstream_stale] <message>
UpstreamRateLimitedErrorupstream_rate_limited503[upstream_rate_limited] <message> (+ Retry after Ns.)
(infra: OperationalError)unavailable503[unavailable] database unreachable — …

Notes on the rows:

  • unauthorized, rate_limited, and quota_exceeded are 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_entity is "well-formed identifier, nothing we hold" (wrong symbol); out_of_coverage is "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_limited is our-surface throttling (per API key). It is distinct from upstream_rate_limited, which means a data provider throttled one of our connectors.
  • quota_exceeded is 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_at is the first instant of the next UTC month. It is distinct from rate_limited, which controls short-lived per-key or per-subject request bursts.
  • upstream_rate_limited subclasses upstream_stale and carries retry_after (seconds); REST also emits a Retry-After header, MCP appends Retry after Ns. to the tool-error line.
  • unavailable has no exception class of its own — it is the code the REST handler stamps on an infrastructure OperationalError (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.ValidationError gets no handler and 500s loudly. A row failing its result model is a data-integrity failure, not caller error; the MCP boundary re-raises ValidationError before its ValueError arm could mask it (and the server's mask_error_details=True keeps 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.