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 (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" }
}
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:
- the MCP tool schema (
ge/leon the parameter), - the REST route signature (
Query(ge=..., le=...)→ 422), - the service function itself (a
BadParameterErrorbackstop for any future caller that skips a surface).
Per-tool-class defaults and maxima (units are periods/bars/rows per page):
| Tool class | Default | Max | Status |
|---|---|---|---|
Statements (stocks_get_income_statements) | 4 periods | 12 periods | Enforced today |
Metrics (stocks_get_financial_metrics) | 4 periods | 12 periods | Enforced today (#25) |
Segmented financials (stocks_get_segmented_financials) | 4 fiscal-year frames | 12 fiscal-year frames | Enforced today (#40) |
Screener (stocks_screen) | 20 | 100 | Enforced today (#38) |
| Prices (daily bars) | ≤1y | ≈260 bars | Target (#20/#26) |
| Filings | 100 | 1000 | Target (#27) |
Filing search (stocks_search_filings) | 10 | 50 | Enforced today (#110) |
Filing sections (stocks_get_filing_section) | 10,000 chars | 25,000 chars | Enforced today (#115) |
Recent filings (stocks_get_recent_filings) | 100 | 1000 | Enforced today (#120) |
Corporate actions (stocks_get_corporate_actions) | 100 | 1000 | Enforced today (#78) |
Beneficial owners (stocks_get_beneficial_owners) | 20 | 100 | Enforced today (#206) |
Ownership-filing search (stocks_search_ownership_filings) | 20 | 100 | Enforced today (#206) |
Insider trades (stocks_get_insider_trades) | 20 | 100 | Enforced today (#39) |
IPO search (stocks_search_ipos) | 20 | 100 | Enforced today (#207) |
| Macro observations | 100 | 500 | Target (#33) |
Real-estate observations (re_get_series) | 100 | 500 | Enforced today (#89) |
Recent-updates feed (macro_list_recent_updates) | 100 | 500 | Enforced today (#121) |
Freshness (platform_get_freshness) | 50 | 100 | Enforced today (#119) |
Account usage history (GET /account/usage/history) | 100 | 500 | Enforced today (#311) |
Account usage events (GET /account/usage/events) | 50 | 200 | Enforced today |
| Entity search | 10 | 50 | Target (#23) |
Real-estate discovery (re_search_geographies, re_search_series) | 10 | 50 | Enforced today (#89) |
Bank entity search (banks_search_institutions) | 10 | 50 | Enforced today (#127) |
Bank financials (banks_get_financials) | 8 quarters | 40 quarters | Enforced today (#127) |
Recipient search (gov_search_recipients) | 10 | 50 | Enforced today (#171) |
Recipient awards (gov_get_recipient_awards) | 5 fiscal years | 20 fiscal years | Enforced today (#171) |
Fund search (funds_search_funds) | 10 | 50 | Enforced today (#148) |
Fund holdings (funds_get_holdings) | 50 rows | 200 rows | Enforced today (#148) |
Fund holders (stocks_get_fund_holders) | 25 | 100 | Enforced today (#149) |
Fund proxy votes (funds_get_proxy_votes) | 25 rows | 100 rows | Enforced today (#238) |
Proxy-voting summary (stocks_get_proxy_voting_summary) | 2 meetings | 5 meetings | Enforced today (#239) |
Company events (stocks_get_company_events) | 20 | 100 | Enforced today (#41) |
Event search (stocks_search_events) | 20 | 100 | Enforced 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>]}, wherekis 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_endties with fiscal year then fiscal period), or limit-truncation would be nondeterministic. has_more/next_cursor.has_moresays whether the query matches more items past this page;next_cursoris the token for the next page, and is set when — and only when — more remain (Noneotherwise).- 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 + 1trick, 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_atis the ingestion-knowledge axis: what we held at T, available to any category with vintage capture.as_reportedis the source-document axis, implemented on the three stocks statement surfaces: values as originally filed, anchored to an accession and before restatements. Set theiras_reportedparameter to opt in. Before the company's first successful vintage-era statement ingest, this mode fails without_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.
| 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.
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.