Shared contract

Conventions across every tool.

The MCP and REST surfaces share one envelope, one error taxonomy, and the same rules for bounds, pagination, provenance, and licensing.

The envelope

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:

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):

{
  "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" }
}

Bounds and token-awareness

Bounds and token-awareness

Results are always bounded (CLAUDE.md: never dump unbounded data into an agent context). The policy is reject an out-of-range limit, never clamp it: a silent clamp lies to the agent about what it asked for and lets the two surfaces drift, whereas a rejection teaches the agent the real contract. Each service module declares DEFAULT_LIMIT/MAX_LIMIT constants sized to the payload, and the bound is enforced in three layers, deliberately:

  1. the MCP tool schema (ge/le on the parameter),
  2. the REST route signature (Query(ge=..., le=...) → 422),
  3. the service function itself (a BadParameterError backstop for any future caller that skips a surface).

Per-tool-class defaults and maxima (units are periods/bars/rows per page):

Tool classDefaultMaxStatus
Statements (stocks_get_income_statements)4 periods12 periodsEnforced today
Metrics (stocks_get_financial_metrics)4 periods12 periodsEnforced today (#25)
Segmented financials (stocks_get_segmented_financials)4 fiscal-year frames12 fiscal-year framesEnforced today (#40)
Screener (stocks_screen)20100Enforced today (#38)
Prices (daily bars)≤1y≈260 barsTarget (#20/#26)
Filings1001000Target (#27)
Filing search (stocks_search_filings)1050Enforced today (#110)
Filing sections (stocks_get_filing_section)10,000 chars25,000 charsEnforced today (#115)
Recent filings (stocks_get_recent_filings)1001000Enforced today (#120)
Corporate actions (stocks_get_corporate_actions)1001000Enforced today (#78)
Beneficial owners (stocks_get_beneficial_owners)20100Enforced today (#206)
Ownership-filing search (stocks_search_ownership_filings)20100Enforced today (#206)
Insider trades (stocks_get_insider_trades)20100Enforced today (#39)
IPO search (stocks_search_ipos)20100Enforced today (#207)
Macro observations100500Target (#33)
Real-estate observations (re_get_series)100500Enforced today (#89)
Recent-updates feed (macro_list_recent_updates)100500Enforced today (#121)
Freshness (platform_get_freshness)50100Enforced today (#119)
Account usage history (GET /account/usage/history)100500Enforced today (#311)
Account usage events (GET /account/usage/events)50200Enforced today
Entity search1050Target (#23)
Real-estate discovery (re_search_geographies, re_search_series)1050Enforced today (#89)
Bank entity search (banks_search_institutions)1050Enforced today (#127)
Bank financials (banks_get_financials)8 quarters40 quartersEnforced today (#127)
Recipient search (gov_search_recipients)1050Enforced today (#171)
Recipient awards (gov_get_recipient_awards)5 fiscal years20 fiscal yearsEnforced today (#171)
Fund search (funds_search_funds)1050Enforced today (#148)
Fund holdings (funds_get_holdings)50 rows200 rowsEnforced today (#148)
Fund holders (stocks_get_fund_holders)25100Enforced today (#149)
Fund proxy votes (funds_get_proxy_votes)25 rows100 rowsEnforced today (#238)
Proxy-voting summary (stocks_get_proxy_voting_summary)2 meetings5 meetingsEnforced today (#239)
Company events (stocks_get_company_events)20100Enforced today (#41)
Event search (stocks_search_events)20100Enforced today (#41)

Pagination

Pagination

Paging is opaque, forward-only, cursor-based (conventions/cursor.py).

  • Cursor anatomy. A cursor is base64url of {"v": 1, "k": [<key parts>]}, where k is the sort-key tuple of the last item on the previous page — not a row id. Url-safe so it survives a query string untouched, versioned so the format can evolve, opaque so callers treat it as a token rather than parsing it. Ordering is newest-first (descending key); the next page is the items whose key sorts strictly before (older than) the cursor.
  • Position, not row id. Because the cursor is a position in a total order, a restatement that inserts or removes items between calls can neither error nor silently skip — the boundary just stays put. This is why the sort key must be a total order (e.g. statements break period_end ties with fiscal year then fiscal period), or limit-truncation would be nondeterministic.
  • has_more / next_cursor. has_more says whether the query matches more items past this page; next_cursor is the token for the next page, and is set when — and only when — more remain (None otherwise).
  • Bad cursors are bad input. A malformed, wrong-version, wrong-shape, or wrong-arity/wrong-type cursor raises BadParameterError (bad_parameter), never a silent empty page. An empty page past the end of a valid query is a normal success, not an error.

Two ways to produce a page, one Pagination shape either way:

  • paginate_sequence(items, *, limit, cursor, key) over an already-sorted (newest-first) in-memory sequence — the right tool for small results the service assembles in Python (e.g. income statements grouped by fiscal frame).
  • SQL keyset (the limit + 1 trick, ordering by the cursor key) for large tables where loading everything into memory is wrong.

Point-in-time reads

Point-in-time reads

as_known_at is the cross-category parameter name for ingestion-time time travel: answer as if asked at instant T, using, for each observation, the newest vintage ingested at or before T. It is opt-in; omitting it preserves today's latest-value view unchanged.

Point-in-time reads fail honestly when T predates the series' vintage-coverage start: out_of_coverage names the earliest supported instant rather than silently falling back to current values. Coverage starts when vintage capture began for the series: the migration-0009-style backfill instant for series that predate capture, or the first ingest for series added later. Values present at capture keep their original ingest as_of, so observations on a page can carry an as_of earlier than vintage_coverage_start. Revisions made before capture were overwritten and are unrecoverable, which is exactly why requests before the boundary are refused rather than answered from an incomplete log.

A known series with no vintage rows fails point-in-time requests with out_of_coverage ("no vintage coverage yet"). In latest mode, an empty page for that same valid series remains a normal success.

In point-in-time mode, meta sets point_in_time: true, echoes the effective as_known_at, and reports vintage_coverage_start.

One platform point-in-time contract has two distinct axes:

  • as_known_at is the ingestion-knowledge axis: what we held at T, available to any category with vintage capture.
  • as_reported is the source-document axis, implemented on the three stocks statement surfaces: values as originally filed, anchored to an accession and before restatements. Set their as_reported parameter to opt in. Before the company's first successful vintage-era statement ingest, this mode fails with out_of_coverage; it never substitutes latest values.

A tool may eventually offer both because they answer different questions. They MUST NOT be conflated or aliased.

Errors

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.

Redistribution gate

Redistribution gate

On the public surface, a payload is served only if every source attribution it carries is registered as publicly redistributable. Tools whose primary source is restricted refuse with out_of_license. Tools that mix restricted and clean sources degrade by excluding restricted-source values at query/build time; meta.restricted_omitted then lists the distinct restricted source tags this request actually excluded (null on the internal surface or when nothing was excluded). The envelope layer independently re-checks the final payload and refuses anything that still carries a restricted or unknown source — a degradation bug fails closed, never leaks. Restricted-derived values that carry no source attribution of their own (the company profile's price-coverage span) are gated in the service, the same degrade-and-disclose way.

Fields literally named source are collected recursively and checked. A model whose source field is descriptive metadata about a source, rather than data from that source, may opt that field out via SOURCE_TAG_EXCLUDE; the first case is DatasetFreshness.source in the freshness registry, which also carries an explicit non_redistributable flag. The default remains fail-closed.

API-key credentials declare their surface as internal or public. Local stdio MCP and direct service calls default to internal; hosted HTTP traffic runs stateless and takes the surface of the key on each request — tool calls never inherit another request's identity via session state.